mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 10:38:32 +08:00
test(web): remove low-value unit tests (#39193)
This commit is contained in:
parent
af4b65b295
commit
5fd06fafe0
@ -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
|
||||
|
||||
@ -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 ? (
|
||||
<div data-testid="rename-dataset-modal">
|
||||
<button type="button" onClick={onSuccess}>
|
||||
rename-success
|
||||
</button>
|
||||
<button type="button" onClick={onClose}>
|
||||
rename-close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
|
||||
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Dataset Name',
|
||||
indexing_status: 'completed',
|
||||
icon_info: {
|
||||
icon: '📙',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_type: 'emoji',
|
||||
icon_url: '',
|
||||
},
|
||||
description: 'Dataset description',
|
||||
permission: DatasetPermission.onlyMe,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: 'high_quality' as DataSet['indexing_technique'],
|
||||
created_by: 'user-1',
|
||||
updated_by: 'user-1',
|
||||
updated_at: 1690000000,
|
||||
app_count: 0,
|
||||
doc_form: ChunkingMode.text,
|
||||
document_count: 1,
|
||||
total_document_count: 1,
|
||||
word_count: 1000,
|
||||
provider: 'internal',
|
||||
embedding_model: 'text-embedding-3',
|
||||
embedding_model_provider: 'openai',
|
||||
embedding_available: true,
|
||||
retrieval_model_dict: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
top_k: 5,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
},
|
||||
retrieval_model: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
top_k: 5,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
},
|
||||
tags: [],
|
||||
external_knowledge_info: {
|
||||
external_knowledge_id: '',
|
||||
external_knowledge_api_id: '',
|
||||
external_knowledge_api_name: '',
|
||||
external_knowledge_api_endpoint: '',
|
||||
},
|
||||
external_retrieval_model: {
|
||||
top_k: 0,
|
||||
score_threshold: 0,
|
||||
score_threshold_enabled: false,
|
||||
},
|
||||
built_in_field_enabled: false,
|
||||
runtime_mode: 'rag_pipeline',
|
||||
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(<DatasetInfo expand />)
|
||||
|
||||
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(<DatasetInfo expand />)
|
||||
|
||||
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(<DatasetInfo expand />)
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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<string, unknown>) => 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<void>; onClose: () => void }) => (
|
||||
<div data-testid="access-control-modal">
|
||||
<button type="button" onClick={() => void onConfirm()}>
|
||||
confirm-access-control
|
||||
</button>
|
||||
<button type="button" onClick={onClose}>
|
||||
close-access-control
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
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(<AppPublisher publishedAt={1700000000} />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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<string, unknown>) => 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 ? (
|
||||
<div data-testid="embedded-modal">
|
||||
<button onClick={onClose}>close-embedded</button>
|
||||
</div>
|
||||
) : 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 = () => <div data-testid="app-access-control" />
|
||||
|
||||
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<string>, 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(<AppPublisher publishedAt={1700000000} onPublish={onPublish} />)
|
||||
|
||||
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(<AppPublisher publishedAt={1700000000} />)
|
||||
|
||||
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(<AppPublisher publishedAt={1700000000} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.openInExplore(?=$|:)/))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/(?:^|\.)notPublishedYet(?=$|:)/),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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<string, unknown>) =>
|
||||
toastMocks.mockNotify({ type: 'success', message, ...options }),
|
||||
error: (message: string, options?: Record<string, unknown>) =>
|
||||
toastMocks.mockNotify({ type: 'error', message, ...options }),
|
||||
warning: (message: string, options?: Record<string, unknown>) =>
|
||||
toastMocks.mockNotify({ type: 'warning', message, ...options }),
|
||||
info: (message: string, options?: Record<string, unknown>) =>
|
||||
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<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useQuery: () => ({
|
||||
data: [],
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/next/dynamic', () => ({
|
||||
default: (loader: () => Promise<React.ComponentType | { default: React.ComponentType }>) => {
|
||||
let Component: React.ComponentType<Record<string, unknown>> | null = null
|
||||
loader()
|
||||
.then((mod) => {
|
||||
Component = (typeof mod === 'function' ? mod : mod.default) as React.ComponentType<
|
||||
Record<string, unknown>
|
||||
>
|
||||
})
|
||||
.catch(() => {})
|
||||
const Wrapper = (props: Record<string, unknown>) => {
|
||||
if (Component) return <Component {...props} />
|
||||
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<string, unknown>) => {
|
||||
if (!show) return null
|
||||
return (
|
||||
<div data-testid="edit-app-modal">
|
||||
<span data-testid="modal-app-name">{appName as string}</span>
|
||||
<button
|
||||
data-testid="confirm-edit"
|
||||
onClick={() =>
|
||||
(onConfirm as (data: Record<string, unknown>) => void)({
|
||||
name: 'Updated App Name',
|
||||
icon_type: 'emoji',
|
||||
icon: '🔥',
|
||||
icon_background: '#fff',
|
||||
description: 'Updated description',
|
||||
})
|
||||
}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button data-testid="cancel-edit" onClick={onHide as () => void}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/duplicate-modal', () => ({
|
||||
default: ({ show, onConfirm, onHide }: Record<string, unknown>) => {
|
||||
if (!show) return null
|
||||
return (
|
||||
<div data-testid="duplicate-app-modal">
|
||||
<button
|
||||
data-testid="confirm-duplicate"
|
||||
onClick={() =>
|
||||
(onConfirm as (data: Record<string, unknown>) => void)({
|
||||
name: 'Copied App',
|
||||
icon_type: 'emoji',
|
||||
icon: '📋',
|
||||
icon_background: '#fff',
|
||||
})
|
||||
}
|
||||
>
|
||||
Confirm Duplicate
|
||||
</button>
|
||||
<button data-testid="cancel-duplicate" onClick={onHide as () => void}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/switch-app-modal', () => ({
|
||||
default: ({ show, onClose, onSuccess }: Record<string, unknown>) => {
|
||||
if (!show) return null
|
||||
return (
|
||||
<div data-testid="switch-app-modal">
|
||||
<button data-testid="confirm-switch" onClick={onSuccess as () => void}>
|
||||
Confirm Switch
|
||||
</button>
|
||||
<button data-testid="cancel-switch" onClick={onClose as () => void}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
|
||||
default: ({ onConfirm, onClose }: Record<string, unknown>) => (
|
||||
<div data-testid="dsl-export-confirm-modal">
|
||||
<button
|
||||
data-testid="export-include"
|
||||
onClick={() => (onConfirm as (include: boolean) => void)(true)}
|
||||
>
|
||||
Include
|
||||
</button>
|
||||
<button data-testid="export-close" onClick={onClose as () => void}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/app-access-control', () => {
|
||||
const MockAccessControl = ({ onConfirm, onClose }: Record<string, unknown>) => (
|
||||
<div data-testid="access-control-modal">
|
||||
<button data-testid="confirm-access" onClick={onConfirm as () => void}>
|
||||
Confirm
|
||||
</button>
|
||||
<button data-testid="cancel-access" onClick={onClose as () => void}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return {
|
||||
default: MockAccessControl,
|
||||
AccessControl: MockAccessControl,
|
||||
}
|
||||
})
|
||||
|
||||
const createMockApp = (overrides: Partial<App> = {}): 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<App>) => {
|
||||
return renderWithSystemFeatures(<AppCard app={createMockApp(app)} onRefresh={mockOnRefresh} />, {
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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<string, unknown>) => {
|
||||
return <div data-testid="dynamic-component" {...props} />
|
||||
}
|
||||
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<string, unknown>) => 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<typeof import('@tanstack/react-query')>()
|
||||
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: <T,>(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<typeof import('ahooks')>('ahooks')
|
||||
const React = await vi.importActual<typeof import('react')>('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> = {}): 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<string, string>) => {
|
||||
const { wrapper: SysWrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: mockSystemFeatures,
|
||||
})
|
||||
const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams })
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsWrapper>
|
||||
<SysWrapper>{children}</SysWrapper>
|
||||
</NuqsWrapper>
|
||||
)
|
||||
return { ...render(ui, { wrapper: Wrapper }), onUrlUpdate }
|
||||
}
|
||||
|
||||
const renderList = (searchParams?: Record<string, string>) => {
|
||||
return renderListUI(<List controlRefreshList={0} />, 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(<List controlRefreshList={0} />)
|
||||
|
||||
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(<List controlRefreshList={0} />)
|
||||
|
||||
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(<List controlRefreshList={0} />)
|
||||
|
||||
rerender(<List controlRefreshList={1} />)
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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<string, unknown>) => 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<typeof import('@tanstack/react-query')>()
|
||||
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: <T,>(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<typeof import('ahooks')>('ahooks')
|
||||
const React = await vi.importActual<typeof import('react')>('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<Record<string, unknown>> | null = null
|
||||
loader()
|
||||
.then((mod) => {
|
||||
Component = mod.default as React.ComponentType<Record<string, unknown>>
|
||||
})
|
||||
.catch(() => {})
|
||||
const Wrapper = (props: Record<string, unknown>) => {
|
||||
if (Component) return <Component {...props} />
|
||||
return null
|
||||
}
|
||||
Wrapper.displayName = 'DynamicWrapper'
|
||||
return Wrapper
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/create-app-modal', () => ({
|
||||
default: ({ show, onClose, onSuccess, onCreateFromTemplate }: Record<string, unknown>) => {
|
||||
if (!show) return null
|
||||
return (
|
||||
<div data-testid="create-app-modal">
|
||||
<button data-testid="create-blank-confirm" onClick={onSuccess as () => void}>
|
||||
Create Blank
|
||||
</button>
|
||||
{!!onCreateFromTemplate && (
|
||||
<button data-testid="switch-to-template" onClick={onCreateFromTemplate as () => void}>
|
||||
From Template
|
||||
</button>
|
||||
)}
|
||||
<button data-testid="create-blank-cancel" onClick={onClose as () => void}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/create-app-dialog', () => ({
|
||||
default: ({ show, onClose, onSuccess, onCreateFromBlank }: Record<string, unknown>) => {
|
||||
if (!show) return null
|
||||
return (
|
||||
<div data-testid="template-dialog">
|
||||
<button data-testid="template-confirm" onClick={onSuccess as () => void}>
|
||||
Create from Template
|
||||
</button>
|
||||
{!!onCreateFromBlank && (
|
||||
<button data-testid="switch-to-blank" onClick={onCreateFromBlank as () => void}>
|
||||
From Blank
|
||||
</button>
|
||||
)}
|
||||
<button data-testid="template-cancel" onClick={onClose as () => void}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/create-from-dsl-modal', () => ({
|
||||
default: ({ show, onClose, onSuccess }: Record<string, unknown>) => {
|
||||
if (!show) return null
|
||||
return (
|
||||
<div data-testid="create-from-dsl-modal">
|
||||
<button data-testid="dsl-import-confirm" onClick={onSuccess as () => void}>
|
||||
Import DSL
|
||||
</button>
|
||||
<button data-testid="dsl-import-cancel" onClick={onClose as () => void}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
CreateFromDSLModalTab: {
|
||||
FROM_URL: 'from-url',
|
||||
FROM_FILE: 'from-file',
|
||||
},
|
||||
}))
|
||||
|
||||
const createMockApp = (overrides: Partial<App> = {}): 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 }) => (
|
||||
<NuqsWrapper>
|
||||
<SysWrapper>{children}</SysWrapper>
|
||||
</NuqsWrapper>
|
||||
)
|
||||
return { ...render(<List controlRefreshList={0} />, { 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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(
|
||||
|
||||
@ -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<typeof import('@/config')>()
|
||||
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<typeof useWebAppBrand>> = {},
|
||||
): ReturnType<typeof useWebAppBrand> => ({
|
||||
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(<CustomPage />)
|
||||
|
||||
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(<CustomPage />)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@ -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>): 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>): 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<string, unknown>
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>): 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>): 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}) => (
|
||||
<div data-testid="image-uploader-mock">
|
||||
{textArea}
|
||||
{actionButton}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
// --- 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<string, unknown> = {}) => ({
|
||||
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(
|
||||
<QueryInput
|
||||
{...createDefaultProps({
|
||||
queries: createTextQuery('How does RAG work?'),
|
||||
retrievalConfig,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<QueryInput
|
||||
{...createDefaultProps({
|
||||
queries: createTextQuery('test query'),
|
||||
retrievalConfig,
|
||||
isEconomy: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<QueryInput
|
||||
{...createDefaultProps({
|
||||
queries: createTextQuery('nonexistent topic'),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<QueryInput
|
||||
{...createDefaultProps({
|
||||
queries: createTextQuery('test'),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<QueryInput
|
||||
{...createDefaultProps({
|
||||
queries: createTextQuery('Test query'),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<QueryInput
|
||||
{...createDefaultProps({
|
||||
queries: createTextQuery('new query'),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<QueryInput
|
||||
{...createDefaultProps({
|
||||
queries: createTextQuery('test'),
|
||||
isExternal: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<QueryInput
|
||||
{...createDefaultProps({
|
||||
queries: createTextQuery('external query'),
|
||||
isExternal: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(findSubmitButton())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetExternalHitResult).toHaveBeenCalledWith(externalResponse)
|
||||
expect(mockOnUpdateList).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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.',
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
|
||||
|
||||
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(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" canManageApiKey />)
|
||||
|
||||
// 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(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" canManageApiKey />)
|
||||
|
||||
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(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" canManageApiKey />)
|
||||
|
||||
// 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(
|
||||
<ApiServer apiBaseUrl="http://localhost:5001/v1" appId="app-dev" canManageApiKey />,
|
||||
)
|
||||
|
||||
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(
|
||||
<ApiServer apiBaseUrl="https://api.production.com/v1" appId="app-prod" canManageApiKey />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('https://api.production.com/v1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -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<string, unknown>) => 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<typeof import('@/i18n-config/language')>()
|
||||
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(<DevelopMain appId="app-1" />)
|
||||
|
||||
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(<DevelopMain appId="app-1" />)
|
||||
|
||||
// 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(<DevelopMain appId="app-1" />)
|
||||
|
||||
// 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(<DevelopMain appId="app-1" />)
|
||||
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(<DevelopMain appId="app-1" />)
|
||||
|
||||
// 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(<DevelopMain appId="app-1" />)
|
||||
|
||||
// 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(<DevelopMain appId="app-1" />)
|
||||
|
||||
// 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(<DevelopMain appId="app-1" />)
|
||||
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
@ -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 (
|
||||
<div data-testid="document-detail-fixed">
|
||||
<button type="button" data-testid="back-button-fixed" onClick={backToPrev}>
|
||||
Back to Documents
|
||||
</button>
|
||||
<div data-testid="document-info">
|
||||
Dataset: {datasetId}, Document: {documentId}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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(<DocumentDetailWithFix datasetId="dataset-123" documentId="doc-456" />)
|
||||
|
||||
// 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(<DocumentDetailWithFix datasetId="dataset-123" documentId="doc-456" />)
|
||||
|
||||
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(<DocumentDetailWithFix datasetId="dataset-123" documentId="doc-456" />)
|
||||
|
||||
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(<DocumentDetailWithFix datasetId="dataset-123" documentId="doc-456" />)
|
||||
|
||||
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(<DocumentDetailWithFix datasetId="ds-prod-2024-001" documentId="doc-456" />)
|
||||
|
||||
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(<DocumentDetailWithFix datasetId="main-dataset" documentId="api-doc-123" />)
|
||||
|
||||
// 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(<DocumentDetailWithFix datasetId="filtered-dataset" documentId="filtered-doc" />)
|
||||
|
||||
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(<DocumentDetailWithFix datasetId="dataset-123" documentId="doc-456" />)
|
||||
|
||||
// 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(<DocumentDetailWithFix datasetId="dataset-123" documentId="doc-456" />)
|
||||
|
||||
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(<DocumentDetailWithFix datasetId="dataset-123" documentId="doc-456" />)
|
||||
|
||||
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`)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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])
|
||||
})
|
||||
})
|
||||
@ -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<typeof import('nuqs')>()
|
||||
return {
|
||||
...actual,
|
||||
useQueryState: () => [mockTabValue, mockSetTab],
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('ahooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
|
||||
const React = await vi.importActual<typeof import('react')>('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 (
|
||||
<div data-testid="create-app-modal">
|
||||
<button
|
||||
data-testid="confirm-create"
|
||||
onClick={() =>
|
||||
props.onConfirm({
|
||||
name: 'New App',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#fff',
|
||||
description: 'desc',
|
||||
})
|
||||
}
|
||||
>
|
||||
confirm
|
||||
</button>
|
||||
<button data-testid="hide-create" onClick={props.onHide}>
|
||||
hide
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({
|
||||
default: ({ onConfirm, onCancel }: { onConfirm: () => void; onCancel: () => void }) => (
|
||||
<div data-testid="dsl-confirm-modal">
|
||||
<button data-testid="dsl-confirm" onClick={onConfirm}>
|
||||
confirm
|
||||
</button>
|
||||
<button data-testid="dsl-cancel" onClick={onCancel}>
|
||||
cancel
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createApp = (overrides: Partial<App> = {}): 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<typeof useMembers>)
|
||||
}
|
||||
|
||||
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(<AppList onSuccess={onSuccess} />, {
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 }) => (
|
||||
<div data-testid="text-generation-app">
|
||||
Text Generation
|
||||
{isWorkflow && ' (Workflow)'}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat-with-history', () => ({
|
||||
default: ({ installedAppInfo }: { installedAppInfo?: InstalledAppModel }) => (
|
||||
<div data-testid="chat-with-history">Chat - {installedAppInfo?.app.name}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
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<string, Mock>) => 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(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
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(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
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(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
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(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
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(<InstalledApp id="nonexistent" />)
|
||||
|
||||
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(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
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(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
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(<InstalledApp id="nonexistent" />)
|
||||
|
||||
expect(screen.getByText(/404/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show 403 when user has no permission', () => {
|
||||
const app = createInstalledApp()
|
||||
setupDefaultMocks(app, { userAccess: { data: { result: false } } })
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
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(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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<typeof import('@langgenius/dify-ui/toast')>()
|
||||
return {
|
||||
...actual,
|
||||
toast: {
|
||||
...actual.toast,
|
||||
success: mockToastSuccess,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const createInstalledApp = (overrides: Partial<InstalledApp> = {}): 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(<SideBar />)
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 (
|
||||
<div className="flex items-center gap-1 text-xs text-text-tertiary">
|
||||
<span>{searchMode === 'scopes' ? 'SCOPES' : 'COMMANDS'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Scope and Command Tags', () => {
|
||||
describe('Tag Display Logic', () => {
|
||||
it('should display SCOPES for @ actions', () => {
|
||||
render(<TagDisplay searchMode="scopes" />)
|
||||
expect(screen.getByText('SCOPES')).toBeInTheDocument()
|
||||
expect(screen.queryByText('COMMANDS')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display COMMANDS for / actions', () => {
|
||||
render(<TagDisplay searchMode="commands" />)
|
||||
expect(screen.getByText('COMMANDS')).toBeInTheDocument()
|
||||
expect(screen.queryByText('SCOPES')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not display any tag when searchMode is null', () => {
|
||||
const { container } = render(<TagDisplay searchMode={null} />)
|
||||
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(<TagDisplay searchMode="scopes" />)
|
||||
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(<TagDisplay searchMode="scopes" />)
|
||||
const scopesText = screen.getByText('SCOPES')
|
||||
expect(scopesText.textContent).toBe('SCOPES')
|
||||
|
||||
render(<TagDisplay searchMode="commands" />)
|
||||
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 (
|
||||
<div>
|
||||
<input value={query} readOnly />
|
||||
<TagDisplay searchMode={searchMode} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
it('should update tag when switching between @ and /', () => {
|
||||
const { rerender } = render(<SearchComponent query="@app" />)
|
||||
expect(screen.getByText('SCOPES')).toBeInTheDocument()
|
||||
|
||||
rerender(<SearchComponent query="/theme" />)
|
||||
expect(screen.queryByText('SCOPES')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('COMMANDS')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide tag when clearing search', () => {
|
||||
const { rerender } = render(<SearchComponent query="@app" />)
|
||||
expect(screen.getByText('SCOPES')).toBeInTheDocument()
|
||||
|
||||
rerender(<SearchComponent query="" />)
|
||||
expect(screen.queryByText('SCOPES')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('COMMANDS')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should maintain correct tag during search refinement', () => {
|
||||
const { rerender } = render(<SearchComponent query="@" />)
|
||||
expect(screen.getByText('SCOPES')).toBeInTheDocument()
|
||||
|
||||
rerender(<SearchComponent query="@app" />)
|
||||
expect(screen.getByText('SCOPES')).toBeInTheDocument()
|
||||
|
||||
rerender(<SearchComponent query="@app test" />)
|
||||
expect(screen.getByText('SCOPES')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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<typeof import('@/service/use-common')>()),
|
||||
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<string, unknown>) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
|
||||
const renderAccountDropdown = () => {
|
||||
return renderWithSystemFeatures(<AccountDropdown />, {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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<string, string> = {
|
||||
'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
|
||||
}) => (
|
||||
<div data-testid="authorize-component">
|
||||
<span data-testid="auth-provider">{pluginPayload.provider}</span>
|
||||
{canOAuth && <span data-testid="auth-oauth">OAuth available</span>}
|
||||
{canApiKey && <span data-testid="auth-apikey">API Key available</span>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-auth/authorized', () => ({
|
||||
default: ({
|
||||
pluginPayload,
|
||||
credentials,
|
||||
}: {
|
||||
pluginPayload: { provider: string }
|
||||
credentials: Array<{ id: string; name: string }>
|
||||
}) => (
|
||||
<div data-testid="authorized-component">
|
||||
<span data-testid="auth-provider">{pluginPayload.provider}</span>
|
||||
<span data-testid="auth-credential-count">{credentials.length} credentials</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
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(<PluginAuth pluginPayload={basePayload} />)
|
||||
|
||||
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(<PluginAuth pluginPayload={basePayload} />)
|
||||
|
||||
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(
|
||||
<PluginAuth pluginPayload={basePayload} className="custom-class" />,
|
||||
)
|
||||
|
||||
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(<PluginAuth pluginPayload={basePayload} />)
|
||||
|
||||
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(
|
||||
<PluginAuth pluginPayload={basePayload}>
|
||||
<div data-testid="custom-children">Custom authorized view</div>
|
||||
</PluginAuth>,
|
||||
)
|
||||
|
||||
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(
|
||||
<PluginAuth pluginPayload={basePayload} className="custom-class" />,
|
||||
)
|
||||
|
||||
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(<PluginAuth pluginPayload={toolPayload} />)
|
||||
|
||||
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(<PluginAuth pluginPayload={dsPayload} />)
|
||||
|
||||
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(<PluginAuth pluginPayload={basePayload} />)
|
||||
|
||||
expect(screen.getByTestId('auth-credential-count')).toHaveTextContent('3 credentials')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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<string, string>, locale: string) => obj[locale] || obj.en_US || '',
|
||||
}))
|
||||
|
||||
vi.mock('@/types/app', async () => {
|
||||
return vi.importActual<typeof import('@/types/app')>('@/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: () => <span data-testid="partner-badge">Partner</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/base/badges/verified', () => ({
|
||||
default: () => <span data-testid="verified-badge">Verified</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
|
||||
default: ({
|
||||
src,
|
||||
installed,
|
||||
installFailed,
|
||||
}: {
|
||||
src: string | object
|
||||
installed?: boolean
|
||||
installFailed?: boolean
|
||||
}) => (
|
||||
<div data-testid="card-icon" data-installed={installed} data-install-failed={installFailed}>
|
||||
{typeof src === 'string' ? src : 'emoji-icon'}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/corner-mark', () => ({
|
||||
default: ({ text }: { text: string }) => <div data-testid="corner-mark">{text}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/description', () => ({
|
||||
default: ({ text, descriptionLineRows }: { text: string; descriptionLineRows?: number }) => (
|
||||
<div data-testid="description" data-rows={descriptionLineRows}>
|
||||
{text}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/org-info', () => ({
|
||||
default: ({ orgName, packageName }: { orgName: string; packageName: string }) => (
|
||||
<div data-testid="org-info">
|
||||
{orgName}/{packageName}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/placeholder', () => ({
|
||||
default: ({ text }: { text: string }) => <div data-testid="placeholder">{text}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/title', () => ({
|
||||
default: ({ title }: { title: string }) => <div data-testid="title">{title}</div>,
|
||||
}))
|
||||
|
||||
const { default: Card } = await import('@/app/components/plugins/card/index')
|
||||
type CardPayload = Parameters<typeof Card>[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(<Card payload={payload} />)
|
||||
|
||||
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(<Card payload={payload} />)
|
||||
|
||||
expect(screen.getByTestId('corner-mark')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides corner mark when hideCornerMark is true', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} hideCornerMark />)
|
||||
|
||||
expect(screen.queryByTestId('corner-mark')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows installed status on icon', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} installed />)
|
||||
|
||||
const icon = screen.getByTestId('card-icon')
|
||||
expect(icon).toHaveAttribute('data-installed', 'true')
|
||||
})
|
||||
|
||||
it('shows install failed status on icon', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} installFailed />)
|
||||
|
||||
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(<Card payload={payload} />)
|
||||
|
||||
expect(screen.getByTestId('verified-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders partner badge when plugin has partner badge', () => {
|
||||
const payload = makePayload({ badges: ['partner'] })
|
||||
render(<Card payload={payload} />)
|
||||
|
||||
expect(screen.getByTestId('partner-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders footer content when provided', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} footer={<div data-testid="custom-footer">Custom footer</div>} />)
|
||||
|
||||
expect(screen.getByTestId('custom-footer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders titleLeft content when provided', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} titleLeft={<span data-testid="title-left-content">New</span>} />)
|
||||
|
||||
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(<Card payload={payload} />)
|
||||
expect(screen.getByTestId('card-icon')).toHaveTextContent('https://example.com/icon-dark.png')
|
||||
})
|
||||
|
||||
it('shows loading placeholder when isLoading is true', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} isLoading loadingFileName="uploading.difypkg" />)
|
||||
|
||||
expect(screen.getByTestId('placeholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders description with custom line rows', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} descriptionLineRows={3} />)
|
||||
|
||||
const description = screen.getByTestId('description')
|
||||
expect(description).toHaveAttribute('data-rows', '3')
|
||||
})
|
||||
})
|
||||
@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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<typeof import('@/utils')>()
|
||||
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: () => <div data-testid="plugin-tasks">plugin tasks</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-page/debug-info', () => ({
|
||||
default: () => <div data-testid="debug-info">debug info</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-page/install-plugin-dropdown', () => ({
|
||||
default: ({ onSwitchToMarketplaceTab }: { onSwitchToMarketplaceTab: () => void }) => (
|
||||
<button type="button" data-testid="install-plugin-dropdown" onClick={onSwitchToMarketplaceTab}>
|
||||
install plugin
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
|
||||
default: ({ uniqueIdentifier, onClose }: { uniqueIdentifier: string; onClose: () => void }) => (
|
||||
<div data-testid="install-from-marketplace-modal">
|
||||
<span>{uniqueIdentifier}</span>
|
||||
<button type="button" onClick={onClose}>
|
||||
close-install-modal
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
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 }) => (
|
||||
<NuqsWrapper>
|
||||
<SysWrapper>{children}</SysWrapper>
|
||||
</NuqsWrapper>
|
||||
)
|
||||
return {
|
||||
...render(
|
||||
<PluginPage
|
||||
plugins={<div data-testid="plugins-view">plugins view</div>}
|
||||
marketplace={<div data-testid="marketplace-view">marketplace view</div>}
|
||||
/>,
|
||||
{ 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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<string, unknown>[] }))
|
||||
|
||||
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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 (
|
||||
<div data-testid="timing-page" style={currentStyles}>
|
||||
<div data-testid="timing-status">
|
||||
Phase: {mounted ? 'CSR' : 'Initial'} | Theme: {theme} | Visual:{' '}
|
||||
{isDark ? 'dark' : 'light'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div data-testid="css-component" className={className}>
|
||||
<div data-testid="css-classes">
|
||||
Classes:
|
||||
{className}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div data-testid="performance-test">
|
||||
Mounted: {mounted.toString()} | Theme: {theme || 'loading'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div data-theme={isDark ? 'dark' : 'light'}>
|
||||
<div data-testid="page-content" style={{ backgroundColor: isDark ? '#1f2937' : '#ffffff' }}>
|
||||
<h1 style={{ color: isDark ? '#ffffff' : '#000000' }}>Dify Application</h1>
|
||||
<div data-testid="theme-indicator">Current Theme: {mounted ? theme : 'unknown'}</div>
|
||||
<div data-testid="visual-appearance">Appearance: {isDark ? 'dark' : 'light'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TestThemeProvider = ({ children }: { children: React.ReactNode }) => (
|
||||
<ThemeProvider
|
||||
attribute="data-theme"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
||||
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(
|
||||
<TestThemeProvider>
|
||||
<PageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
// 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(
|
||||
<TestThemeProvider>
|
||||
<PageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
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(
|
||||
<TestThemeProvider>
|
||||
<PageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
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(
|
||||
<TestThemeProvider>
|
||||
<PageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
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(
|
||||
<TestThemeProvider>
|
||||
<PageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
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(
|
||||
<TestThemeProvider>
|
||||
<TimingPageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
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(
|
||||
<TestThemeProvider>
|
||||
<CSSTestComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
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(
|
||||
<TestThemeProvider>
|
||||
<PageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
// 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(
|
||||
<TestThemeProvider>
|
||||
<PageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
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(
|
||||
<TestThemeProvider>
|
||||
<PerformanceTestComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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<string, unknown>
|
||||
onInputsChange: (inputs: Record<string, unknown>) => void
|
||||
onSend: () => void
|
||||
runControl?: { isStopping: boolean } | null
|
||||
}) => (
|
||||
<div data-testid="run-once-mock">
|
||||
<span data-testid="run-once-input-name">{String(inputs.name ?? '')}</span>
|
||||
<button onClick={() => onInputsChange({ ...inputs, name: 'Gamma' })}>change-inputs</button>
|
||||
<button onClick={onSend}>run-once</button>
|
||||
<span>{runControl ? 'stop-ready' : 'idle'}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/share/text-generation/run-batch', () => ({
|
||||
default: ({ onSend }: { onSend: (data: string[][]) => void }) => (
|
||||
<button onClick={() => onSend([['Name'], ['Alpha'], ['Beta']])}>run-batch</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/text-generate/saved-items', () => ({
|
||||
default: ({ list }: { list: { id: string }[] }) => (
|
||||
<div data-testid="saved-items-mock">{list.length}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/share/text-generation/menu-dropdown', () => ({
|
||||
default: () => <div data-testid="menu-dropdown-mock" />,
|
||||
}))
|
||||
|
||||
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 <div data-testid={taskId ? `result-task-${taskId}` : 'result-single'} />
|
||||
}
|
||||
|
||||
return {
|
||||
default: MockResult,
|
||||
}
|
||||
})
|
||||
|
||||
const fetchSavedMessageMock = vi.fn()
|
||||
|
||||
vi.mock('@/service/share', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/service/share')>('@/service/share')
|
||||
return {
|
||||
...actual,
|
||||
fetchSavedMessage: (...args: Parameters<typeof actual.fetchSavedMessage>) =>
|
||||
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(<TextGeneration />, { 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(<TextGeneration />, { 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()
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}) => (
|
||||
<div data-testid="text-generation-sidebar">
|
||||
<span data-testid="current-tab">{currentTab}</span>
|
||||
<button type="button" onClick={() => onTabChange('batch')}>
|
||||
switch-to-batch
|
||||
</button>
|
||||
<button type="button" onClick={() => onTabChange('create')}>
|
||||
switch-to-create
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/share/text-generation/text-generation-result-panel', () => ({
|
||||
default: ({
|
||||
isCallBatchAPI,
|
||||
resultExisted,
|
||||
}: {
|
||||
isCallBatchAPI: boolean
|
||||
resultExisted: boolean
|
||||
}) => (
|
||||
<div
|
||||
data-testid="text-generation-result-panel"
|
||||
data-batch={String(isCallBatchAPI)}
|
||||
data-result={String(resultExisted)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
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(<TextGeneration />)
|
||||
|
||||
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(<TextGeneration />)
|
||||
|
||||
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(<TextGeneration />)
|
||||
|
||||
expect(screen.getByTestId('current-tab')).toHaveTextContent('create')
|
||||
expect(screen.getByTestId('text-generation-result-panel')).toHaveAttribute(
|
||||
'data-batch',
|
||||
'false',
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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 <div data-testid="csv-reader">CSV Reader</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/share/text-generation/run-batch/csv-download', () => ({
|
||||
default: ({ vars }: { vars: { name: string }[] }) => (
|
||||
<div data-testid="csv-download">{vars.map((v) => v.name).join(', ')}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
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(<RunBatch vars={vars} onSend={onSend} isAllFinished />)
|
||||
|
||||
// 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(<RunBatch vars={vars} onSend={onSend} isAllFinished={false} />)
|
||||
expect(runButton).toBeDisabled()
|
||||
|
||||
// Phase 5 – results finish → can run again
|
||||
rerender(<RunBatch vars={vars} onSend={onSend} isAllFinished />)
|
||||
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(<RunBatch vars={vars} onSend={onSend} isAllFinished />)
|
||||
|
||||
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(<RunBatch vars={vars} onSend={onSend} isAllFinished={false} />)
|
||||
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
@ -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 }) => (
|
||||
<textarea
|
||||
data-testid="code-editor"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/image-uploader/text-generation-image-uploader', () => ({
|
||||
default: () => <div data-testid="vision-uploader" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
FileUploaderInAttachmentWrapper: () => <div data-testid="file-uploader" />,
|
||||
}))
|
||||
|
||||
// ----- helpers -----
|
||||
|
||||
const variable = (overrides: Partial<PromptVariable>): PromptVariable => ({
|
||||
key: 'k',
|
||||
name: 'Name',
|
||||
type: 'string',
|
||||
required: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const visionOff: VisionSettings = {
|
||||
enabled: false,
|
||||
number_limits: 0,
|
||||
detail: Resolution.low,
|
||||
transfer_methods: [TransferMethod.local_file],
|
||||
image_file_size_limit: 5,
|
||||
}
|
||||
|
||||
const siteInfo: SiteInfo = { title: 'Test' }
|
||||
|
||||
/**
|
||||
* Stateful wrapper that mirrors what text-generation/index.tsx does:
|
||||
* owns `inputs` state and passes an `inputsRef`.
|
||||
*/
|
||||
function Harness({
|
||||
promptConfig,
|
||||
visionConfig = visionOff,
|
||||
onSendSpy,
|
||||
runControl = null,
|
||||
}: {
|
||||
promptConfig: PromptConfig
|
||||
visionConfig?: VisionSettings
|
||||
onSendSpy: () => void
|
||||
runControl?: React.ComponentProps<typeof RunOnce>['runControl']
|
||||
}) {
|
||||
const [inputs, setInputs] = useState<Record<string, InputValueTypes>>({})
|
||||
const inputsRef = useRef<Record<string, InputValueTypes>>({})
|
||||
|
||||
return (
|
||||
<RunOnce
|
||||
siteInfo={siteInfo}
|
||||
promptConfig={promptConfig}
|
||||
inputs={inputs}
|
||||
inputsRef={inputsRef}
|
||||
onInputsChange={(updated) => {
|
||||
inputsRef.current = updated
|
||||
setInputs(updated)
|
||||
}}
|
||||
onSend={onSendSpy}
|
||||
visionConfig={visionConfig}
|
||||
onVisionFilesChange={vi.fn()}
|
||||
runControl={runControl}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ----- tests -----
|
||||
|
||||
describe('RunOnce – integration flow', () => {
|
||||
it('full lifecycle: init → edit → submit → running → stop', async () => {
|
||||
const onSend = vi.fn()
|
||||
|
||||
const config: PromptConfig = {
|
||||
prompt_template: 'tpl',
|
||||
prompt_variables: [
|
||||
variable({ key: 'name', name: 'Name', type: 'string', default: '' }),
|
||||
variable({ key: 'age', name: 'Age', type: 'number', default: '' }),
|
||||
variable({ key: 'bio', name: 'Bio', type: 'paragraph', default: '' }),
|
||||
],
|
||||
}
|
||||
|
||||
// Phase 1 – render, wait for initialisation
|
||||
const { rerender } = render(<Harness promptConfig={config} onSendSpy={onSend} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Phase 2 – fill fields
|
||||
fireEvent.change(screen.getByPlaceholderText('Name'), { target: { value: 'Alice' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Age'), { target: { value: '30' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Bio'), { target: { value: 'Hello' } })
|
||||
|
||||
// Phase 3 – submit
|
||||
fireEvent.click(screen.getByRole('button', { name: 'share.generation.run' }))
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Phase 4 – simulate "running" state
|
||||
const onStop = vi.fn()
|
||||
rerender(
|
||||
<Harness
|
||||
promptConfig={config}
|
||||
onSendSpy={onSend}
|
||||
runControl={{ onStop, isStopping: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const stopBtn = screen.getByRole('button', {
|
||||
name: 'share.generation.stopRun:{"defaultValue":"Stop Run"}',
|
||||
})
|
||||
expect(stopBtn).toBeInTheDocument()
|
||||
fireEvent.click(stopBtn)
|
||||
expect(onStop).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Phase 5 – simulate "stopping" state
|
||||
rerender(
|
||||
<Harness
|
||||
promptConfig={config}
|
||||
onSendSpy={onSend}
|
||||
runControl={{ onStop, isStopping: true }}
|
||||
/>,
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'share.generation.stopRun:{"defaultValue":"Stop Run"}' }),
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
it('clear resets all field types and allows re-submit', async () => {
|
||||
const onSend = vi.fn()
|
||||
|
||||
const config: PromptConfig = {
|
||||
prompt_template: 'tpl',
|
||||
prompt_variables: [
|
||||
variable({ key: 'q', name: 'Question', type: 'string', default: 'Hi' }),
|
||||
variable({ key: 'flag', name: 'Flag', type: 'checkbox' }),
|
||||
],
|
||||
}
|
||||
|
||||
render(<Harness promptConfig={config} onSendSpy={onSend} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Question')).toHaveValue('Hi')
|
||||
})
|
||||
|
||||
// Clear all
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Question')).toHaveValue('')
|
||||
})
|
||||
|
||||
// Re-fill and submit
|
||||
fireEvent.change(screen.getByPlaceholderText('Question'), { target: { value: 'New' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'share.generation.run' }))
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('mixed input types: string + select + json_object', async () => {
|
||||
const onSend = vi.fn()
|
||||
|
||||
const config: PromptConfig = {
|
||||
prompt_template: 'tpl',
|
||||
prompt_variables: [
|
||||
variable({ key: 'txt', name: 'Text', type: 'string', default: '' }),
|
||||
variable({
|
||||
key: 'sel',
|
||||
name: 'Dropdown',
|
||||
type: 'select',
|
||||
options: ['A', 'B'],
|
||||
default: 'A',
|
||||
}),
|
||||
variable({
|
||||
key: 'json',
|
||||
name: 'JSON',
|
||||
type: 'json_object' as PromptVariable['type'],
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
render(<Harness promptConfig={config} onSendSpy={onSend} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Text')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dropdown')).toBeInTheDocument()
|
||||
expect(screen.getByText('JSON')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Edit text & json
|
||||
fireEvent.change(screen.getByPlaceholderText('Text'), { target: { value: 'hello' } })
|
||||
fireEvent.change(screen.getByTestId('code-editor'), { target: { value: '{"a":1}' } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'share.generation.run' }))
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -1,230 +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 ProviderList from '@/app/components/integrations/tool-provider-list'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
|
||||
const mockInvalidateInstalledPluginList = vi.fn()
|
||||
vi.mock('@/app/components/plugins/hooks', () => ({
|
||||
useTags: () => ({
|
||||
getTagLabel: (name: string) => name,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllToolProviders: () => ({
|
||||
data: [
|
||||
{
|
||||
id: 'builtin-plugin',
|
||||
name: 'plugin-tool',
|
||||
author: 'Dify',
|
||||
description: { en_US: 'Plugin Tool' },
|
||||
icon: 'icon-plugin',
|
||||
label: { en_US: 'Plugin Tool' },
|
||||
type: CollectionType.builtIn,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: ['search'],
|
||||
plugin_id: 'langgenius/plugin-tool',
|
||||
},
|
||||
{
|
||||
id: 'builtin-basic',
|
||||
name: 'basic-tool',
|
||||
author: 'Dify',
|
||||
description: { en_US: 'Basic Tool' },
|
||||
icon: 'icon-basic',
|
||||
label: { en_US: 'Basic Tool' },
|
||||
type: CollectionType.builtIn,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: ['utility'],
|
||||
},
|
||||
],
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useCheckInstalled: ({ enabled }: { enabled: boolean }) => ({
|
||||
data: enabled
|
||||
? {
|
||||
plugins: [
|
||||
{
|
||||
plugin_id: 'langgenius/plugin-tool',
|
||||
declaration: {
|
||||
category: 'tool',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList,
|
||||
useMutationPluginPermissionSettings: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
usePluginPermissionSettings: () => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/labels/filter', () => ({
|
||||
default: ({ onChange }: { onChange: (value: string[]) => void }) => (
|
||||
<div data-testid="tool-label-filter">
|
||||
<button type="button" onClick={() => onChange(['search'])}>
|
||||
apply-search-filter
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card', () => ({
|
||||
default: ({ payload, className }: { payload: { name: string }; className?: string }) => (
|
||||
<div data-testid={`tool-card-${payload.name}`} className={className}>
|
||||
{payload.name}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/card-more-info', () => ({
|
||||
default: ({ tags }: { tags: string[] }) => (
|
||||
<div data-testid="tool-card-more-info">{tags.join(',')}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/provider/detail', () => ({
|
||||
default: ({ collection, onHide }: { collection: { name: string }; onHide: () => void }) => (
|
||||
<div data-testid="tool-provider-detail">
|
||||
<span>{collection.name}</span>
|
||||
<button type="button" onClick={onHide}>
|
||||
close-provider-detail
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({
|
||||
default: ({
|
||||
detail,
|
||||
onHide,
|
||||
onUpdate,
|
||||
}: {
|
||||
detail?: { plugin_id: string }
|
||||
onHide: () => void
|
||||
onUpdate: () => void
|
||||
}) =>
|
||||
detail ? (
|
||||
<div data-testid="tool-plugin-detail-panel">
|
||||
<span>{detail.plugin_id}</span>
|
||||
<button type="button" onClick={onUpdate}>
|
||||
update-plugin-detail
|
||||
</button>
|
||||
<button type="button" onClick={onHide}>
|
||||
close-plugin-detail
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/provider/empty', () => ({
|
||||
default: () => <div data-testid="workflow-empty">workflow empty</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/marketplace/empty', () => ({
|
||||
default: ({ text }: { text: string }) => <div data-testid="tools-empty">{text}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/marketplace', () => ({
|
||||
default: ({
|
||||
isMarketplaceArrowVisible,
|
||||
showMarketplacePanel,
|
||||
}: {
|
||||
isMarketplaceArrowVisible: boolean
|
||||
showMarketplacePanel: () => void
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="marketplace-arrow"
|
||||
data-visible={String(isMarketplaceArrowVisible)}
|
||||
onClick={showMarketplacePanel}
|
||||
>
|
||||
marketplace-arrow
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/marketplace/hooks', () => ({
|
||||
useMarketplace: () => ({
|
||||
handleScroll: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/mcp', () => ({
|
||||
default: ({ searchText }: { searchText: string }) => (
|
||||
<div data-testid="mcp-list">{searchText}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/update-setting-dialog', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
const renderProviderList = (searchParams = '') => {
|
||||
const { wrapper: SysWrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: { enable_marketplace: true },
|
||||
})
|
||||
const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams })
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsWrapper>
|
||||
<SysWrapper>{children}</SysWrapper>
|
||||
</NuqsWrapper>
|
||||
)
|
||||
return { ...render(<ProviderList />, { wrapper: Wrapper }), onUrlUpdate }
|
||||
}
|
||||
|
||||
describe('Tool Provider List Shell Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Element.prototype.scrollTo = vi.fn()
|
||||
})
|
||||
|
||||
it('opens a plugin-backed provider detail panel and invalidates installed plugins on update', async () => {
|
||||
renderProviderList('?category=builtin')
|
||||
|
||||
fireEvent.click(screen.getByTestId('tool-card-plugin-tool'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('tool-plugin-detail-panel'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'update-plugin-detail' }))
|
||||
expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close-plugin-detail' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('tool-plugin-detail-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('scrolls to the marketplace section and syncs workflow tab selection into the URL', async () => {
|
||||
const { onUrlUpdate } = renderProviderList('?category=builtin')
|
||||
|
||||
fireEvent.click(screen.getByTestId('marketplace-arrow'))
|
||||
expect(Element.prototype.scrollTo).toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByTestId('tab-item-workflow'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('workflow-empty'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
|
||||
expect(update.searchParams.get('category')).toBe('workflow')
|
||||
})
|
||||
})
|
||||
@ -1,387 +0,0 @@
|
||||
import type { Collection } from '@/app/components/tools/types'
|
||||
/**
|
||||
* Integration Test: Tool Browsing & Filtering Flow
|
||||
*
|
||||
* Tests the integration between ProviderList, TabSliderNew, LabelFilter,
|
||||
* Input (search), and card rendering. Verifies that tab switching, keyword
|
||||
* filtering, and label filtering work together correctly.
|
||||
*/
|
||||
import { cleanup, 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 { CollectionType } from '@/app/components/tools/types'
|
||||
|
||||
// ---- Mocks ----
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: withSelectorKey((key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'type.builtIn': 'Built-in',
|
||||
'type.custom': 'Custom',
|
||||
'type.workflow': 'Workflow',
|
||||
noTools: 'No tools found',
|
||||
}
|
||||
return map[key] ?? key
|
||||
}),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('nuqs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('nuqs')>()
|
||||
return {
|
||||
...actual,
|
||||
useQueryState: () => ['builtin', vi.fn()],
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/plugins/hooks', () => ({
|
||||
useTags: () => ({
|
||||
getTagLabel: (key: string) => key,
|
||||
tags: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useCheckInstalled: () => ({ data: null }),
|
||||
useInvalidateInstalledPluginList: () => vi.fn(),
|
||||
useMutationPluginPermissionSettings: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
usePluginPermissionSettings: () => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockCollections: Collection[] = [
|
||||
{
|
||||
id: 'google-search',
|
||||
name: 'google_search',
|
||||
author: 'Dify',
|
||||
description: { en_US: 'Google Search Tool', zh_Hans: 'Google搜索工具' },
|
||||
icon: 'https://example.com/google.png',
|
||||
label: { en_US: 'Google Search', zh_Hans: 'Google搜索' },
|
||||
type: CollectionType.builtIn,
|
||||
team_credentials: {},
|
||||
is_team_authorization: true,
|
||||
allow_delete: false,
|
||||
labels: ['search'],
|
||||
},
|
||||
{
|
||||
id: 'weather-api',
|
||||
name: 'weather_api',
|
||||
author: 'Dify',
|
||||
description: { en_US: 'Weather API Tool', zh_Hans: '天气API工具' },
|
||||
icon: 'https://example.com/weather.png',
|
||||
label: { en_US: 'Weather API', zh_Hans: '天气API' },
|
||||
type: CollectionType.builtIn,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: ['utility'],
|
||||
},
|
||||
{
|
||||
id: 'my-custom-tool',
|
||||
name: 'my_custom_tool',
|
||||
author: 'User',
|
||||
description: { en_US: 'My Custom Tool', zh_Hans: '我的自定义工具' },
|
||||
icon: 'https://example.com/custom.png',
|
||||
label: { en_US: 'My Custom Tool', zh_Hans: '我的自定义工具' },
|
||||
type: CollectionType.custom,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: true,
|
||||
labels: [],
|
||||
},
|
||||
{
|
||||
id: 'workflow-tool-1',
|
||||
name: 'workflow_tool_1',
|
||||
author: 'User',
|
||||
description: { en_US: 'Workflow Tool', zh_Hans: '工作流工具' },
|
||||
icon: 'https://example.com/workflow.png',
|
||||
label: { en_US: 'Workflow Tool', zh_Hans: '工作流工具' },
|
||||
type: CollectionType.workflow,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: true,
|
||||
labels: [],
|
||||
},
|
||||
]
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllToolProviders: () => ({
|
||||
data: mockCollections,
|
||||
refetch: mockRefetch,
|
||||
isSuccess: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tab-slider-new', () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
options: Array<{ value: string; text: string }>
|
||||
}) => (
|
||||
<div data-testid="tab-slider">
|
||||
{options.map((opt: { value: string; text: string }) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
data-testid={`tab-${opt.value}`}
|
||||
data-active={value === opt.value ? 'true' : 'false'}
|
||||
onClick={() => onChange(opt.value)}
|
||||
>
|
||||
{opt.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card', () => ({
|
||||
default: ({
|
||||
payload,
|
||||
className,
|
||||
}: {
|
||||
payload: { brief: Record<string, string> | string; name: string }
|
||||
className?: string
|
||||
}) => {
|
||||
const briefText = typeof payload.brief === 'object' ? payload.brief?.en_US || '' : payload.brief
|
||||
return (
|
||||
<div data-testid={`card-${payload.name}`} className={className}>
|
||||
<span>{payload.name}</span>
|
||||
<span>{briefText}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/card-more-info', () => ({
|
||||
default: ({ tags }: { tags: string[] }) => (
|
||||
<div data-testid="card-more-info">{tags.join(', ')}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/labels/filter', () => ({
|
||||
default: ({ value: _value, onChange }: { value: string[]; onChange: (v: string[]) => void }) => (
|
||||
<div data-testid="label-filter">
|
||||
<button data-testid="filter-search" onClick={() => onChange(['search'])}>
|
||||
Filter: search
|
||||
</button>
|
||||
<button data-testid="filter-utility" onClick={() => onChange(['utility'])}>
|
||||
Filter: utility
|
||||
</button>
|
||||
<button data-testid="filter-clear" onClick={() => onChange([])}>
|
||||
Clear filter
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/provider/custom-create-card', () => ({
|
||||
default: () => <div data-testid="custom-create-card">Create Custom Tool</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/provider/detail', () => ({
|
||||
default: ({ collection, onHide }: { collection: Collection; onHide: () => void }) => (
|
||||
<div data-testid="provider-detail">
|
||||
<span data-testid="detail-name">{collection.name}</span>
|
||||
<button data-testid="detail-close" onClick={onHide}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/provider/empty', () => ({
|
||||
default: () => <div data-testid="workflow-empty">No workflow tools</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({
|
||||
default: ({ detail, onHide }: { detail: unknown; onHide: () => void }) =>
|
||||
detail ? (
|
||||
<div data-testid="plugin-detail-panel">
|
||||
<button onClick={onHide}>Close</button>
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/marketplace/empty', () => ({
|
||||
default: ({ text }: { text: string }) => <div data-testid="empty-state">{text}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/marketplace', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/marketplace/hooks', () => ({
|
||||
useMarketplace: () => ({
|
||||
handleScroll: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/mcp', () => ({
|
||||
default: () => <div data-testid="mcp-list">MCP List</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/update-setting-dialog', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/cn', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector/types', () => ({
|
||||
ToolTypeEnum: { BuiltIn: 'builtin', Custom: 'api', Workflow: 'workflow', MCP: 'mcp' },
|
||||
}))
|
||||
|
||||
const { default: ProviderList } = await import('@/app/components/integrations/tool-provider-list')
|
||||
|
||||
const createWrapper = () => {
|
||||
const { wrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: { enable_marketplace: false },
|
||||
})
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('Tool Browsing & Filtering Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders tab options and built-in tools by default', () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByTestId('tab-slider')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-builtin')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-api')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-workflow')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-mcp')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-weather_api')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('card-my_custom_tool')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('card-workflow_tool_1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters tools by keyword search', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('operation.search')
|
||||
fireEvent.change(searchInput, { target: { value: 'Google' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('clears search keyword and shows all tools again', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('operation.search')
|
||||
fireEvent.change(searchInput, { target: { value: 'Google' } })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: '' } })
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-weather_api')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('filters tools by label tags', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
fireEvent.click(screen.getByTestId('filter-search'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('clears label filter and shows all tools', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
fireEvent.click(screen.getByTestId('filter-utility'))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('card-google_search')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-weather_api')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('filter-clear'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-weather_api')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('combines keyword search and label filter', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
fireEvent.click(screen.getByTestId('filter-search'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('operation.search')
|
||||
fireEvent.change(searchInput, { target: { value: 'Weather' } })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('card-google_search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens provider detail when clicking a non-plugin collection card', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
const card = screen.getByTestId('card-google_search')
|
||||
fireEvent.click(card.parentElement!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('provider-detail')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('detail-name')).toHaveTextContent('google_search')
|
||||
})
|
||||
})
|
||||
|
||||
it('closes provider detail and deselects current provider', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
const card = screen.getByTestId('card-google_search')
|
||||
fireEvent.click(card.parentElement!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('provider-detail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('detail-close'))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('provider-detail')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows label filter for non-MCP tabs', () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByTestId('label-filter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows search input on all tabs', () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByPlaceholderText('operation.search')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,240 +0,0 @@
|
||||
/**
|
||||
* Integration Test: Tool Data Processing Pipeline
|
||||
*
|
||||
* Tests the integration between tool utility functions and type conversions.
|
||||
* Verifies that data flows correctly through the processing pipeline:
|
||||
* raw API data → form schemas → form values → configured values.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils/index'
|
||||
import {
|
||||
addDefaultValue,
|
||||
generateFormValue,
|
||||
getConfiguredValue,
|
||||
getPlainValue,
|
||||
getStructureValue,
|
||||
toolCredentialToFormSchemas,
|
||||
toolParametersToFormSchemas,
|
||||
toType,
|
||||
triggerEventParametersToFormSchemas,
|
||||
} from '@/app/components/tools/utils/to-form-schema'
|
||||
|
||||
describe('Tool Data Processing Pipeline Integration', () => {
|
||||
describe('End-to-end: API schema → form schema → form value', () => {
|
||||
it('processes tool parameters through the full pipeline', () => {
|
||||
const rawParameters = [
|
||||
{
|
||||
name: 'query',
|
||||
label: { en_US: 'Search Query', zh_Hans: '搜索查询' },
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: 'hello',
|
||||
form: 'llm',
|
||||
human_description: { en_US: 'Enter your search query', zh_Hans: '输入搜索查询' },
|
||||
llm_description: 'The search query string',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
label: { en_US: 'Result Limit', zh_Hans: '结果限制' },
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: '10',
|
||||
form: 'form',
|
||||
human_description: { en_US: 'Maximum results', zh_Hans: '最大结果数' },
|
||||
llm_description: 'Limit for results',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const formSchemas = toolParametersToFormSchemas(
|
||||
rawParameters as unknown as Parameters<typeof toolParametersToFormSchemas>[0],
|
||||
)
|
||||
expect(formSchemas).toHaveLength(2)
|
||||
expect(formSchemas[0]!.variable).toBe('query')
|
||||
expect(formSchemas[0]!.required).toBe(true)
|
||||
expect(formSchemas[0]!.type).toBe('text-input')
|
||||
expect(formSchemas[1]!.variable).toBe('limit')
|
||||
expect(formSchemas[1]!.type).toBe('number-input')
|
||||
|
||||
const withDefaults = addDefaultValue({}, formSchemas)
|
||||
expect(withDefaults.query).toBe('hello')
|
||||
expect(withDefaults.limit).toBe('10')
|
||||
|
||||
const formValues = generateFormValue({}, formSchemas, false)
|
||||
expect(formValues).toBeDefined()
|
||||
expect(formValues.query).toBeDefined()
|
||||
expect(formValues.limit).toBeDefined()
|
||||
})
|
||||
|
||||
it('processes tool credentials through the pipeline', () => {
|
||||
const rawCredentials = [
|
||||
{
|
||||
name: 'api_key',
|
||||
label: { en_US: 'API Key', zh_Hans: 'API 密钥' },
|
||||
type: 'secret-input',
|
||||
required: true,
|
||||
default: '',
|
||||
placeholder: { en_US: 'Enter API key', zh_Hans: '输入 API 密钥' },
|
||||
help: { en_US: 'Your API key', zh_Hans: '你的 API 密钥' },
|
||||
url: 'https://example.com/get-key',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const credentialSchemas = toolCredentialToFormSchemas(
|
||||
rawCredentials as Parameters<typeof toolCredentialToFormSchemas>[0],
|
||||
)
|
||||
expect(credentialSchemas).toHaveLength(1)
|
||||
expect(credentialSchemas[0]!.variable).toBe('api_key')
|
||||
expect(credentialSchemas[0]!.required).toBe(true)
|
||||
expect(credentialSchemas[0]!.type).toBe('secret-input')
|
||||
})
|
||||
|
||||
it('processes trigger event parameters through the pipeline', () => {
|
||||
const rawParams = [
|
||||
{
|
||||
name: 'event_type',
|
||||
label: { en_US: 'Event Type', zh_Hans: '事件类型' },
|
||||
type: 'select',
|
||||
required: true,
|
||||
default: 'push',
|
||||
form: 'form',
|
||||
description: { en_US: 'Type of event', zh_Hans: '事件类型' },
|
||||
options: [
|
||||
{ value: 'push', label: { en_US: 'Push', zh_Hans: '推送' } },
|
||||
{ value: 'pull', label: { en_US: 'Pull', zh_Hans: '拉取' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const schemas = triggerEventParametersToFormSchemas(
|
||||
rawParams as unknown as Parameters<typeof triggerEventParametersToFormSchemas>[0],
|
||||
)
|
||||
expect(schemas).toHaveLength(1)
|
||||
expect(schemas[0]!.name).toBe('event_type')
|
||||
expect(schemas[0]!.type).toBe('select')
|
||||
expect(schemas[0]!.options).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Type conversion integration', () => {
|
||||
it('converts all supported types correctly', () => {
|
||||
const typeConversions = [
|
||||
{ input: 'string', expected: 'text-input' },
|
||||
{ input: 'number', expected: 'number-input' },
|
||||
{ input: 'boolean', expected: 'checkbox' },
|
||||
{ input: 'select', expected: 'select' },
|
||||
{ input: 'secret-input', expected: 'secret-input' },
|
||||
{ input: 'file', expected: 'file' },
|
||||
{ input: 'files', expected: 'files' },
|
||||
]
|
||||
|
||||
typeConversions.forEach(({ input, expected }) => {
|
||||
expect(toType(input)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the original type for unrecognized types', () => {
|
||||
expect(toType('unknown-type')).toBe('unknown-type')
|
||||
expect(toType('app-selector')).toBe('app-selector')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Value extraction integration', () => {
|
||||
it('wraps values with getStructureValue and extracts inner value with getPlainValue', () => {
|
||||
const plainInput = { query: 'test', limit: 10 }
|
||||
const structured = getStructureValue(plainInput)
|
||||
|
||||
expect(structured.query).toEqual({ value: 'test' })
|
||||
expect(structured.limit).toEqual({ value: 10 })
|
||||
|
||||
const objectStructured = {
|
||||
query: { value: { type: 'constant', content: 'test search' } },
|
||||
limit: { value: { type: 'constant', content: 10 } },
|
||||
}
|
||||
const extracted = getPlainValue(objectStructured)
|
||||
expect(extracted.query).toEqual({ type: 'constant', content: 'test search' })
|
||||
expect(extracted.limit).toEqual({ type: 'constant', content: 10 })
|
||||
})
|
||||
|
||||
it('handles getConfiguredValue for workflow tool configurations', () => {
|
||||
const formSchemas = [
|
||||
{ variable: 'query', type: 'text-input', default: 'default-query' },
|
||||
{ variable: 'format', type: 'select', default: 'json' },
|
||||
]
|
||||
|
||||
const configured = getConfiguredValue({}, formSchemas)
|
||||
expect(configured).toBeDefined()
|
||||
expect(configured.query).toBeDefined()
|
||||
expect(configured.format).toBeDefined()
|
||||
})
|
||||
|
||||
it('preserves existing values in getConfiguredValue', () => {
|
||||
const formSchemas = [{ variable: 'query', type: 'text-input', default: 'default-query' }]
|
||||
|
||||
const configured = getConfiguredValue({ query: 'my-existing-query' }, formSchemas)
|
||||
expect(configured.query).toBe('my-existing-query')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent utilities integration', () => {
|
||||
it('sorts agent thoughts and enriches with file infos end-to-end', () => {
|
||||
const thoughts = [
|
||||
{ id: 't3', position: 3, tool: 'search', files: ['f1'] },
|
||||
{ id: 't1', position: 1, tool: 'analyze', files: [] },
|
||||
{ id: 't2', position: 2, tool: 'summarize', files: ['f2'] },
|
||||
] as Parameters<typeof sortAgentSorts>[0]
|
||||
|
||||
const messageFiles = [
|
||||
{ id: 'f1', name: 'result.txt', type: 'document' },
|
||||
{ id: 'f2', name: 'summary.pdf', type: 'document' },
|
||||
] as Parameters<typeof addFileInfos>[1]
|
||||
|
||||
const sorted = sortAgentSorts(thoughts)
|
||||
expect(sorted[0]!.id).toBe('t1')
|
||||
expect(sorted[1]!.id).toBe('t2')
|
||||
expect(sorted[2]!.id).toBe('t3')
|
||||
|
||||
const enriched = addFileInfos(sorted, messageFiles)
|
||||
expect(enriched[0]!.message_files).toBeUndefined()
|
||||
expect(enriched[1]!.message_files).toHaveLength(1)
|
||||
expect(enriched[1]!.message_files![0]!.id).toBe('f2')
|
||||
expect(enriched[2]!.message_files).toHaveLength(1)
|
||||
expect(enriched[2]!.message_files![0]!.id).toBe('f1')
|
||||
})
|
||||
|
||||
it('handles null inputs gracefully in the pipeline', () => {
|
||||
const sortedNull = sortAgentSorts(null as never)
|
||||
expect(sortedNull).toBeNull()
|
||||
|
||||
const enrichedNull = addFileInfos(null as never, [])
|
||||
expect(enrichedNull).toBeNull()
|
||||
|
||||
// addFileInfos with empty list and null files returns the mapped (empty) list
|
||||
const enrichedEmptyList = addFileInfos([], null as never)
|
||||
expect(enrichedEmptyList).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default value application', () => {
|
||||
it('applies defaults only to empty fields, preserving user values', () => {
|
||||
const userValues = { api_key: 'user-provided-key' }
|
||||
const schemas = [
|
||||
{ variable: 'api_key', type: 'text-input', default: 'default-key', name: 'api_key' },
|
||||
{ variable: 'secret', type: 'secret-input', default: 'default-secret', name: 'secret' },
|
||||
]
|
||||
|
||||
const result = addDefaultValue(userValues, schemas)
|
||||
expect(result.api_key).toBe('user-provided-key')
|
||||
expect(result.secret).toBe('default-secret')
|
||||
})
|
||||
|
||||
it('handles boolean type conversion in defaults', () => {
|
||||
const schemas = [{ variable: 'enabled', type: 'boolean', default: 'true', name: 'enabled' }]
|
||||
|
||||
const result = addDefaultValue({ enabled: 'true' }, schemas)
|
||||
expect(result.enabled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,721 +0,0 @@
|
||||
import type { Collection } from '@/app/components/tools/types'
|
||||
/**
|
||||
* Integration Test: Tool Provider Detail Flow
|
||||
*
|
||||
* Tests the integration between ProviderDetail, ConfigCredential,
|
||||
* EditCustomToolModal, WorkflowToolModal, and service APIs.
|
||||
* Verifies that different provider types render correctly and
|
||||
* handle auth/edit/delete flows.
|
||||
*/
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: withSelectorKey((key: string, opts?: Record<string, unknown>) => {
|
||||
const map: Record<string, string> = {
|
||||
'auth.authorized': 'Authorized',
|
||||
'auth.unauthorized': 'Set up credentials',
|
||||
'auth.setup': 'NEEDS SETUP',
|
||||
'createTool.editAction': 'Edit',
|
||||
'createTool.deleteToolConfirmTitle': 'Delete Tool',
|
||||
'createTool.deleteToolConfirmContent': 'Are you sure?',
|
||||
'createTool.toolInput.title': 'Tool Input',
|
||||
'createTool.toolInput.required': 'Required',
|
||||
openInStudio: 'Open in Studio',
|
||||
'api.actionSuccess': 'Action succeeded',
|
||||
}
|
||||
if (key === 'detailPanel.actionNum') return `${opts?.num ?? 0} actions`
|
||||
if (key === 'includeToolNum') return `${opts?.num ?? 0} actions`
|
||||
return map[key] ?? key
|
||||
}),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'en',
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
getLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
vi.mock('@/context/account-state', async (importOriginal) => {
|
||||
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
|
||||
return createAppContextStateAtomMock(importOriginal, () => ({
|
||||
isCurrentWorkspaceManager: true,
|
||||
workspacePermissionKeys: [
|
||||
'tool.manage',
|
||||
'credential.create',
|
||||
'credential.manage',
|
||||
'credential.use',
|
||||
],
|
||||
}))
|
||||
})
|
||||
vi.mock('@/context/workspace-state', async (importOriginal) => {
|
||||
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
|
||||
return createAppContextStateAtomMock(importOriginal, () => ({
|
||||
isCurrentWorkspaceManager: true,
|
||||
workspacePermissionKeys: [
|
||||
'tool.manage',
|
||||
'credential.create',
|
||||
'credential.manage',
|
||||
'credential.use',
|
||||
],
|
||||
}))
|
||||
})
|
||||
vi.mock('@/context/permission-state', async (importOriginal) => {
|
||||
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
|
||||
return createAppContextStateAtomMock(importOriginal, () => ({
|
||||
isCurrentWorkspaceManager: true,
|
||||
workspacePermissionKeys: [
|
||||
'tool.manage',
|
||||
'credential.create',
|
||||
'credential.manage',
|
||||
'credential.use',
|
||||
],
|
||||
}))
|
||||
})
|
||||
vi.mock('@/context/version-state', async (importOriginal) => {
|
||||
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
|
||||
return createAppContextStateAtomMock(importOriginal, () => ({
|
||||
isCurrentWorkspaceManager: true,
|
||||
workspacePermissionKeys: [
|
||||
'tool.manage',
|
||||
'credential.create',
|
||||
'credential.manage',
|
||||
'credential.use',
|
||||
],
|
||||
}))
|
||||
})
|
||||
vi.mock('@/context/system-features-state', async (importOriginal) => {
|
||||
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
|
||||
return createAppContextStateAtomMock(importOriginal, () => ({
|
||||
isCurrentWorkspaceManager: true,
|
||||
workspacePermissionKeys: [
|
||||
'tool.manage',
|
||||
'credential.create',
|
||||
'credential.manage',
|
||||
'credential.use',
|
||||
],
|
||||
}))
|
||||
})
|
||||
|
||||
vi.mock('jotai', async (importOriginal) => {
|
||||
const { createAppContextStateJotaiMock } =
|
||||
await import('@/__tests__/utils/mock-app-context-state')
|
||||
return createAppContextStateJotaiMock(importOriginal)
|
||||
})
|
||||
|
||||
const mockSetShowModelModal = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowModelModal: mockSetShowModelModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
modelProviders: [{ provider: 'model-provider-1', name: 'Model Provider 1' }],
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockFetchBuiltInToolList = vi.fn().mockResolvedValue([
|
||||
{ name: 'tool-1', description: { en_US: 'Tool 1' }, parameters: [] },
|
||||
{ name: 'tool-2', description: { en_US: 'Tool 2' }, parameters: [] },
|
||||
])
|
||||
const mockFetchModelToolList = vi.fn().mockResolvedValue([])
|
||||
const mockFetchCustomToolList = vi.fn().mockResolvedValue([])
|
||||
const mockFetchCustomCollection = vi.fn().mockResolvedValue({
|
||||
credentials: { auth_type: 'none' },
|
||||
schema: '',
|
||||
schema_type: 'openapi',
|
||||
})
|
||||
const mockFetchWorkflowToolDetail = vi.fn().mockResolvedValue({
|
||||
workflow_app_id: 'app-123',
|
||||
tool: {
|
||||
parameters: [
|
||||
{
|
||||
name: 'query',
|
||||
llm_description: 'Search query',
|
||||
form: 'text',
|
||||
required: true,
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
labels: ['search'],
|
||||
},
|
||||
})
|
||||
const mockUpdateBuiltInToolCredential = vi.fn().mockResolvedValue({})
|
||||
const mockRemoveBuiltInToolCredential = vi.fn().mockResolvedValue({})
|
||||
const mockUpdateCustomCollection = vi.fn().mockResolvedValue({})
|
||||
const mockRemoveCustomCollection = vi.fn().mockResolvedValue({})
|
||||
const mockDeleteWorkflowTool = vi.fn().mockResolvedValue({})
|
||||
const mockSaveWorkflowToolProvider = vi.fn().mockResolvedValue({})
|
||||
|
||||
vi.mock('@/service/tools', () => ({
|
||||
fetchBuiltInToolList: (...args: unknown[]) => mockFetchBuiltInToolList(...args),
|
||||
fetchModelToolList: (...args: unknown[]) => mockFetchModelToolList(...args),
|
||||
fetchCustomToolList: (...args: unknown[]) => mockFetchCustomToolList(...args),
|
||||
fetchCustomCollection: (...args: unknown[]) => mockFetchCustomCollection(...args),
|
||||
fetchWorkflowToolDetail: (...args: unknown[]) => mockFetchWorkflowToolDetail(...args),
|
||||
updateBuiltInToolCredential: (...args: unknown[]) => mockUpdateBuiltInToolCredential(...args),
|
||||
removeBuiltInToolCredential: (...args: unknown[]) => mockRemoveBuiltInToolCredential(...args),
|
||||
updateCustomCollection: (...args: unknown[]) => mockUpdateCustomCollection(...args),
|
||||
removeCustomCollection: (...args: unknown[]) => mockRemoveCustomCollection(...args),
|
||||
deleteWorkflowTool: (...args: unknown[]) => mockDeleteWorkflowTool(...args),
|
||||
saveWorkflowToolProvider: (...args: unknown[]) => mockSaveWorkflowToolProvider(...args),
|
||||
fetchBuiltInToolCredential: vi.fn().mockResolvedValue({}),
|
||||
fetchBuiltInToolCredentialSchema: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useInvalidateAllWorkflowTools: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/cn', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
basePath: '',
|
||||
}))
|
||||
|
||||
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/icons/src/vender/line/general', () => ({
|
||||
LinkExternal02: () => <span data-testid="link-icon" />,
|
||||
Settings01: () => <span data-testid="settings-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiCloseLine: () => <span data-testid="close-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
|
||||
ConfigurationMethodEnum: { predefinedModel: 'predefined-model' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
|
||||
default: ({ src }: { src: string }) => (
|
||||
<div data-testid="card-icon" data-src={typeof src === 'string' ? src : 'emoji'} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/description', () => ({
|
||||
default: ({ text }: { text: string }) => <div data-testid="description">{text}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/org-info', () => ({
|
||||
default: ({ orgName, packageName }: { orgName: string; packageName: string }) => (
|
||||
<div data-testid="org-info">
|
||||
{orgName} / {packageName}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/title', () => ({
|
||||
default: ({ title }: { title: string }) => <div data-testid="title">{title}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({
|
||||
default: ({
|
||||
onHide,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: {
|
||||
onHide: () => void
|
||||
onEdit: (data: unknown) => void
|
||||
onRemove: () => void
|
||||
payload: unknown
|
||||
}) => (
|
||||
<div data-testid="edit-custom-modal">
|
||||
<button data-testid="custom-modal-hide" onClick={onHide}>
|
||||
Hide
|
||||
</button>
|
||||
<button
|
||||
data-testid="custom-modal-save"
|
||||
onClick={() => onEdit({ name: 'updated', labels: [] })}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button data-testid="custom-modal-remove" onClick={onRemove}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({
|
||||
default: ({
|
||||
onCancel,
|
||||
onSaved,
|
||||
onRemove,
|
||||
}: {
|
||||
collection: Collection
|
||||
onCancel: () => void
|
||||
onSaved: (v: Record<string, unknown>) => void
|
||||
onRemove: () => void
|
||||
}) => (
|
||||
<div data-testid="config-credential">
|
||||
<button data-testid="cred-cancel" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button data-testid="cred-save" onClick={() => onSaved({ api_key: 'test-key' })}>
|
||||
Save
|
||||
</button>
|
||||
<button data-testid="cred-remove" onClick={onRemove}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/workflow-tool', () => ({
|
||||
WorkflowToolDrawer: ({
|
||||
onHide,
|
||||
onSave,
|
||||
onRemove,
|
||||
}: {
|
||||
payload: unknown
|
||||
onHide: () => void
|
||||
onSave: (d: unknown) => void
|
||||
onRemove: () => void
|
||||
}) => (
|
||||
<div data-testid="workflow-tool-modal">
|
||||
<button data-testid="wf-modal-hide" onClick={onHide}>
|
||||
Hide
|
||||
</button>
|
||||
<button data-testid="wf-modal-save" onClick={() => onSave({ name: 'updated-wf' })}>
|
||||
Save
|
||||
</button>
|
||||
<button data-testid="wf-modal-remove" onClick={onRemove}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/provider/tool-item', () => ({
|
||||
default: ({ tool }: { tool: { name: string } }) => (
|
||||
<div data-testid={`tool-item-${tool.name}`}>{tool.name}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const { default: ProviderDetail } = await import('@/app/components/tools/provider/detail')
|
||||
|
||||
const getDeleteConfirmButton = () => screen.getByRole('button', { name: /operation\.confirm$/ })
|
||||
|
||||
const makeCollection = (overrides: Partial<Collection> = {}): Collection => ({
|
||||
id: 'test-collection',
|
||||
name: 'test_collection',
|
||||
author: 'Dify',
|
||||
description: { en_US: 'Test collection description', zh_Hans: '测试集合描述' },
|
||||
icon: 'https://example.com/icon.png',
|
||||
label: { en_US: 'Test Collection', zh_Hans: '测试集合' },
|
||||
type: CollectionType.builtIn,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const mockOnHide = vi.fn()
|
||||
const mockOnRefreshData = vi.fn()
|
||||
|
||||
describe('Tool Provider Detail Flow Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('Built-in Provider', () => {
|
||||
it('renders provider detail with title, author, and description', async () => {
|
||||
const collection = makeCollection()
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={collection}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Test Collection')
|
||||
expect(screen.getByTestId('org-info')).toHaveTextContent('Dify')
|
||||
expect(screen.getByTestId('description')).toHaveTextContent('Test collection description')
|
||||
})
|
||||
})
|
||||
|
||||
it('loads tool list from API on mount', async () => {
|
||||
const collection = makeCollection()
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={collection}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchBuiltInToolList).toHaveBeenCalledWith('test_collection')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('tool-item-tool-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tool-item-tool-2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows "Set up credentials" button when not authorized and needs auth', async () => {
|
||||
const collection = makeCollection({
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={collection}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows "Authorized" button when authorized', async () => {
|
||||
const collection = makeCollection({
|
||||
allow_delete: true,
|
||||
is_team_authorization: true,
|
||||
})
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={collection}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Authorized')).toBeInTheDocument()
|
||||
expect(document.querySelector('.shadow-status-indicator-green-shadow')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens ConfigCredential when clicking auth button (built-in type)', async () => {
|
||||
const collection = makeCollection({
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={collection}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Set up credentials'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('config-credential')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('saves credential and refreshes data', async () => {
|
||||
const collection = makeCollection({
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={collection}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Set up credentials'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('config-credential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('cred-save'))
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateBuiltInToolCredential).toHaveBeenCalledWith('test_collection', {
|
||||
api_key: 'test-key',
|
||||
})
|
||||
expect(mockOnRefreshData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('removes credential and refreshes data', async () => {
|
||||
const collection = makeCollection({
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={collection}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByText('Set up credentials'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('config-credential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('cred-remove'))
|
||||
await waitFor(() => {
|
||||
expect(mockRemoveBuiltInToolCredential).toHaveBeenCalledWith('test_collection')
|
||||
expect(mockOnRefreshData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Model Provider', () => {
|
||||
it('opens model modal when clicking auth button for model type', async () => {
|
||||
const collection = makeCollection({
|
||||
id: 'model-provider-1',
|
||||
type: CollectionType.model,
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={collection}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Set up credentials'))
|
||||
await waitFor(() => {
|
||||
expect(mockSetShowModelModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
currentProvider: expect.objectContaining({ provider: 'model-provider-1' }),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom Provider', () => {
|
||||
it('fetches custom collection details and shows edit button', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.custom,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={collection}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchCustomCollection).toHaveBeenCalledWith('test_collection')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens edit modal and saves changes', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.custom,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={collection}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Edit'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('edit-custom-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom-modal-save'))
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateCustomCollection).toHaveBeenCalled()
|
||||
expect(mockOnRefreshData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows delete confirmation and removes collection', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.custom,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={collection}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Edit'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('edit-custom-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom-modal-remove'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete Tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(getDeleteConfirmButton())
|
||||
await waitFor(() => {
|
||||
expect(mockRemoveCustomCollection).toHaveBeenCalledWith('test_collection')
|
||||
expect(mockOnRefreshData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workflow Provider', () => {
|
||||
it('fetches workflow tool detail and shows "Open in Studio" and "Edit" buttons', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.workflow,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={collection}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowToolDetail).toHaveBeenCalledWith('test-collection')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Open in Studio')).toBeInTheDocument()
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows workflow tool parameters', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.workflow,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={collection}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('query')).toBeInTheDocument()
|
||||
expect(screen.getByText('string')).toBeInTheDocument()
|
||||
expect(screen.getByText('Search query')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes workflow tool through confirmation dialog', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.workflow,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={collection}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Edit'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('workflow-tool-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('wf-modal-remove'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete Tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(getDeleteConfirmButton())
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteWorkflowTool).toHaveBeenCalledWith('test-collection')
|
||||
expect(mockOnRefreshData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Drawer Interaction', () => {
|
||||
it('calls onHide when closing the drawer', async () => {
|
||||
const collection = makeCollection()
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={collection}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'operation.close' }))
|
||||
expect(mockOnHide).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,399 +0,0 @@
|
||||
/**
|
||||
* Unified Tags Editing - Pure Logic Tests
|
||||
*
|
||||
* This test file validates the core business logic and state management
|
||||
* behaviors introduced in the recent 7 commits without requiring complex mocks.
|
||||
*/
|
||||
|
||||
describe('Unified Tags Editing - Pure Logic Tests', () => {
|
||||
describe('Tag State Management Logic', () => {
|
||||
it('should detect when tag values have changed', () => {
|
||||
const currentValue = ['tag1', 'tag2']
|
||||
const newSelectedTagIDs = ['tag1', 'tag3']
|
||||
|
||||
// This is the valueNotChanged logic from TagSelector component
|
||||
const valueNotChanged =
|
||||
currentValue.length === newSelectedTagIDs.length &&
|
||||
currentValue.every((v) => newSelectedTagIDs.includes(v)) &&
|
||||
newSelectedTagIDs.every((v) => currentValue.includes(v))
|
||||
|
||||
expect(valueNotChanged).toBe(false)
|
||||
})
|
||||
|
||||
it('should correctly identify unchanged tag values', () => {
|
||||
const currentValue = ['tag1', 'tag2']
|
||||
const newSelectedTagIDs = ['tag2', 'tag1'] // Same tags, different order
|
||||
|
||||
const valueNotChanged =
|
||||
currentValue.length === newSelectedTagIDs.length &&
|
||||
currentValue.every((v) => newSelectedTagIDs.includes(v)) &&
|
||||
newSelectedTagIDs.every((v) => currentValue.includes(v))
|
||||
|
||||
expect(valueNotChanged).toBe(true)
|
||||
})
|
||||
|
||||
it('should calculate correct tag operations for binding/unbinding', () => {
|
||||
const currentValue = ['tag1', 'tag2']
|
||||
const selectedTagIDs = ['tag2', 'tag3']
|
||||
|
||||
// This is the handleValueChange logic from TagSelector
|
||||
const addTagIDs = selectedTagIDs.filter((v) => !currentValue.includes(v))
|
||||
const removeTagIDs = currentValue.filter((v) => !selectedTagIDs.includes(v))
|
||||
|
||||
expect(addTagIDs).toEqual(['tag3'])
|
||||
expect(removeTagIDs).toEqual(['tag1'])
|
||||
})
|
||||
|
||||
it('should handle empty tag arrays correctly', () => {
|
||||
const currentValue: string[] = []
|
||||
const selectedTagIDs = ['tag1']
|
||||
|
||||
const addTagIDs = selectedTagIDs.filter((v) => !currentValue.includes(v))
|
||||
const removeTagIDs = currentValue.filter((v) => !selectedTagIDs.includes(v))
|
||||
|
||||
expect(addTagIDs).toEqual(['tag1'])
|
||||
expect(removeTagIDs).toEqual([])
|
||||
expect(currentValue.length).toBe(0) // Verify empty array usage
|
||||
})
|
||||
|
||||
it('should handle removing all tags', () => {
|
||||
const currentValue = ['tag1', 'tag2']
|
||||
const selectedTagIDs: string[] = []
|
||||
|
||||
const addTagIDs = selectedTagIDs.filter((v) => !currentValue.includes(v))
|
||||
const removeTagIDs = currentValue.filter((v) => !selectedTagIDs.includes(v))
|
||||
|
||||
expect(addTagIDs).toEqual([])
|
||||
expect(removeTagIDs).toEqual(['tag1', 'tag2'])
|
||||
expect(selectedTagIDs.length).toBe(0) // Verify empty array usage
|
||||
})
|
||||
})
|
||||
|
||||
describe('Fallback Logic (from layout-main.tsx)', () => {
|
||||
type Tag = { id: string; name: string }
|
||||
type AppDetail = { tags: Tag[] }
|
||||
type FallbackResult = { tags?: Tag[] } | null
|
||||
// no-op
|
||||
it('should trigger fallback when tags are missing or empty', () => {
|
||||
const appDetailWithoutTags: AppDetail = { tags: [] }
|
||||
const appDetailWithTags: AppDetail = { tags: [{ id: 'tag1', name: 't' }] }
|
||||
const appDetailWithUndefinedTags: { tags: Tag[] | undefined } = { tags: undefined }
|
||||
|
||||
// This simulates the condition in layout-main.tsx
|
||||
const shouldFallback1 = appDetailWithoutTags.tags.length === 0
|
||||
const shouldFallback2 = appDetailWithTags.tags.length === 0
|
||||
const shouldFallback3 =
|
||||
!appDetailWithUndefinedTags.tags || appDetailWithUndefinedTags.tags.length === 0
|
||||
|
||||
expect(shouldFallback1).toBe(true) // Empty array should trigger fallback
|
||||
expect(shouldFallback2).toBe(false) // Has tags, no fallback needed
|
||||
expect(shouldFallback3).toBe(true) // Undefined tags should trigger fallback
|
||||
})
|
||||
|
||||
it('should preserve tags when fallback succeeds', () => {
|
||||
const originalAppDetail: AppDetail = { tags: [] }
|
||||
const fallbackResult: { tags?: Tag[] } = { tags: [{ id: 'tag1', name: 'fallback-tag' }] }
|
||||
|
||||
// This simulates the successful fallback in layout-main.tsx
|
||||
const tags = fallbackResult.tags
|
||||
if (tags) originalAppDetail.tags = tags
|
||||
|
||||
expect(originalAppDetail.tags).toEqual(fallbackResult.tags)
|
||||
expect(originalAppDetail.tags.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should continue with empty tags when fallback fails', () => {
|
||||
const originalAppDetail: AppDetail = { tags: [] }
|
||||
const fallbackResult = null as FallbackResult
|
||||
|
||||
// This simulates fallback failure in layout-main.tsx
|
||||
const tags: Tag[] | undefined =
|
||||
fallbackResult && 'tags' in fallbackResult ? fallbackResult.tags : undefined
|
||||
if (tags) originalAppDetail.tags = tags
|
||||
|
||||
expect(originalAppDetail.tags).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('TagSelector Auto-initialization Logic', () => {
|
||||
it('should trigger getTagList when tagList is empty', () => {
|
||||
const tagList: any[] = []
|
||||
let getTagListCalled = false
|
||||
const getTagList = () => {
|
||||
getTagListCalled = true
|
||||
}
|
||||
|
||||
// This simulates the useEffect in TagSelector
|
||||
if (tagList.length === 0) getTagList()
|
||||
|
||||
expect(getTagListCalled).toBe(true)
|
||||
})
|
||||
|
||||
it('should not trigger getTagList when tagList has items', () => {
|
||||
const tagList = [{ id: 'tag1', name: 'existing-tag' }]
|
||||
let getTagListCalled = false
|
||||
const getTagList = () => {
|
||||
getTagListCalled = true
|
||||
}
|
||||
|
||||
// This simulates the useEffect in TagSelector
|
||||
if (tagList.length === 0) getTagList()
|
||||
|
||||
expect(getTagListCalled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('State Initialization Patterns', () => {
|
||||
it('should maintain AppCard tag state pattern', () => {
|
||||
const app = { tags: [{ id: 'tag1', name: 'test' }] }
|
||||
|
||||
// Original AppCard pattern: useState(app.tags)
|
||||
const initialTags = app.tags
|
||||
expect(Array.isArray(initialTags)).toBe(true)
|
||||
expect(initialTags.length).toBe(1)
|
||||
expect(initialTags).toBe(app.tags) // Reference equality for AppCard
|
||||
})
|
||||
|
||||
it('should maintain AppInfo tag state pattern', () => {
|
||||
const appDetail = { tags: [{ id: 'tag1', name: 'test' }] }
|
||||
|
||||
// New AppInfo pattern: useState(appDetail?.tags || [])
|
||||
const initialTags = appDetail?.tags || []
|
||||
expect(Array.isArray(initialTags)).toBe(true)
|
||||
expect(initialTags.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should handle undefined appDetail gracefully in AppInfo', () => {
|
||||
const appDetail = undefined
|
||||
|
||||
// AppInfo pattern with undefined appDetail
|
||||
const initialTags = (appDetail as any)?.tags || []
|
||||
expect(Array.isArray(initialTags)).toBe(true)
|
||||
expect(initialTags.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSS Class and Layout Logic', () => {
|
||||
it('should apply correct minimum width condition', () => {
|
||||
const minWidth = 'true'
|
||||
|
||||
// This tests the minWidth logic in TagSelector
|
||||
const shouldApplyMinWidth = minWidth && '!min-w-80'
|
||||
expect(shouldApplyMinWidth).toBe('!min-w-80')
|
||||
})
|
||||
|
||||
it('should not apply minimum width when not specified', () => {
|
||||
const minWidth = undefined
|
||||
|
||||
const shouldApplyMinWidth = minWidth && '!min-w-80'
|
||||
expect(shouldApplyMinWidth).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should handle overflow layout classes correctly', () => {
|
||||
// This tests the layout pattern from AppCard and new AppInfo
|
||||
const overflowLayoutClasses = {
|
||||
container: 'flex w-0 grow items-center',
|
||||
inner: 'w-full',
|
||||
truncate: 'truncate',
|
||||
}
|
||||
|
||||
expect(overflowLayoutClasses.container).toContain('w-0 grow')
|
||||
expect(overflowLayoutClasses.inner).toContain('w-full')
|
||||
expect(overflowLayoutClasses.truncate).toBe('truncate')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchAppWithTags Service Logic', () => {
|
||||
it('should correctly find app by ID from app list', () => {
|
||||
const appList = [
|
||||
{ id: 'app1', name: 'App 1', tags: [] },
|
||||
{ id: 'test-app-id', name: 'Test App', tags: [{ id: 'tag1', name: 'test' }] },
|
||||
{ id: 'app3', name: 'App 3', tags: [] },
|
||||
]
|
||||
const targetAppId = 'test-app-id'
|
||||
|
||||
// This simulates the logic in fetchAppWithTags
|
||||
const foundApp = appList.find((app) => app.id === targetAppId)
|
||||
|
||||
expect(foundApp).toBeDefined()
|
||||
expect(foundApp?.id).toBe('test-app-id')
|
||||
expect(foundApp?.tags.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should return null when app not found', () => {
|
||||
const appList = [
|
||||
{ id: 'app1', name: 'App 1' },
|
||||
{ id: 'app2', name: 'App 2' },
|
||||
]
|
||||
const targetAppId = 'nonexistent-app'
|
||||
|
||||
const foundApp = appList.find((app) => app.id === targetAppId) || null
|
||||
|
||||
expect(foundApp).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle empty app list', () => {
|
||||
const appList: any[] = []
|
||||
const targetAppId = 'any-app'
|
||||
|
||||
const foundApp = appList.find((app) => app.id === targetAppId) || null
|
||||
|
||||
expect(foundApp).toBeNull()
|
||||
expect(appList.length).toBe(0) // Verify empty array usage
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data Structure Validation', () => {
|
||||
it('should maintain consistent tag data structure', () => {
|
||||
const tag = {
|
||||
id: 'tag1',
|
||||
name: 'test-tag',
|
||||
type: 'app',
|
||||
binding_count: '',
|
||||
}
|
||||
|
||||
expect(tag).toHaveProperty('id')
|
||||
expect(tag).toHaveProperty('name')
|
||||
expect(tag).toHaveProperty('type')
|
||||
expect(tag).toHaveProperty('binding_count')
|
||||
expect(tag.type).toBe('app')
|
||||
expect(typeof tag.binding_count).toBe('string')
|
||||
})
|
||||
|
||||
it('should handle tag arrays correctly', () => {
|
||||
const tags = [
|
||||
{ id: 'tag1', name: 'Tag 1', type: 'app', binding_count: '' },
|
||||
{ id: 'tag2', name: 'Tag 2', type: 'app', binding_count: '' },
|
||||
]
|
||||
|
||||
expect(Array.isArray(tags)).toBe(true)
|
||||
expect(tags.length).toBe(2)
|
||||
expect(tags.every((tag) => tag.type === 'app')).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate app data structure with tags', () => {
|
||||
const app = {
|
||||
id: 'test-app',
|
||||
name: 'Test App',
|
||||
tags: [{ id: 'tag1', name: 'Tag 1', type: 'app', binding_count: '' }],
|
||||
}
|
||||
|
||||
expect(app).toHaveProperty('id')
|
||||
expect(app).toHaveProperty('name')
|
||||
expect(app).toHaveProperty('tags')
|
||||
expect(Array.isArray(app.tags)).toBe(true)
|
||||
expect(app.tags.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance and Edge Cases', () => {
|
||||
it('should handle large tag arrays efficiently', () => {
|
||||
const largeTags = Array.from({ length: 100 }, (_, i) => `tag${i}`)
|
||||
const selectedTags = ['tag1', 'tag50', 'tag99']
|
||||
|
||||
// Performance test: filtering should be efficient
|
||||
const startTime = Date.now()
|
||||
const addTags = selectedTags.filter((tag) => !largeTags.includes(tag))
|
||||
const removeTags = largeTags.filter((tag) => !selectedTags.includes(tag))
|
||||
const endTime = Date.now()
|
||||
|
||||
expect(endTime - startTime).toBeLessThan(10) // Should be very fast
|
||||
expect(addTags.length).toBe(0) // All selected tags exist
|
||||
expect(removeTags.length).toBe(97) // 100 - 3 = 97 tags to remove
|
||||
})
|
||||
|
||||
it('should handle malformed tag data gracefully', () => {
|
||||
const mixedData = [
|
||||
{ id: 'valid1', name: 'Valid Tag', type: 'app', binding_count: '' },
|
||||
{ id: 'invalid1' }, // Missing required properties
|
||||
null,
|
||||
undefined,
|
||||
{ id: 'valid2', name: 'Another Valid', type: 'app', binding_count: '' },
|
||||
]
|
||||
|
||||
// Filter out invalid entries
|
||||
const validTags = mixedData.filter(
|
||||
(tag): tag is { id: string; name: string; type: string; binding_count: string } =>
|
||||
tag != null &&
|
||||
typeof tag === 'object' &&
|
||||
'id' in tag &&
|
||||
'name' in tag &&
|
||||
'type' in tag &&
|
||||
'binding_count' in tag &&
|
||||
typeof tag.binding_count === 'string',
|
||||
)
|
||||
|
||||
expect(validTags.length).toBe(2)
|
||||
expect(validTags.every((tag) => tag.id && tag.name)).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle concurrent tag operations correctly', () => {
|
||||
const operations = [
|
||||
{ type: 'add', tagIds: ['tag1', 'tag2'] },
|
||||
{ type: 'remove', tagIds: ['tag3'] },
|
||||
{ type: 'add', tagIds: ['tag4'] },
|
||||
]
|
||||
|
||||
// Simulate processing operations
|
||||
const results = operations.map((op) => ({
|
||||
...op,
|
||||
processed: true,
|
||||
timestamp: Date.now(),
|
||||
}))
|
||||
|
||||
expect(results.length).toBe(3)
|
||||
expect(results.every((result) => result.processed)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Backward Compatibility Verification', () => {
|
||||
it('should not break existing AppCard behavior', () => {
|
||||
// Verify AppCard continues to work with original patterns
|
||||
const originalAppCardLogic = {
|
||||
initializeTags: (app: any) => app.tags,
|
||||
updateTags: (_currentTags: any[], newTags: any[]) => newTags,
|
||||
shouldRefresh: true,
|
||||
}
|
||||
|
||||
const app = { tags: [{ id: 'tag1', name: 'original' }] }
|
||||
const initializedTags = originalAppCardLogic.initializeTags(app)
|
||||
|
||||
expect(initializedTags).toBe(app.tags)
|
||||
expect(originalAppCardLogic.shouldRefresh).toBe(true)
|
||||
})
|
||||
|
||||
it('should ensure AppInfo follows AppCard patterns', () => {
|
||||
// Verify AppInfo uses compatible state management
|
||||
const appCardPattern = (app: any) => app.tags
|
||||
const appInfoPattern = (appDetail: any) => appDetail?.tags || []
|
||||
|
||||
const appWithTags = { tags: [{ id: 'tag1' }] }
|
||||
const appWithoutTags = { tags: [] }
|
||||
const undefinedApp = undefined
|
||||
|
||||
expect(appCardPattern(appWithTags)).toEqual(appInfoPattern(appWithTags))
|
||||
expect(appInfoPattern(appWithoutTags)).toEqual([])
|
||||
expect(appInfoPattern(undefinedApp)).toEqual([])
|
||||
})
|
||||
|
||||
it('should maintain consistent API parameters', () => {
|
||||
// Verify service layer maintains expected parameters
|
||||
const fetchAppListParams = {
|
||||
url: '/apps',
|
||||
params: { page: 1, limit: 100 },
|
||||
}
|
||||
|
||||
const tagApiParams = {
|
||||
bindTag: (tagIDs: string[], targetID: string, type: string) => ({ tagIDs, targetID, type }),
|
||||
unBindTag: (tagID: string, targetID: string, type: string) => ({ tagID, targetID, type }),
|
||||
}
|
||||
|
||||
expect(fetchAppListParams.url).toBe('/apps')
|
||||
expect(fetchAppListParams.params.limit).toBe(100)
|
||||
|
||||
const bindResult = tagApiParams.bindTag(['tag1'], 'app1', 'app')
|
||||
expect(bindResult.tagIDs).toEqual(['tag1'])
|
||||
expect(bindResult.type).toBe('app')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,657 +0,0 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
|
||||
// Type for mocked store
|
||||
type MockWorkflowStore = {
|
||||
showOnboarding: boolean
|
||||
setShowOnboarding: Mock
|
||||
hasShownOnboarding: boolean
|
||||
setHasShownOnboarding: Mock
|
||||
hasSelectedStartNode: boolean
|
||||
setHasSelectedStartNode: Mock
|
||||
setShouldAutoOpenStartNodeSelector: Mock
|
||||
notInitialWorkflow: boolean
|
||||
}
|
||||
|
||||
// Type for mocked node
|
||||
type MockNode = {
|
||||
id: string
|
||||
data: { type?: BlockEnum }
|
||||
}
|
||||
|
||||
// Mock zustand store
|
||||
vi.mock('@/app/components/workflow/store')
|
||||
|
||||
// Mock ReactFlow store
|
||||
const mockGetNodes = vi.fn()
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => ({
|
||||
getState: () => ({
|
||||
getNodes: mockGetNodes,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Workflow Onboarding Integration Logic', () => {
|
||||
const mockSetShowOnboarding = vi.fn()
|
||||
const mockSetHasSelectedStartNode = vi.fn()
|
||||
const mockSetHasShownOnboarding = vi.fn()
|
||||
const mockSetShouldAutoOpenStartNodeSelector = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Mock store implementation
|
||||
;(useWorkflowStore as Mock).mockReturnValue({
|
||||
showOnboarding: false,
|
||||
setShowOnboarding: mockSetShowOnboarding,
|
||||
hasSelectedStartNode: false,
|
||||
setHasSelectedStartNode: mockSetHasSelectedStartNode,
|
||||
hasShownOnboarding: false,
|
||||
setHasShownOnboarding: mockSetHasShownOnboarding,
|
||||
notInitialWorkflow: false,
|
||||
shouldAutoOpenStartNodeSelector: false,
|
||||
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Onboarding State Management', () => {
|
||||
it('should initialize onboarding state correctly', () => {
|
||||
const store = useWorkflowStore() as unknown as MockWorkflowStore
|
||||
|
||||
expect(store.showOnboarding).toBe(false)
|
||||
expect(store.hasSelectedStartNode).toBe(false)
|
||||
expect(store.hasShownOnboarding).toBe(false)
|
||||
})
|
||||
|
||||
it('should update onboarding visibility', () => {
|
||||
const store = useWorkflowStore() as unknown as MockWorkflowStore
|
||||
|
||||
store.setShowOnboarding(true)
|
||||
expect(mockSetShowOnboarding).toHaveBeenCalledWith(true)
|
||||
|
||||
store.setShowOnboarding(false)
|
||||
expect(mockSetShowOnboarding).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should track node selection state', () => {
|
||||
const store = useWorkflowStore() as unknown as MockWorkflowStore
|
||||
|
||||
store.setHasSelectedStartNode(true)
|
||||
expect(mockSetHasSelectedStartNode).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should track onboarding show state', () => {
|
||||
const store = useWorkflowStore() as unknown as MockWorkflowStore
|
||||
|
||||
store.setHasShownOnboarding(true)
|
||||
expect(mockSetHasShownOnboarding).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Validation Logic', () => {
|
||||
/**
|
||||
* Test the critical fix in use-nodes-sync-draft.ts
|
||||
* This ensures trigger nodes are recognized as valid start nodes
|
||||
*/
|
||||
it('should validate Start node as valid start node', () => {
|
||||
const mockNode = {
|
||||
data: { type: BlockEnum.Start },
|
||||
id: 'start-1',
|
||||
}
|
||||
|
||||
// Simulate the validation logic from use-nodes-sync-draft.ts
|
||||
const isValidStartNode =
|
||||
mockNode.data.type === BlockEnum.Start ||
|
||||
mockNode.data.type === BlockEnum.TriggerSchedule ||
|
||||
mockNode.data.type === BlockEnum.TriggerWebhook ||
|
||||
mockNode.data.type === BlockEnum.TriggerPlugin
|
||||
|
||||
expect(isValidStartNode).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate TriggerSchedule as valid start node', () => {
|
||||
const mockNode = {
|
||||
data: { type: BlockEnum.TriggerSchedule },
|
||||
id: 'trigger-schedule-1',
|
||||
}
|
||||
|
||||
const isValidStartNode =
|
||||
mockNode.data.type === BlockEnum.Start ||
|
||||
mockNode.data.type === BlockEnum.TriggerSchedule ||
|
||||
mockNode.data.type === BlockEnum.TriggerWebhook ||
|
||||
mockNode.data.type === BlockEnum.TriggerPlugin
|
||||
|
||||
expect(isValidStartNode).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate TriggerWebhook as valid start node', () => {
|
||||
const mockNode = {
|
||||
data: { type: BlockEnum.TriggerWebhook },
|
||||
id: 'trigger-webhook-1',
|
||||
}
|
||||
|
||||
const isValidStartNode =
|
||||
mockNode.data.type === BlockEnum.Start ||
|
||||
mockNode.data.type === BlockEnum.TriggerSchedule ||
|
||||
mockNode.data.type === BlockEnum.TriggerWebhook ||
|
||||
mockNode.data.type === BlockEnum.TriggerPlugin
|
||||
|
||||
expect(isValidStartNode).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate TriggerPlugin as valid start node', () => {
|
||||
const mockNode = {
|
||||
data: { type: BlockEnum.TriggerPlugin },
|
||||
id: 'trigger-plugin-1',
|
||||
}
|
||||
|
||||
const isValidStartNode =
|
||||
mockNode.data.type === BlockEnum.Start ||
|
||||
mockNode.data.type === BlockEnum.TriggerSchedule ||
|
||||
mockNode.data.type === BlockEnum.TriggerWebhook ||
|
||||
mockNode.data.type === BlockEnum.TriggerPlugin
|
||||
|
||||
expect(isValidStartNode).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject non-trigger nodes as invalid start nodes', () => {
|
||||
const mockNode = {
|
||||
data: { type: BlockEnum.LLM },
|
||||
id: 'llm-1',
|
||||
}
|
||||
|
||||
const isValidStartNode =
|
||||
mockNode.data.type === BlockEnum.Start ||
|
||||
mockNode.data.type === BlockEnum.TriggerSchedule ||
|
||||
mockNode.data.type === BlockEnum.TriggerWebhook ||
|
||||
mockNode.data.type === BlockEnum.TriggerPlugin
|
||||
|
||||
expect(isValidStartNode).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle array of nodes with mixed types', () => {
|
||||
const mockNodes = [
|
||||
{ data: { type: BlockEnum.LLM }, id: 'llm-1' },
|
||||
{ data: { type: BlockEnum.TriggerWebhook }, id: 'webhook-1' },
|
||||
{ data: { type: BlockEnum.Answer }, id: 'answer-1' },
|
||||
]
|
||||
|
||||
// Simulate hasStartNode logic from use-nodes-sync-draft.ts
|
||||
const hasStartNode = mockNodes.find(
|
||||
(node) =>
|
||||
node.data.type === BlockEnum.Start ||
|
||||
node.data.type === BlockEnum.TriggerSchedule ||
|
||||
node.data.type === BlockEnum.TriggerWebhook ||
|
||||
node.data.type === BlockEnum.TriggerPlugin,
|
||||
)
|
||||
|
||||
expect(hasStartNode).toBeTruthy()
|
||||
expect(hasStartNode?.id).toBe('webhook-1')
|
||||
})
|
||||
|
||||
it('should return undefined when no valid start nodes exist', () => {
|
||||
const mockNodes = [
|
||||
{ data: { type: BlockEnum.LLM }, id: 'llm-1' },
|
||||
{ data: { type: BlockEnum.Answer }, id: 'answer-1' },
|
||||
]
|
||||
|
||||
const hasStartNode = mockNodes.find(
|
||||
(node) =>
|
||||
node.data.type === BlockEnum.Start ||
|
||||
node.data.type === BlockEnum.TriggerSchedule ||
|
||||
node.data.type === BlockEnum.TriggerWebhook ||
|
||||
node.data.type === BlockEnum.TriggerPlugin,
|
||||
)
|
||||
|
||||
expect(hasStartNode).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Auto-open Logic for Node Handles', () => {
|
||||
/**
|
||||
* Test the auto-open logic from node-handle.tsx
|
||||
* This ensures all trigger types auto-open the block selector when flagged
|
||||
*/
|
||||
it('should auto-expand for Start node in new workflow', () => {
|
||||
const shouldAutoOpenStartNodeSelector = true
|
||||
const nodeType = BlockEnum.Start
|
||||
const isChatMode = false
|
||||
|
||||
const shouldAutoExpand =
|
||||
shouldAutoOpenStartNodeSelector &&
|
||||
(nodeType === BlockEnum.Start ||
|
||||
nodeType === BlockEnum.TriggerSchedule ||
|
||||
nodeType === BlockEnum.TriggerWebhook ||
|
||||
nodeType === BlockEnum.TriggerPlugin) &&
|
||||
!isChatMode
|
||||
|
||||
expect(shouldAutoExpand).toBe(true)
|
||||
})
|
||||
|
||||
it('should auto-expand for TriggerSchedule in new workflow', () => {
|
||||
const shouldAutoOpenStartNodeSelector = true
|
||||
const nodeType: BlockEnum = BlockEnum.TriggerSchedule
|
||||
const isChatMode = false
|
||||
const validStartTypes = [
|
||||
BlockEnum.Start,
|
||||
BlockEnum.TriggerSchedule,
|
||||
BlockEnum.TriggerWebhook,
|
||||
BlockEnum.TriggerPlugin,
|
||||
]
|
||||
|
||||
const shouldAutoExpand =
|
||||
shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode
|
||||
|
||||
expect(shouldAutoExpand).toBe(true)
|
||||
})
|
||||
|
||||
it('should auto-expand for TriggerWebhook in new workflow', () => {
|
||||
const shouldAutoOpenStartNodeSelector = true
|
||||
const nodeType: BlockEnum = BlockEnum.TriggerWebhook
|
||||
const isChatMode = false
|
||||
const validStartTypes = [
|
||||
BlockEnum.Start,
|
||||
BlockEnum.TriggerSchedule,
|
||||
BlockEnum.TriggerWebhook,
|
||||
BlockEnum.TriggerPlugin,
|
||||
]
|
||||
|
||||
const shouldAutoExpand =
|
||||
shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode
|
||||
|
||||
expect(shouldAutoExpand).toBe(true)
|
||||
})
|
||||
|
||||
it('should auto-expand for TriggerPlugin in new workflow', () => {
|
||||
const shouldAutoOpenStartNodeSelector = true
|
||||
const nodeType: BlockEnum = BlockEnum.TriggerPlugin
|
||||
const isChatMode = false
|
||||
const validStartTypes = [
|
||||
BlockEnum.Start,
|
||||
BlockEnum.TriggerSchedule,
|
||||
BlockEnum.TriggerWebhook,
|
||||
BlockEnum.TriggerPlugin,
|
||||
]
|
||||
|
||||
const shouldAutoExpand =
|
||||
shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode
|
||||
|
||||
expect(shouldAutoExpand).toBe(true)
|
||||
})
|
||||
|
||||
it('should not auto-expand for non-trigger nodes', () => {
|
||||
const shouldAutoOpenStartNodeSelector = true
|
||||
const nodeType: BlockEnum = BlockEnum.LLM
|
||||
const isChatMode = false
|
||||
const validStartTypes = [
|
||||
BlockEnum.Start,
|
||||
BlockEnum.TriggerSchedule,
|
||||
BlockEnum.TriggerWebhook,
|
||||
BlockEnum.TriggerPlugin,
|
||||
]
|
||||
|
||||
const shouldAutoExpand =
|
||||
shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode
|
||||
|
||||
expect(shouldAutoExpand).toBe(false)
|
||||
})
|
||||
|
||||
it('should not auto-expand in chat mode', () => {
|
||||
const shouldAutoOpenStartNodeSelector = true
|
||||
const nodeType = BlockEnum.Start
|
||||
const isChatMode = true
|
||||
|
||||
const shouldAutoExpand =
|
||||
shouldAutoOpenStartNodeSelector &&
|
||||
(nodeType === BlockEnum.Start ||
|
||||
nodeType === BlockEnum.TriggerSchedule ||
|
||||
nodeType === BlockEnum.TriggerWebhook ||
|
||||
nodeType === BlockEnum.TriggerPlugin) &&
|
||||
!isChatMode
|
||||
|
||||
expect(shouldAutoExpand).toBe(false)
|
||||
})
|
||||
|
||||
it('should not auto-expand for existing workflows', () => {
|
||||
const shouldAutoOpenStartNodeSelector = false
|
||||
const nodeType = BlockEnum.Start
|
||||
const isChatMode = false
|
||||
|
||||
const shouldAutoExpand =
|
||||
shouldAutoOpenStartNodeSelector &&
|
||||
(nodeType === BlockEnum.Start ||
|
||||
nodeType === BlockEnum.TriggerSchedule ||
|
||||
nodeType === BlockEnum.TriggerWebhook ||
|
||||
nodeType === BlockEnum.TriggerPlugin) &&
|
||||
!isChatMode
|
||||
|
||||
expect(shouldAutoExpand).toBe(false)
|
||||
})
|
||||
it('should reset auto-open flag after triggering once', () => {
|
||||
let shouldAutoOpenStartNodeSelector = true
|
||||
const nodeType = BlockEnum.Start
|
||||
const isChatMode = false
|
||||
|
||||
const shouldAutoExpand =
|
||||
shouldAutoOpenStartNodeSelector &&
|
||||
(nodeType === BlockEnum.Start ||
|
||||
nodeType === BlockEnum.TriggerSchedule ||
|
||||
nodeType === BlockEnum.TriggerWebhook ||
|
||||
nodeType === BlockEnum.TriggerPlugin) &&
|
||||
!isChatMode
|
||||
|
||||
if (shouldAutoExpand) shouldAutoOpenStartNodeSelector = false
|
||||
|
||||
expect(shouldAutoExpand).toBe(true)
|
||||
expect(shouldAutoOpenStartNodeSelector).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Creation Without Auto-selection', () => {
|
||||
/**
|
||||
* Test that nodes are created without the 'selected: true' property
|
||||
* This prevents auto-opening the properties panel
|
||||
*/
|
||||
it('should create Start node without auto-selection', () => {
|
||||
const nodeData = { type: BlockEnum.Start, title: 'Start' }
|
||||
|
||||
// Simulate node creation logic from workflow-children.tsx
|
||||
const createdNodeData: Record<string, unknown> = {
|
||||
...nodeData,
|
||||
// Note: 'selected: true' should NOT be added
|
||||
}
|
||||
|
||||
expect(createdNodeData.selected).toBeUndefined()
|
||||
expect(createdNodeData.type).toBe(BlockEnum.Start)
|
||||
})
|
||||
|
||||
it('should create TriggerWebhook node without auto-selection', () => {
|
||||
const nodeData = { type: BlockEnum.TriggerWebhook, title: 'Webhook Trigger' }
|
||||
const toolConfig = { webhook_url: 'https://example.com/webhook' }
|
||||
|
||||
const createdNodeData: Record<string, unknown> = {
|
||||
...nodeData,
|
||||
...toolConfig,
|
||||
// Note: 'selected: true' should NOT be added
|
||||
}
|
||||
|
||||
expect(createdNodeData.selected).toBeUndefined()
|
||||
expect(createdNodeData.type).toBe(BlockEnum.TriggerWebhook)
|
||||
expect(createdNodeData.webhook_url).toBe('https://example.com/webhook')
|
||||
})
|
||||
|
||||
it('should preserve other node properties while avoiding auto-selection', () => {
|
||||
const nodeData = {
|
||||
type: BlockEnum.TriggerSchedule,
|
||||
title: 'Schedule Trigger',
|
||||
config: { interval: '1h' },
|
||||
}
|
||||
|
||||
const createdNodeData: Record<string, unknown> = {
|
||||
...nodeData,
|
||||
}
|
||||
|
||||
expect(createdNodeData.selected).toBeUndefined()
|
||||
expect(createdNodeData.type).toBe(BlockEnum.TriggerSchedule)
|
||||
expect(createdNodeData.title).toBe('Schedule Trigger')
|
||||
expect(createdNodeData.config).toEqual({ interval: '1h' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workflow Initialization Logic', () => {
|
||||
/**
|
||||
* Test the initialization logic from use-workflow-init.ts
|
||||
* This ensures onboarding is triggered correctly for new workflows
|
||||
*/
|
||||
it('should trigger onboarding for new workflow when draft does not exist', () => {
|
||||
// Simulate the error handling logic from use-workflow-init.ts
|
||||
const error = {
|
||||
json: vi.fn().mockResolvedValue({ code: 'draft_workflow_not_exist' }),
|
||||
bodyUsed: false,
|
||||
}
|
||||
|
||||
const mockWorkflowStore = {
|
||||
setState: vi.fn(),
|
||||
}
|
||||
|
||||
// Simulate error handling
|
||||
if (error && error.json && !error.bodyUsed) {
|
||||
error.json().then((err: any) => {
|
||||
if (err.code === 'draft_workflow_not_exist') {
|
||||
mockWorkflowStore.setState({
|
||||
notInitialWorkflow: true,
|
||||
showOnboarding: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return error.json().then(() => {
|
||||
expect(mockWorkflowStore.setState).toHaveBeenCalledWith({
|
||||
notInitialWorkflow: true,
|
||||
showOnboarding: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not trigger onboarding for existing workflows', () => {
|
||||
// Simulate successful draft fetch
|
||||
const mockWorkflowStore = {
|
||||
setState: vi.fn(),
|
||||
}
|
||||
|
||||
// Normal initialization path should not set showOnboarding: true
|
||||
mockWorkflowStore.setState({
|
||||
environmentVariables: [],
|
||||
conversationVariables: [],
|
||||
})
|
||||
|
||||
expect(mockWorkflowStore.setState).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ showOnboarding: true }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should create empty draft with proper structure', () => {
|
||||
const mockSyncWorkflowDraft = vi.fn()
|
||||
const appId = 'test-app-id'
|
||||
|
||||
// Simulate the syncWorkflowDraft call from use-workflow-init.ts
|
||||
const draftParams = {
|
||||
url: `/apps/${appId}/workflows/draft`,
|
||||
params: {
|
||||
graph: {
|
||||
nodes: [], // Empty nodes initially
|
||||
edges: [],
|
||||
},
|
||||
features: {
|
||||
retriever_resource: { enabled: true },
|
||||
},
|
||||
environment_variables: [],
|
||||
conversation_variables: [],
|
||||
},
|
||||
}
|
||||
|
||||
mockSyncWorkflowDraft(draftParams)
|
||||
|
||||
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({
|
||||
url: `/apps/${appId}/workflows/draft`,
|
||||
params: {
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
features: {
|
||||
retriever_resource: { enabled: true },
|
||||
},
|
||||
environment_variables: [],
|
||||
conversation_variables: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Auto-Detection for Empty Canvas', () => {
|
||||
beforeEach(() => {
|
||||
mockGetNodes.mockClear()
|
||||
})
|
||||
|
||||
it('should detect empty canvas and trigger onboarding', () => {
|
||||
// Mock empty canvas
|
||||
mockGetNodes.mockReturnValue([])
|
||||
|
||||
// Mock store with proper state for auto-detection
|
||||
;(useWorkflowStore as Mock).mockReturnValue({
|
||||
showOnboarding: false,
|
||||
hasShownOnboarding: false,
|
||||
notInitialWorkflow: false,
|
||||
setShowOnboarding: mockSetShowOnboarding,
|
||||
setHasShownOnboarding: mockSetHasShownOnboarding,
|
||||
hasSelectedStartNode: false,
|
||||
setHasSelectedStartNode: mockSetHasSelectedStartNode,
|
||||
shouldAutoOpenStartNodeSelector: false,
|
||||
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
|
||||
getState: () => ({
|
||||
showOnboarding: false,
|
||||
hasShownOnboarding: false,
|
||||
notInitialWorkflow: false,
|
||||
setShowOnboarding: mockSetShowOnboarding,
|
||||
setHasShownOnboarding: mockSetHasShownOnboarding,
|
||||
hasSelectedStartNode: false,
|
||||
setHasSelectedStartNode: mockSetHasSelectedStartNode,
|
||||
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
|
||||
}),
|
||||
})
|
||||
|
||||
// Simulate empty canvas check logic
|
||||
const nodes = mockGetNodes()
|
||||
const startNodeTypes = [
|
||||
BlockEnum.Start,
|
||||
BlockEnum.TriggerSchedule,
|
||||
BlockEnum.TriggerWebhook,
|
||||
BlockEnum.TriggerPlugin,
|
||||
]
|
||||
const hasStartNode = nodes.some((node: MockNode) =>
|
||||
startNodeTypes.includes(node.data?.type as BlockEnum),
|
||||
)
|
||||
const isEmpty = nodes.length === 0 || !hasStartNode
|
||||
|
||||
expect(isEmpty).toBe(true)
|
||||
expect(nodes.length).toBe(0)
|
||||
})
|
||||
|
||||
it('should detect canvas with non-start nodes as empty', () => {
|
||||
// Mock canvas with non-start nodes
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: '1', data: { type: BlockEnum.LLM } },
|
||||
{ id: '2', data: { type: BlockEnum.Code } },
|
||||
])
|
||||
|
||||
const nodes = mockGetNodes()
|
||||
const startNodeTypes = [
|
||||
BlockEnum.Start,
|
||||
BlockEnum.TriggerSchedule,
|
||||
BlockEnum.TriggerWebhook,
|
||||
BlockEnum.TriggerPlugin,
|
||||
]
|
||||
const hasStartNode = nodes.some((node: MockNode) =>
|
||||
startNodeTypes.includes(node.data.type as BlockEnum),
|
||||
)
|
||||
const isEmpty = nodes.length === 0 || !hasStartNode
|
||||
|
||||
expect(isEmpty).toBe(true)
|
||||
expect(hasStartNode).toBe(false)
|
||||
})
|
||||
|
||||
it('should not detect canvas with start nodes as empty', () => {
|
||||
// Mock canvas with start node
|
||||
mockGetNodes.mockReturnValue([{ id: '1', data: { type: BlockEnum.Start } }])
|
||||
|
||||
const nodes = mockGetNodes()
|
||||
const startNodeTypes = [
|
||||
BlockEnum.Start,
|
||||
BlockEnum.TriggerSchedule,
|
||||
BlockEnum.TriggerWebhook,
|
||||
BlockEnum.TriggerPlugin,
|
||||
]
|
||||
const hasStartNode = nodes.some((node: MockNode) =>
|
||||
startNodeTypes.includes(node.data.type as BlockEnum),
|
||||
)
|
||||
const isEmpty = nodes.length === 0 || !hasStartNode
|
||||
|
||||
expect(isEmpty).toBe(false)
|
||||
expect(hasStartNode).toBe(true)
|
||||
})
|
||||
|
||||
it('should not trigger onboarding if already shown in session', () => {
|
||||
// Mock empty canvas
|
||||
mockGetNodes.mockReturnValue([])
|
||||
|
||||
// Mock store with hasShownOnboarding = true
|
||||
;(useWorkflowStore as Mock).mockReturnValue({
|
||||
showOnboarding: false,
|
||||
hasShownOnboarding: true, // Already shown in this session
|
||||
notInitialWorkflow: false,
|
||||
setShowOnboarding: mockSetShowOnboarding,
|
||||
setHasShownOnboarding: mockSetHasShownOnboarding,
|
||||
hasSelectedStartNode: false,
|
||||
setHasSelectedStartNode: mockSetHasSelectedStartNode,
|
||||
shouldAutoOpenStartNodeSelector: false,
|
||||
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
|
||||
getState: () => ({
|
||||
showOnboarding: false,
|
||||
hasShownOnboarding: true,
|
||||
notInitialWorkflow: false,
|
||||
setShowOnboarding: mockSetShowOnboarding,
|
||||
setHasShownOnboarding: mockSetHasShownOnboarding,
|
||||
hasSelectedStartNode: false,
|
||||
setHasSelectedStartNode: mockSetHasSelectedStartNode,
|
||||
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
|
||||
}),
|
||||
})
|
||||
|
||||
// Simulate the check logic with hasShownOnboarding = true
|
||||
const store = useWorkflowStore() as unknown as MockWorkflowStore
|
||||
const shouldTrigger =
|
||||
!store.hasShownOnboarding && !store.showOnboarding && !store.notInitialWorkflow
|
||||
|
||||
expect(shouldTrigger).toBe(false)
|
||||
})
|
||||
|
||||
it('should not trigger onboarding during initial workflow creation', () => {
|
||||
// Mock empty canvas
|
||||
mockGetNodes.mockReturnValue([])
|
||||
|
||||
// Mock store with notInitialWorkflow = true (initial creation)
|
||||
;(useWorkflowStore as Mock).mockReturnValue({
|
||||
showOnboarding: false,
|
||||
hasShownOnboarding: false,
|
||||
notInitialWorkflow: true, // Initial workflow creation
|
||||
setShowOnboarding: mockSetShowOnboarding,
|
||||
setHasShownOnboarding: mockSetHasShownOnboarding,
|
||||
hasSelectedStartNode: false,
|
||||
setHasSelectedStartNode: mockSetHasSelectedStartNode,
|
||||
shouldAutoOpenStartNodeSelector: false,
|
||||
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
|
||||
getState: () => ({
|
||||
showOnboarding: false,
|
||||
hasShownOnboarding: false,
|
||||
notInitialWorkflow: true,
|
||||
setShowOnboarding: mockSetShowOnboarding,
|
||||
setHasShownOnboarding: mockSetHasShownOnboarding,
|
||||
hasSelectedStartNode: false,
|
||||
setHasSelectedStartNode: mockSetHasSelectedStartNode,
|
||||
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
|
||||
}),
|
||||
})
|
||||
|
||||
// Simulate the check logic with notInitialWorkflow = true
|
||||
const store = useWorkflowStore() as unknown as MockWorkflowStore
|
||||
const shouldTrigger =
|
||||
!store.hasShownOnboarding && !store.showOnboarding && !store.notInitialWorkflow
|
||||
|
||||
expect(shouldTrigger).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -158,22 +158,6 @@ describe('AppDetailLayout', () => {
|
||||
expect(screen.queryByRole('main')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve the column flex context for full-height workflow content', async () => {
|
||||
render(
|
||||
<AppDetailLayout appId="app-1">
|
||||
<div>App page content</div>
|
||||
</AppDetailLayout>,
|
||||
)
|
||||
|
||||
await waitForAppContent()
|
||||
|
||||
const contentSurface = screen.getByText('App page content').parentElement
|
||||
const appDetailContent = contentSurface?.parentElement
|
||||
const appDetailRoot = appDetailContent?.parentElement
|
||||
|
||||
expect(appDetailRoot).toHaveClass('flex-col')
|
||||
})
|
||||
|
||||
it('should redirect restricted app pages before exposing app detail content', async () => {
|
||||
mockPathname = '/app/app-1/logs'
|
||||
mockFetchAppDetailDirect.mockResolvedValue(
|
||||
|
||||
@ -2,12 +2,7 @@ import { render, screen } from '@testing-library/react'
|
||||
import AccessConfig from '../page'
|
||||
|
||||
vi.mock('@/app/components/app/access-config', () => ({
|
||||
default: ({ appId }: { appId: string }) => (
|
||||
<div data-testid="app-access-config" data-app-id={appId}>
|
||||
app access config
|
||||
{appId}
|
||||
</div>
|
||||
),
|
||||
default: ({ appId }: { appId: string }) => <div>app access config {appId}</div>,
|
||||
}))
|
||||
|
||||
describe('App access config route', () => {
|
||||
@ -20,7 +15,7 @@ describe('App access config route', () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('app-access-config')).toHaveAttribute('data-app-id', 'app-route-id')
|
||||
expect(screen.getByText('app access config app-route-id')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -231,29 +231,5 @@ describe('CardView ACL edit guards', () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should refresh the Zustand app detail after saving webapp settings', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockAppState.appDetail.permission_keys = ['app.acl.edit']
|
||||
|
||||
render(<CardView appId="app-1" />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save webapp/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
|
||||
})
|
||||
expect(mockSetQueryData).toHaveBeenCalledWith(
|
||||
['apps', 'detail', 'app-1'],
|
||||
expect.objectContaining({
|
||||
site: expect.objectContaining({ title: 'Saved site title' }),
|
||||
}),
|
||||
)
|
||||
expect(mockAppState.setAppDetail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
site: expect.objectContaining({ title: 'Saved site title' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,142 +0,0 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { OpikIconBig } from '@/app/components/base/icons/src/public/tracing'
|
||||
import iconData from '@/app/components/base/icons/src/public/tracing/OpikIconBig.json'
|
||||
import { normalizeAttrs } from '@/app/components/base/icons/utils'
|
||||
|
||||
describe('SVG Attribute Error Reproduction', () => {
|
||||
// Capture console errors
|
||||
const originalError = console.error
|
||||
let errorMessages: string[] = []
|
||||
beforeEach(() => {
|
||||
errorMessages = []
|
||||
console.error = vi.fn((message) => {
|
||||
errorMessages.push(message)
|
||||
originalError(message)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
console.error = originalError
|
||||
})
|
||||
|
||||
it('should reproduce inkscape attribute errors when rendering OpikIconBig', () => {
|
||||
console.log('\n=== TESTING OpikIconBig SVG ATTRIBUTE ERRORS ===')
|
||||
|
||||
// Test multiple renders to check for inconsistency
|
||||
for (let i = 0; i < 5; i++) {
|
||||
console.log(`\nRender attempt ${i + 1}:`)
|
||||
|
||||
const { unmount } = render(<OpikIconBig />)
|
||||
|
||||
// Check for specific inkscape attribute errors
|
||||
const inkscapeErrors = errorMessages.filter(
|
||||
(msg) => typeof msg === 'string' && msg.includes('inkscape'),
|
||||
)
|
||||
|
||||
if (inkscapeErrors.length > 0) {
|
||||
console.log(`Found ${inkscapeErrors.length} inkscape errors:`)
|
||||
inkscapeErrors.forEach((error, index) => {
|
||||
console.log(` ${index + 1}. ${error.substring(0, 100)}...`)
|
||||
})
|
||||
} else {
|
||||
console.log('No inkscape errors found in this render')
|
||||
}
|
||||
|
||||
unmount()
|
||||
|
||||
// Clear errors for next iteration
|
||||
errorMessages = []
|
||||
}
|
||||
})
|
||||
|
||||
it('should analyze the SVG structure causing the errors', () => {
|
||||
console.log('\n=== ANALYZING SVG STRUCTURE ===')
|
||||
|
||||
console.log('Icon structure analysis:')
|
||||
console.log('- Root element:', iconData.icon.name)
|
||||
console.log('- Children count:', iconData.icon.children?.length || 0)
|
||||
|
||||
// Find problematic elements
|
||||
const findProblematicElements = (node: any, path = '') => {
|
||||
const problematicElements: any[] = []
|
||||
|
||||
if (node.name && (node.name.includes(':') || node.name.startsWith('sodipodi'))) {
|
||||
problematicElements.push({
|
||||
path,
|
||||
name: node.name,
|
||||
attributes: Object.keys(node.attributes || {}),
|
||||
})
|
||||
}
|
||||
|
||||
// Check attributes for inkscape/sodipodi properties
|
||||
if (node.attributes) {
|
||||
const problematicAttrs = Object.keys(node.attributes).filter(
|
||||
(attr) => attr.startsWith('inkscape:') || attr.startsWith('sodipodi:'),
|
||||
)
|
||||
|
||||
if (problematicAttrs.length > 0) {
|
||||
problematicElements.push({
|
||||
path,
|
||||
name: node.name,
|
||||
problematicAttributes: problematicAttrs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
node.children.forEach((child: any, index: number) => {
|
||||
problematicElements.push(
|
||||
...findProblematicElements(child, `${path}/${node.name}[${index}]`),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return problematicElements
|
||||
}
|
||||
|
||||
const problematicElements = findProblematicElements(iconData.icon, 'root')
|
||||
|
||||
console.log(`\n🚨 Found ${problematicElements.length} problematic elements:`)
|
||||
problematicElements.forEach((element, index) => {
|
||||
console.log(`\n${index + 1}. Element: ${element.name}`)
|
||||
console.log(` Path: ${element.path}`)
|
||||
if (element.problematicAttributes)
|
||||
console.log(` Problematic attributes: ${element.problematicAttributes.join(', ')}`)
|
||||
})
|
||||
})
|
||||
|
||||
it('should test the normalizeAttrs function behavior', () => {
|
||||
console.log('\n=== TESTING normalizeAttrs FUNCTION ===')
|
||||
|
||||
const testAttributes = {
|
||||
'inkscape:showpageshadow': '2',
|
||||
'inkscape:pageopacity': '0.0',
|
||||
'inkscape:pagecheckerboard': '0',
|
||||
'inkscape:deskcolor': '#d1d1d1',
|
||||
'sodipodi:docname': 'opik-icon-big.svg',
|
||||
'xmlns:inkscape': 'https://www.inkscape.org/namespaces/inkscape',
|
||||
'xmlns:sodipodi': 'https://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd',
|
||||
'xmlns:svg': 'https://www.w3.org/2000/svg',
|
||||
'data-name': 'Layer 1',
|
||||
'normal-attr': 'value',
|
||||
class: 'test-class',
|
||||
}
|
||||
|
||||
console.log('Input attributes:', Object.keys(testAttributes))
|
||||
|
||||
const normalized = normalizeAttrs(testAttributes)
|
||||
|
||||
console.log('Normalized attributes:', Object.keys(normalized))
|
||||
console.log('Normalized values:', normalized)
|
||||
|
||||
// Check if problematic attributes are still present
|
||||
const problematicKeys = Object.keys(normalized).filter(
|
||||
(key) => key.toLowerCase().includes('inkscape') || key.toLowerCase().includes('sodipodi'),
|
||||
)
|
||||
|
||||
if (problematicKeys.length > 0)
|
||||
console.log(`🚨 PROBLEM: Still found problematic attributes: ${problematicKeys.join(', ')}`)
|
||||
else console.log('✅ No problematic attributes found after normalization')
|
||||
})
|
||||
})
|
||||
@ -212,115 +212,6 @@ describe('DatasetDetailLayout', () => {
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should apply the dataset surface outside pipeline pages', () => {
|
||||
// Arrange
|
||||
mockUseDatasetDetail.mockReturnValue({
|
||||
data: {
|
||||
id: 'dataset-1',
|
||||
name: 'Dataset 1',
|
||||
provider: 'vendor',
|
||||
runtime_mode: 'rag_pipeline',
|
||||
is_published: true,
|
||||
},
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useDatasetDetail>)
|
||||
|
||||
// Act
|
||||
render(
|
||||
<DatasetDetailLayout datasetId="dataset-1">
|
||||
<div>Documents content</div>
|
||||
</DatasetDetailLayout>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Documents content').parentElement).toHaveClass('rounded-lg')
|
||||
})
|
||||
|
||||
it('should keep pipeline pages unframed', () => {
|
||||
// Arrange
|
||||
mockUsePathname.mockReturnValue('/datasets/dataset-1/pipeline')
|
||||
mockUseDatasetDetail.mockReturnValue({
|
||||
data: {
|
||||
id: 'dataset-1',
|
||||
name: 'Dataset 1',
|
||||
provider: 'vendor',
|
||||
runtime_mode: 'rag_pipeline',
|
||||
is_published: false,
|
||||
},
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useDatasetDetail>)
|
||||
|
||||
// Act
|
||||
render(
|
||||
<DatasetDetailLayout datasetId="dataset-1">
|
||||
<div>Pipeline content</div>
|
||||
</DatasetDetailLayout>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Pipeline content').parentElement).not.toHaveClass('rounded-lg')
|
||||
})
|
||||
|
||||
it('should preserve the column flex context for full-height pipeline content', () => {
|
||||
// Arrange
|
||||
mockUsePathname.mockReturnValue('/datasets/dataset-1/pipeline')
|
||||
mockUseDatasetDetail.mockReturnValue({
|
||||
data: {
|
||||
id: 'dataset-1',
|
||||
name: 'Dataset 1',
|
||||
provider: 'vendor',
|
||||
runtime_mode: 'rag_pipeline',
|
||||
is_published: false,
|
||||
},
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useDatasetDetail>)
|
||||
|
||||
// Act
|
||||
render(
|
||||
<DatasetDetailLayout datasetId="dataset-1">
|
||||
<div>Pipeline content</div>
|
||||
</DatasetDetailLayout>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const contentSurface = screen.getByText('Pipeline content').parentElement
|
||||
const datasetDetailContent = contentSurface?.parentElement
|
||||
const datasetDetailRoot = datasetDetailContent?.parentElement
|
||||
|
||||
expect(datasetDetailRoot).toHaveClass('flex-col')
|
||||
})
|
||||
|
||||
it('should keep create-from-pipeline pages unframed', () => {
|
||||
// Arrange
|
||||
mockUsePathname.mockReturnValue('/datasets/dataset-1/documents/create-from-pipeline')
|
||||
mockUseDatasetDetail.mockReturnValue({
|
||||
data: {
|
||||
id: 'dataset-1',
|
||||
name: 'Dataset 1',
|
||||
provider: 'vendor',
|
||||
runtime_mode: 'rag_pipeline',
|
||||
is_published: false,
|
||||
},
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useDatasetDetail>)
|
||||
|
||||
// Act
|
||||
render(
|
||||
<DatasetDetailLayout datasetId="dataset-1">
|
||||
<div>Create from pipeline content</div>
|
||||
</DatasetDetailLayout>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Create from pipeline content').parentElement).not.toHaveClass(
|
||||
'rounded-lg',
|
||||
)
|
||||
})
|
||||
|
||||
it('should render document creation route content without owning the main skip target', () => {
|
||||
// Arrange
|
||||
mockUsePathname.mockReturnValue('/datasets/dataset-1/documents/create')
|
||||
|
||||
@ -2,12 +2,7 @@ import { render, screen } from '@testing-library/react'
|
||||
import AccessConfig from '../page'
|
||||
|
||||
vi.mock('@/app/components/datasets/access-config', () => ({
|
||||
default: ({ datasetId }: { datasetId: string }) => (
|
||||
<div data-testid="dataset-access-config" data-dataset-id={datasetId}>
|
||||
dataset access config
|
||||
{datasetId}
|
||||
</div>
|
||||
),
|
||||
default: ({ datasetId }: { datasetId: string }) => <div>dataset access config {datasetId}</div>,
|
||||
}))
|
||||
|
||||
describe('Dataset access config route', () => {
|
||||
@ -20,10 +15,7 @@ describe('Dataset access config route', () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('dataset-access-config')).toHaveAttribute(
|
||||
'data-dataset-id',
|
||||
'dataset-route-id',
|
||||
)
|
||||
expect(screen.getByText('dataset access config dataset-route-id')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import InstalledApp from '../page'
|
||||
|
||||
vi.mock('@/app/components/explore/installed-app', () => ({
|
||||
default: ({ id }: { id: string }) => <main aria-label="installed app">{id}</main>,
|
||||
}))
|
||||
|
||||
describe('installed app route', () => {
|
||||
it('should render the installed app page with the route app id', async () => {
|
||||
const page = await InstalledApp({
|
||||
params: Promise.resolve({ appId: 'installed-1' }),
|
||||
})
|
||||
|
||||
render(page)
|
||||
|
||||
expect(screen.getByRole('main', { name: 'installed app' })).toHaveTextContent('installed-1')
|
||||
})
|
||||
})
|
||||
@ -1,28 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MarketplacePage from '../page'
|
||||
|
||||
vi.mock('@/app/components/plugins/marketplace', () => ({
|
||||
default: ({ showInstallButton }: { showInstallButton?: boolean }) => (
|
||||
<div data-show-install={String(!!showInstallButton)}>Marketplace</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/marketplace/marketplace-install-permission-provider', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
describe('MarketplacePage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should keep marketplace card install actions enabled', () => {
|
||||
render(<MarketplacePage />)
|
||||
|
||||
expect(screen.getByText('Marketplace')).toHaveAttribute('data-show-install', 'true')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,19 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import FormPage from '../page'
|
||||
|
||||
vi.mock('../form', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>form-content</div>,
|
||||
}))
|
||||
|
||||
describe('Human input share form page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the form content inside the page shell', () => {
|
||||
render(<FormPage />)
|
||||
|
||||
expect(screen.getByText('form-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -2,7 +2,6 @@ import { screen } from '@testing-library/react'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { AppACLPermission } from '@/utils/permission'
|
||||
import AppDetailSection from '../app-detail-section'
|
||||
import { useAppInfoActions } from '../app-info/use-app-info-actions'
|
||||
|
||||
let mockAppMode = 'chat'
|
||||
let mockPathname = '/app/app-1/logs'
|
||||
@ -69,9 +68,7 @@ vi.mock('@/next/navigation', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../app-info', () => ({
|
||||
AppInfoView: ({ expand }: { expand: boolean }) => (
|
||||
<div data-testid="app-info" data-expand={expand} />
|
||||
),
|
||||
AppInfoView: () => <div />,
|
||||
}))
|
||||
|
||||
vi.mock('../app-info/use-app-info-actions', () => ({
|
||||
@ -83,21 +80,7 @@ vi.mock('../../base/divider', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../nav-link', () => ({
|
||||
default: ({
|
||||
name,
|
||||
href,
|
||||
mode,
|
||||
iconMap,
|
||||
}: {
|
||||
name: string
|
||||
href: string
|
||||
mode: string
|
||||
iconMap: { normal: { displayName?: string } }
|
||||
}) => (
|
||||
<a href={href} data-mode={mode} data-icon={iconMap.normal.displayName}>
|
||||
{name}
|
||||
</a>
|
||||
),
|
||||
default: ({ name, href }: { name: string; href: string }) => <a href={href}>{name}</a>,
|
||||
}))
|
||||
|
||||
describe('AppDetailSection', () => {
|
||||
@ -127,7 +110,6 @@ describe('AppDetailSection', () => {
|
||||
expect(
|
||||
screen.queryByRole('link', { name: 'common.appMenus.annotations' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('separator')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should render logs and annotations for chat apps with app log and annotation permission', () => {
|
||||
@ -147,27 +129,11 @@ describe('AppDetailSection', () => {
|
||||
'href',
|
||||
'/app/app-1/annotations',
|
||||
)
|
||||
expect(screen.getByRole('link', { name: 'common.appMenus.annotations' })).toHaveAttribute(
|
||||
'data-icon',
|
||||
'Annotations',
|
||||
)
|
||||
expect(
|
||||
screen.queryByRole('link', { name: 'common.appMenus.overview' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dividers before logs and after annotations for chat apps', () => {
|
||||
// Arrange
|
||||
mockAppMode = 'chat'
|
||||
mockAppPermissionKeys = [AppACLPermission.LogAndAnnotation]
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getAllByRole('separator')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should only render logs navigation for workflow apps', () => {
|
||||
// Arrange
|
||||
mockAppMode = 'workflow'
|
||||
@ -186,18 +152,6 @@ describe('AppDetailSection', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dividers before and after logs for workflow apps', () => {
|
||||
// Arrange
|
||||
mockAppMode = 'workflow'
|
||||
mockAppPermissionKeys = [AppACLPermission.LogAndAnnotation]
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getAllByRole('separator')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should only render logs navigation for completion apps', () => {
|
||||
// Arrange
|
||||
mockAppMode = 'completion'
|
||||
@ -216,44 +170,6 @@ describe('AppDetailSection', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render log and annotation group dividers without log and annotation permission', () => {
|
||||
// Arrange
|
||||
mockAppPermissionKeys = [AppACLPermission.Monitor]
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryAllByRole('separator')).toHaveLength(0)
|
||||
expect(screen.queryByRole('link', { name: 'common.appMenus.logs' })).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('link', { name: 'common.appMenus.annotations' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'common.appMenus.overview' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render logs for users with app log and annotation permission', () => {
|
||||
// Arrange
|
||||
mockAppPermissionKeys = [AppACLPermission.LogAndAnnotation]
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/app-1/logs',
|
||||
)
|
||||
expect(screen.getByRole('link', { name: 'common.appMenus.annotations' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/app-1/annotations',
|
||||
)
|
||||
expect(
|
||||
screen.queryByRole('link', { name: 'common.appMenus.overview' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getAllByRole('separator')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should render the layout navigation for users with view layout permission', () => {
|
||||
// Arrange
|
||||
mockAppPermissionKeys = [AppACLPermission.ViewLayout]
|
||||
@ -325,33 +241,5 @@ describe('AppDetailSection', () => {
|
||||
screen.queryByRole('link', { name: 'common.settings.resourceAccess' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass collapsed mode to app info and navigation links when collapsed', () => {
|
||||
// Arrange
|
||||
mockAppPermissionKeys = [AppACLPermission.LogAndAnnotation]
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection expand={false} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('app-info')).toHaveAttribute('data-expand', 'false')
|
||||
expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute(
|
||||
'data-mode',
|
||||
'collapse',
|
||||
)
|
||||
})
|
||||
|
||||
it('should scope app info state to the app instead of the current path', () => {
|
||||
// Arrange
|
||||
const { rerender } = render(<AppDetailSection />)
|
||||
|
||||
// Act
|
||||
mockPathname = '/app/app-1/overview'
|
||||
rerender(<AppDetailSection />)
|
||||
|
||||
// Assert
|
||||
expect(useAppInfoActions).toHaveBeenCalledWith({ resetKey: 'app-1' })
|
||||
expect(useAppInfoActions).toHaveBeenLastCalledWith({ resetKey: 'app-1' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -100,11 +100,5 @@ describe('AppBasic', () => {
|
||||
render(<AppBasic name="My App" type="Chatbot" isExtraInLine />)
|
||||
expect(screen.getByText('Chatbot')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom text styles', () => {
|
||||
render(<AppBasic name="My App" type="Chatbot" textStyle={{ main: 'text-red-500' }} />)
|
||||
const nameContainer = screen.getByText('My App').parentElement
|
||||
expect(nameContainer).toHaveClass('text-red-500')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -59,9 +59,7 @@ vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../dataset-info', () => ({
|
||||
default: ({ expand }: { expand: boolean }) => (
|
||||
<div data-testid="dataset-info" data-expand={expand} />
|
||||
),
|
||||
default: () => <div>dataset info</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../nav-link', () => ({
|
||||
@ -73,9 +71,7 @@ vi.mock('../nav-link', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../datasets/extra-info', () => ({
|
||||
default: ({ expand, documentCount }: { expand: boolean; documentCount?: number }) => (
|
||||
<div data-testid="extra-info" data-expand={expand} data-document-count={documentCount} />
|
||||
),
|
||||
default: () => <div>dataset extra info</div>,
|
||||
}))
|
||||
|
||||
const createDataset = (overrides: Partial<DataSet> = {}): DataSet =>
|
||||
@ -122,16 +118,6 @@ describe('DatasetDetailSection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('should pin dataset stats and API access to the bottom of the expanded sidebar', () => {
|
||||
render(<DatasetDetailSection expand />)
|
||||
|
||||
const extraInfo = screen.getByTestId('extra-info')
|
||||
|
||||
expect(extraInfo).toHaveAttribute('data-expand', 'true')
|
||||
expect(extraInfo).toHaveAttribute('data-document-count', '120')
|
||||
expect(extraInfo.parentElement).toHaveClass('mt-auto', 'shrink-0')
|
||||
})
|
||||
|
||||
it('should hide dataset stats and API access when dataset edit permission is missing', () => {
|
||||
mockDataset = createDataset({
|
||||
permission_keys: [DatasetACLPermission.Readonly],
|
||||
@ -139,7 +125,7 @@ describe('DatasetDetailSection', () => {
|
||||
|
||||
render(<DatasetDetailSection expand />)
|
||||
|
||||
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('dataset extra info')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render resource access navigation when dataset access config permission is granted', () => {
|
||||
|
||||
@ -1,265 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
|
||||
// Simple Mock Components that reproduce the exact UI issues
|
||||
const MockNavLink = ({ name, mode }: { name: string; mode: string }) => {
|
||||
return (
|
||||
<a
|
||||
className={`group flex h-9 items-center rounded-md py-2 text-sm font-normal ${mode === 'expand' ? 'px-3' : 'px-2.5'} `}
|
||||
data-testid={`nav-link-${name}`}
|
||||
data-mode={mode}
|
||||
>
|
||||
{/* Icon with inconsistent margin - reproduces issue #2 */}
|
||||
<svg
|
||||
className={`h-4 w-4 shrink-0 ${mode === 'expand' ? 'mr-2' : 'mr-0'}`}
|
||||
data-testid={`nav-icon-${name}`}
|
||||
/>
|
||||
{/* Text that appears/disappears abruptly - reproduces issue #2 */}
|
||||
{mode === 'expand' && <span data-testid={`nav-text-${name}`}>{name}</span>}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const MockSidebarToggleButton = ({
|
||||
expand,
|
||||
onToggle,
|
||||
}: {
|
||||
expand: boolean
|
||||
onToggle: () => void
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all ${expand ? 'w-[216px]' : 'w-14'} `}
|
||||
data-testid="sidebar-container"
|
||||
>
|
||||
{/* Top section with variable padding - reproduces issue #1 */}
|
||||
<div className={`shrink-0 ${expand ? 'p-2' : 'p-1'}`} data-testid="top-section">
|
||||
App Info Area
|
||||
</div>
|
||||
|
||||
{/* Navigation section - reproduces issue #2 */}
|
||||
<nav className={`grow space-y-1 ${expand ? 'p-4' : 'px-2.5 py-4'}`} data-testid="navigation">
|
||||
<MockNavLink name="Orchestrate" mode={expand ? 'expand' : 'collapse'} />
|
||||
<MockNavLink name="API Access" mode={expand ? 'expand' : 'collapse'} />
|
||||
<MockNavLink name="Logs & Annotations" mode={expand ? 'expand' : 'collapse'} />
|
||||
<MockNavLink name="Monitoring" mode={expand ? 'expand' : 'collapse'} />
|
||||
</nav>
|
||||
|
||||
{/* Toggle button section with consistent padding - issue #1 FIXED */}
|
||||
<div className="shrink-0 px-4 py-3" data-testid="toggle-section">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 cursor-pointer items-center justify-center"
|
||||
onClick={onToggle}
|
||||
data-testid="toggle-button"
|
||||
>
|
||||
{expand ? '→' : '←'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MockAppInfo = ({ expand }: { expand: boolean }) => {
|
||||
return (
|
||||
<div data-testid="app-info" data-expand={expand}>
|
||||
<button type="button" className="block w-full">
|
||||
{/* Container with layout mode switching - reproduces issue #3 */}
|
||||
<div
|
||||
className={`flex rounded-lg ${expand ? 'flex-col gap-2 p-2 pb-2.5' : 'items-start justify-center gap-1 p-1'}`}
|
||||
>
|
||||
{/* Icon container with justify-between to flex-col switch - reproduces issue #3 */}
|
||||
<div
|
||||
className={`flex items-center self-stretch ${expand ? 'justify-between' : 'flex-col gap-1'}`}
|
||||
data-testid="icon-container"
|
||||
>
|
||||
{/* Icon with size changes - reproduces issue #3 */}
|
||||
<div
|
||||
data-testid="app-icon"
|
||||
data-size={expand ? 'large' : 'small'}
|
||||
style={{
|
||||
width: expand ? '40px' : '24px',
|
||||
height: expand ? '40px' : '24px',
|
||||
backgroundColor: '#000',
|
||||
transition: 'all 0.3s ease', // This broad transition causes bounce
|
||||
}}
|
||||
>
|
||||
Icon
|
||||
</div>
|
||||
<div className="flex items-center justify-center rounded-md p-0.5">
|
||||
<div className="flex h-5 w-5 items-center justify-center">⚙️</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Text that appears/disappears conditionally */}
|
||||
{expand && (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="flex w-full">
|
||||
<div className="truncate system-md-semibold text-text-secondary">Test App</div>
|
||||
</div>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">chatflow</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Sidebar Animation Issues Reproduction', () => {
|
||||
beforeEach(() => {
|
||||
// Mock getBoundingClientRect for position testing
|
||||
Element.prototype.getBoundingClientRect = vi.fn(() => ({
|
||||
width: 200,
|
||||
height: 40,
|
||||
x: 10,
|
||||
y: 10,
|
||||
left: 10,
|
||||
right: 210,
|
||||
top: 10,
|
||||
bottom: 50,
|
||||
toJSON: vi.fn(),
|
||||
}))
|
||||
})
|
||||
|
||||
describe('Issue #1: Toggle Button Position Movement - FIXED', () => {
|
||||
it('should verify consistent padding prevents button position shift', () => {
|
||||
let expanded = false
|
||||
const handleToggle = () => {
|
||||
expanded = !expanded
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<MockSidebarToggleButton expand={false} onToggle={handleToggle} />,
|
||||
)
|
||||
|
||||
// Check collapsed state padding
|
||||
const toggleSection = screen.getByTestId('toggle-section')
|
||||
expect(toggleSection).toHaveClass('px-4') // Consistent padding
|
||||
expect(toggleSection).not.toHaveClass('px-5')
|
||||
expect(toggleSection).not.toHaveClass('px-6')
|
||||
|
||||
// Switch to expanded state
|
||||
rerender(<MockSidebarToggleButton expand={true} onToggle={handleToggle} />)
|
||||
|
||||
// Check expanded state padding - should be the same
|
||||
expect(toggleSection).toHaveClass('px-4') // Same consistent padding
|
||||
expect(toggleSection).not.toHaveClass('px-5')
|
||||
expect(toggleSection).not.toHaveClass('px-6')
|
||||
})
|
||||
|
||||
it('should verify sidebar width animation is working correctly', () => {
|
||||
const handleToggle = vi.fn()
|
||||
const { rerender } = render(
|
||||
<MockSidebarToggleButton expand={false} onToggle={handleToggle} />,
|
||||
)
|
||||
|
||||
const container = screen.getByTestId('sidebar-container')
|
||||
|
||||
// Collapsed state
|
||||
expect(container).toHaveClass('w-14')
|
||||
expect(container).toHaveClass('transition-all')
|
||||
|
||||
// Expanded state
|
||||
rerender(<MockSidebarToggleButton expand={true} onToggle={handleToggle} />)
|
||||
expect(container).toHaveClass('w-[216px]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Issue #2: Navigation Text Squeeze Animation', () => {
|
||||
it('should reproduce text squeeze effect from padding and margin changes', () => {
|
||||
const { rerender } = render(<MockNavLink name="Orchestrate" mode="collapse" />)
|
||||
|
||||
const link = screen.getByTestId('nav-link-Orchestrate')
|
||||
const icon = screen.getByTestId('nav-icon-Orchestrate')
|
||||
|
||||
// Collapsed state checks
|
||||
expect(link).toHaveClass('px-2.5') // 10px padding
|
||||
expect(icon).toHaveClass('mr-0') // No margin
|
||||
expect(screen.queryByTestId('nav-text-Orchestrate')).not.toBeInTheDocument()
|
||||
|
||||
// Switch to expanded state
|
||||
rerender(<MockNavLink name="Orchestrate" mode="expand" />)
|
||||
|
||||
// Expanded state checks
|
||||
expect(link).toHaveClass('px-3') // 12px padding (+2px)
|
||||
expect(icon).toHaveClass('mr-2') // 8px margin (+8px)
|
||||
expect(screen.getByTestId('nav-text-Orchestrate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should document the abrupt text rendering issue', () => {
|
||||
const { rerender } = render(<MockNavLink name="API Access" mode="collapse" />)
|
||||
|
||||
// Text completely absent
|
||||
expect(screen.queryByTestId('nav-text-API Access')).not.toBeInTheDocument()
|
||||
|
||||
rerender(<MockNavLink name="API Access" mode="expand" />)
|
||||
|
||||
// Text suddenly appears - no transition
|
||||
expect(screen.getByTestId('nav-text-API Access')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Issue #3: App Icon Bounce Animation', () => {
|
||||
it('should reproduce icon bounce from layout mode switching', () => {
|
||||
const { rerender } = render(<MockAppInfo expand={true} />)
|
||||
|
||||
const iconContainer = screen.getByTestId('icon-container')
|
||||
const appIcon = screen.getByTestId('app-icon')
|
||||
|
||||
// Expanded state layout
|
||||
expect(iconContainer).toHaveClass('justify-between')
|
||||
expect(iconContainer).not.toHaveClass('flex-col')
|
||||
expect(appIcon).toHaveAttribute('data-size', 'large')
|
||||
|
||||
// Switch to collapsed state
|
||||
rerender(<MockAppInfo expand={false} />)
|
||||
|
||||
// Collapsed state layout - completely different layout mode
|
||||
expect(iconContainer).toHaveClass('flex-col')
|
||||
expect(iconContainer).toHaveClass('gap-1')
|
||||
expect(iconContainer).not.toHaveClass('justify-between')
|
||||
expect(appIcon).toHaveAttribute('data-size', 'small')
|
||||
})
|
||||
|
||||
it('should identify the problematic transition-all property', () => {
|
||||
render(<MockAppInfo expand={true} />)
|
||||
|
||||
const appIcon = screen.getByTestId('app-icon')
|
||||
const computedStyle = window.getComputedStyle(appIcon)
|
||||
|
||||
// The problematic broad transition
|
||||
expect(computedStyle.transition).toContain('all')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactive Toggle Test', () => {
|
||||
it('should demonstrate all issues in a single interactive test', () => {
|
||||
let expanded = false
|
||||
const handleToggle = () => {
|
||||
expanded = !expanded
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<div data-testid="complete-sidebar">
|
||||
<MockSidebarToggleButton expand={expanded} onToggle={handleToggle} />
|
||||
<MockAppInfo expand={expanded} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
const toggleButton = screen.getByTestId('toggle-button')
|
||||
|
||||
// Initial state verification
|
||||
expect(expanded).toBe(false)
|
||||
|
||||
// Simulate toggle click
|
||||
fireEvent.click(toggleButton)
|
||||
expanded = true
|
||||
rerender(
|
||||
<div data-testid="complete-sidebar">
|
||||
<MockSidebarToggleButton expand={expanded} onToggle={handleToggle} />
|
||||
<MockAppInfo expand={expanded} />
|
||||
</div>,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,171 +0,0 @@
|
||||
/**
|
||||
* Text Squeeze Fix Verification Test
|
||||
* This test verifies that the CSS-based text rendering fixes work correctly
|
||||
*/
|
||||
|
||||
import { render } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
|
||||
// Mock Next.js navigation
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useSelectedLayoutSegment: () => 'overview',
|
||||
}))
|
||||
|
||||
// Mock classnames utility
|
||||
vi.mock('@langgenius/dify-ui/cn', () => ({
|
||||
default: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
// Simplified NavLink component to test the fix
|
||||
const TestNavLink = ({ mode }: { mode: 'expand' | 'collapse' }) => {
|
||||
const name = 'Orchestrate'
|
||||
|
||||
return (
|
||||
<div className="nav-link-container">
|
||||
<div
|
||||
className={`flex h-9 items-center rounded-md py-2 text-sm font-normal ${
|
||||
mode === 'expand' ? 'px-3' : 'px-2.5'
|
||||
}`}
|
||||
>
|
||||
<div className={`h-4 w-4 shrink-0 ${mode === 'expand' ? 'mr-2' : 'mr-0'}`}>Icon</div>
|
||||
<span
|
||||
className={`whitespace-nowrap transition-all duration-200 ease-in-out ${
|
||||
mode === 'expand'
|
||||
? 'w-auto opacity-100'
|
||||
: 'pointer-events-none w-0 overflow-hidden opacity-0'
|
||||
}`}
|
||||
data-testid="nav-text"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Simplified AppInfo component to test the fix
|
||||
const TestAppInfo = ({ expand }: { expand: boolean }) => {
|
||||
const appDetail = {
|
||||
name: 'Test ChatBot App',
|
||||
mode: 'chat' as const,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-info-container">
|
||||
<div
|
||||
className={`flex rounded-lg ${expand ? 'flex-col gap-2 p-2 pb-2.5' : 'items-start justify-center gap-1 p-1'}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center self-stretch ${expand ? 'justify-between' : 'flex-col gap-1'}`}
|
||||
>
|
||||
<div className="app-icon">AppIcon</div>
|
||||
<div className="dashboard-icon">Dashboard</div>
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col items-start gap-1 transition-all duration-200 ease-in-out ${
|
||||
expand ? 'w-auto opacity-100' : 'pointer-events-none w-0 overflow-hidden opacity-0'
|
||||
}`}
|
||||
data-testid="app-text-container"
|
||||
>
|
||||
<div className="flex w-full">
|
||||
<div
|
||||
className="truncate system-md-semibold whitespace-nowrap text-text-secondary"
|
||||
data-testid="app-name"
|
||||
>
|
||||
{appDetail.name}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary"
|
||||
data-testid="app-type"
|
||||
>
|
||||
ChatBot
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Text Squeeze Fix Verification', () => {
|
||||
describe('NavLink Text Rendering Fix', () => {
|
||||
it('should keep text in DOM and use CSS transitions', () => {
|
||||
const { container, rerender } = render(<TestNavLink mode="collapse" />)
|
||||
|
||||
// In collapsed state, text should be in DOM but hidden
|
||||
const textElement = container.querySelector('[data-testid="nav-text"]')
|
||||
expect(textElement).toBeInTheDocument()
|
||||
expect(textElement).toHaveClass('opacity-0')
|
||||
expect(textElement).toHaveClass('w-0')
|
||||
expect(textElement).toHaveClass('overflow-hidden')
|
||||
expect(textElement).toHaveClass('pointer-events-none')
|
||||
expect(textElement).toHaveClass('whitespace-nowrap')
|
||||
expect(textElement).toHaveClass('transition-all')
|
||||
|
||||
// Switch to expanded state
|
||||
rerender(<TestNavLink mode="expand" />)
|
||||
|
||||
const expandedText = container.querySelector('[data-testid="nav-text"]')
|
||||
expect(expandedText).toBeInTheDocument()
|
||||
expect(expandedText).toHaveClass('opacity-100')
|
||||
expect(expandedText).toHaveClass('w-auto')
|
||||
expect(expandedText).not.toHaveClass('pointer-events-none')
|
||||
})
|
||||
|
||||
it('should verify smooth transition properties', () => {
|
||||
const { container } = render(<TestNavLink mode="collapse" />)
|
||||
|
||||
const textElement = container.querySelector('[data-testid="nav-text"]')
|
||||
expect(textElement).toHaveClass('transition-all')
|
||||
expect(textElement).toHaveClass('duration-200')
|
||||
expect(textElement).toHaveClass('ease-in-out')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppInfo Text Rendering Fix', () => {
|
||||
it('should keep app text in DOM and use CSS transitions', () => {
|
||||
const { container, rerender } = render(<TestAppInfo expand={false} />)
|
||||
|
||||
// In collapsed state, text container should be in DOM but hidden
|
||||
const textContainer = container.querySelector('[data-testid="app-text-container"]')
|
||||
expect(textContainer).toBeInTheDocument()
|
||||
expect(textContainer).toHaveClass('opacity-0')
|
||||
expect(textContainer).toHaveClass('w-0')
|
||||
expect(textContainer).toHaveClass('overflow-hidden')
|
||||
expect(textContainer).toHaveClass('pointer-events-none')
|
||||
|
||||
// Text elements should still be in DOM
|
||||
const appName = container.querySelector('[data-testid="app-name"]')
|
||||
const appType = container.querySelector('[data-testid="app-type"]')
|
||||
expect(appName).toBeInTheDocument()
|
||||
expect(appType).toBeInTheDocument()
|
||||
expect(appName).toHaveClass('whitespace-nowrap')
|
||||
expect(appType).toHaveClass('whitespace-nowrap')
|
||||
|
||||
// Switch to expanded state
|
||||
rerender(<TestAppInfo expand={true} />)
|
||||
|
||||
const expandedContainer = container.querySelector('[data-testid="app-text-container"]')
|
||||
expect(expandedContainer).toBeInTheDocument()
|
||||
expect(expandedContainer).toHaveClass('opacity-100')
|
||||
expect(expandedContainer).toHaveClass('w-auto')
|
||||
expect(expandedContainer).not.toHaveClass('pointer-events-none')
|
||||
})
|
||||
|
||||
it('should verify transition properties on text container', () => {
|
||||
const { container } = render(<TestAppInfo expand={false} />)
|
||||
|
||||
const textContainer = container.querySelector('[data-testid="app-text-container"]')
|
||||
expect(textContainer).toHaveClass('transition-all')
|
||||
expect(textContainer).toHaveClass('duration-200')
|
||||
expect(textContainer).toHaveClass('ease-in-out')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Fix Strategy Comparison', () => {
|
||||
it('should document the fix strategy differences', () => {
|
||||
// Always pass documentation test
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -109,10 +109,4 @@ describe('AppInfoTrigger', () => {
|
||||
const iconWrapper = screen.getByTestId('app-icon').parentElement
|
||||
expect(iconWrapper?.parentElement).toHaveClass('items-center')
|
||||
})
|
||||
|
||||
it('should not apply ml-1 class when expanded', () => {
|
||||
render(<AppInfoTrigger appDetail={createAppDetail()} expand onClick={vi.fn()} />)
|
||||
const iconWrapper = screen.getByTestId('app-icon').parentElement
|
||||
expect(iconWrapper).not.toHaveClass('ml-1')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,342 +0,0 @@
|
||||
import type { Operation } from '../app-operations'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import AppOperations from '../app-operations'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
className,
|
||||
size,
|
||||
variant,
|
||||
id,
|
||||
tabIndex,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
size?: string
|
||||
variant?: string
|
||||
id?: string
|
||||
tabIndex?: number
|
||||
'data-targetid'?: string
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={className}
|
||||
data-size={size}
|
||||
data-variant={variant}
|
||||
id={id}
|
||||
tabIndex={tabIndex}
|
||||
data-targetid={rest['data-targetid']}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/dropdown-menu', () => {
|
||||
const DropdownMenuContext = React.createContext<{
|
||||
isOpen: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
} | null>(null)
|
||||
|
||||
const useDropdownMenuContext = () => {
|
||||
const context = React.use(DropdownMenuContext)
|
||||
if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
|
||||
return context
|
||||
}
|
||||
|
||||
return {
|
||||
DropdownMenu: ({
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
open: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => (
|
||||
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
|
||||
<div data-testid="dropdown-menu" data-open={open}>
|
||||
{children}
|
||||
</div>
|
||||
</DropdownMenuContext>
|
||||
),
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
render,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: React.MouseEventHandler<HTMLElement>
|
||||
render?: React.ReactElement
|
||||
}) => {
|
||||
const { isOpen, setOpen } = useDropdownMenuContext()
|
||||
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
|
||||
onClick?.(e)
|
||||
setOpen(!isOpen)
|
||||
}
|
||||
|
||||
if (render)
|
||||
return React.cloneElement(
|
||||
render,
|
||||
{ 'data-testid': 'dropdown-trigger', onClick: handleClick } as Record<string, unknown>,
|
||||
children,
|
||||
)
|
||||
|
||||
return (
|
||||
<button data-testid="dropdown-trigger" onClick={handleClick}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
DropdownMenuContent: ({
|
||||
children,
|
||||
popupClassName,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
popupClassName?: string
|
||||
}) => {
|
||||
const { isOpen } = useDropdownMenuContext()
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div data-testid="dropdown-content" className={popupClassName}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>
|
||||
}) => {
|
||||
const { setOpen } = useDropdownMenuContext()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dropdown-item"
|
||||
onClick={(e) => {
|
||||
onClick?.(e)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
DropdownMenuSeparator: () => <hr data-testid="dropdown-separator" />,
|
||||
}
|
||||
})
|
||||
|
||||
const createOperation = (id: string, title: string, type?: 'divider'): Operation => ({
|
||||
id,
|
||||
title,
|
||||
icon: <svg data-testid={`icon-${id}`} />,
|
||||
onClick: vi.fn(),
|
||||
type,
|
||||
})
|
||||
|
||||
function setupDomMeasurements(navWidth: number, moreWidth: number, childWidths: number[]) {
|
||||
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
if (this.getAttribute('aria-hidden') === 'true') return navWidth
|
||||
if (this.id === 'more-measure') return moreWidth
|
||||
if (this.dataset.targetid) {
|
||||
const idx = Array.from(this.parentElement?.children ?? []).indexOf(this)
|
||||
return childWidths[idx] ?? 50
|
||||
}
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (originalClientWidth)
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth)
|
||||
}
|
||||
}
|
||||
|
||||
describe('AppOperations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering with operations prop', () => {
|
||||
it('should render measurement container', () => {
|
||||
const ops = [createOperation('edit', 'Edit'), createOperation('copy', 'Copy')]
|
||||
const { container } = render(<AppOperations gap={4} operations={ops} />)
|
||||
expect(container.querySelector('[aria-hidden="true"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render operation buttons in measurement container', () => {
|
||||
const ops = [createOperation('edit', 'Edit'), createOperation('copy', 'Copy')]
|
||||
render(<AppOperations gap={4} operations={ops} />)
|
||||
const editButtons = screen.getAllByText('Edit')
|
||||
expect(editButtons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should use operations as primary when provided', () => {
|
||||
const ops = [createOperation('edit', 'Edit')]
|
||||
const secondary = [createOperation('delete', 'Delete')]
|
||||
render(<AppOperations gap={4} operations={ops} secondaryOperations={secondary} />)
|
||||
const editButtons = screen.getAllByText('Edit')
|
||||
expect(editButtons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering with primaryOperations and secondaryOperations', () => {
|
||||
it('should render primary operations in measurement container', () => {
|
||||
const primary = [createOperation('edit', 'Edit')]
|
||||
render(<AppOperations gap={4} primaryOperations={primary} />)
|
||||
const editButtons = screen.getAllByText('Edit')
|
||||
expect(editButtons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should use secondary operations when provided', () => {
|
||||
const primary = [createOperation('edit', 'Edit')]
|
||||
const secondary = [createOperation('delete', 'Delete')]
|
||||
render(<AppOperations gap={4} primaryOperations={primary} secondaryOperations={secondary} />)
|
||||
const editButtons = screen.getAllByText('Edit')
|
||||
expect(editButtons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should use empty operations array when neither operations nor primaryOperations provided', () => {
|
||||
const { container } = render(<AppOperations gap={4} />)
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Overflow behavior', () => {
|
||||
it('should show all operations when container is wide enough', () => {
|
||||
const cleanup = setupDomMeasurements(500, 60, [80, 80])
|
||||
const ops = [createOperation('edit', 'Edit'), createOperation('copy', 'Copy')]
|
||||
|
||||
render(<AppOperations gap={4} operations={ops} />)
|
||||
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('should move operations to more menu when container is narrow', () => {
|
||||
const cleanup = setupDomMeasurements(100, 60, [80, 80])
|
||||
const ops = [createOperation('edit', 'Edit'), createOperation('copy', 'Copy')]
|
||||
|
||||
render(<AppOperations gap={4} operations={ops} />)
|
||||
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('should show last item without more button if it fits alone', () => {
|
||||
const cleanup = setupDomMeasurements(90, 60, [80])
|
||||
const ops = [createOperation('edit', 'Edit')]
|
||||
|
||||
render(<AppOperations gap={4} operations={ops} />)
|
||||
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
describe('More button', () => {
|
||||
it('should render more button text in measurement container', () => {
|
||||
const ops = [createOperation('edit', 'Edit')]
|
||||
render(<AppOperations gap={4} operations={ops} />)
|
||||
const moreButtons = screen.getAllByText('common.operation.more')
|
||||
expect(moreButtons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should handle trigger more click', async () => {
|
||||
const cleanup = setupDomMeasurements(100, 60, [80, 80])
|
||||
const user = userEvent.setup()
|
||||
const ops = [createOperation('edit', 'Edit'), createOperation('copy', 'Copy')]
|
||||
const secondary = [createOperation('delete', 'Delete')]
|
||||
|
||||
render(<AppOperations gap={4} primaryOperations={ops} secondaryOperations={secondary} />)
|
||||
|
||||
const trigger = screen.queryByTestId('dropdown-trigger')
|
||||
if (trigger) await user.click(trigger)
|
||||
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Visible operations click', () => {
|
||||
it('should call onClick when a visible operation is clicked', async () => {
|
||||
const cleanup = setupDomMeasurements(500, 60, [80, 80])
|
||||
const user = userEvent.setup()
|
||||
const editOp = createOperation('edit', 'Edit')
|
||||
const copyOp = createOperation('copy', 'Copy')
|
||||
|
||||
render(<AppOperations gap={4} operations={[editOp, copyOp]} />)
|
||||
|
||||
const visibleButtons = screen.getAllByText('Edit')
|
||||
const clickableButton = visibleButtons.find((btn) => btn.closest('button')?.tabIndex !== -1)
|
||||
if (clickableButton) await user.click(clickableButton)
|
||||
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Divider operations', () => {
|
||||
it('should filter out divider operations from inline display', () => {
|
||||
const ops = [
|
||||
createOperation('edit', 'Edit'),
|
||||
createOperation('div-1', '', 'divider'),
|
||||
createOperation('delete', 'Delete'),
|
||||
]
|
||||
render(<AppOperations gap={4} operations={ops} />)
|
||||
const editButtons = screen.getAllByText('Edit')
|
||||
expect(editButtons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Gap styling', () => {
|
||||
it('should apply gap to measurement and visible containers', () => {
|
||||
const ops = [createOperation('edit', 'Edit')]
|
||||
const { container } = render(<AppOperations gap={8} operations={ops} />)
|
||||
const hiddenContainer = container.querySelector('[aria-hidden="true"]')
|
||||
expect(hiddenContainer).toHaveStyle({ gap: '8px' })
|
||||
})
|
||||
|
||||
it('should apply gap to visible container', () => {
|
||||
const ops = [createOperation('edit', 'Edit')]
|
||||
const { container } = render(<AppOperations gap={4} operations={ops} />)
|
||||
const containers = container.querySelectorAll('div[style]')
|
||||
const visibleContainer = Array.from(containers).find(
|
||||
(el) => el.getAttribute('aria-hidden') !== 'true',
|
||||
)
|
||||
if (visibleContainer) expect(visibleContainer).toHaveStyle({ gap: '4px' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('More menu content', () => {
|
||||
it('should render divider items in more menu', () => {
|
||||
const cleanup = setupDomMeasurements(100, 60, [80, 80])
|
||||
const primary = [createOperation('edit', 'Edit'), createOperation('copy', 'Copy')]
|
||||
const secondary = [
|
||||
createOperation('divider-1', '', 'divider'),
|
||||
createOperation('delete', 'Delete'),
|
||||
]
|
||||
|
||||
render(<AppOperations gap={4} primaryOperations={primary} secondaryOperations={secondary} />)
|
||||
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty inline operations', () => {
|
||||
it('should handle when all operations are dividers', () => {
|
||||
const ops = [createOperation('div-1', '', 'divider'), createOperation('div-2', '', 'divider')]
|
||||
const { container } = render(<AppOperations gap={4} operations={ops} />)
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -280,7 +280,7 @@ describe('MenuItem', () => {
|
||||
expect(parentClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not crash when no click handler is provided', () => {
|
||||
it('should prevent the default action when no click handler is provided', () => {
|
||||
render(<MenuItem name="Edit" Icon={TestEditIcon} />)
|
||||
|
||||
const event = createEvent.click(screen.getByText('Edit'))
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { NavLinkProps } from '..'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import NavLink from '..'
|
||||
|
||||
@ -29,255 +30,48 @@ vi.mock('@/next/link', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock RemixIcon components
|
||||
const MockIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} data-testid="nav-icon" />
|
||||
const SelectedIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} data-testid="selected-nav-icon" />
|
||||
)
|
||||
|
||||
describe('NavLink Animation and Layout Issues', () => {
|
||||
const NormalIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} data-testid="normal-nav-icon" />
|
||||
)
|
||||
|
||||
describe('NavLink', () => {
|
||||
const mockProps: NavLinkProps = {
|
||||
name: 'Orchestrate',
|
||||
href: '/app/123/workflow',
|
||||
iconMap: {
|
||||
selected: MockIcon,
|
||||
normal: MockIcon,
|
||||
selected: SelectedIcon,
|
||||
normal: NormalIcon,
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock getComputedStyle for transition testing
|
||||
Object.defineProperty(window, 'getComputedStyle', {
|
||||
value: vi.fn((element) => {
|
||||
const isExpanded = element.getAttribute('data-mode') === 'expand'
|
||||
const style = {
|
||||
transition: 'all 0.3s ease',
|
||||
opacity: isExpanded ? '1' : '0',
|
||||
width: isExpanded ? 'auto' : '0px',
|
||||
overflow: 'hidden',
|
||||
paddingLeft: isExpanded ? '12px' : '10px', // px-3 vs px-2.5
|
||||
paddingRight: isExpanded ? '12px' : '10px',
|
||||
}
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (property: keyof typeof style) => style[property] ?? '',
|
||||
}
|
||||
}),
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Text Squeeze Animation Issue', () => {
|
||||
it('should show text squeeze effect when switching from collapse to expand', async () => {
|
||||
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
// In collapse mode, text should be in DOM but hidden via CSS
|
||||
const textElement = screen.getByText('Orchestrate')
|
||||
expect(textElement).toBeInTheDocument()
|
||||
expect(textElement).toHaveClass('opacity-0')
|
||||
expect(textElement).toHaveClass('max-w-0')
|
||||
expect(textElement).toHaveClass('overflow-hidden')
|
||||
|
||||
// Icon should still be present
|
||||
expect(screen.getByTestId('nav-icon')).toBeInTheDocument()
|
||||
|
||||
// Check Figma collapsed icon-only sizing.
|
||||
const linkElement = screen.getByTestId('nav-link')
|
||||
expect(linkElement).toHaveClass('size-8')
|
||||
expect(linkElement).toHaveClass('p-1.5')
|
||||
|
||||
// Switch to expand mode - should have smooth text transition
|
||||
rerender(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
// Text should now be visible with opacity animation
|
||||
expect(screen.getByText('Orchestrate')).toBeInTheDocument()
|
||||
|
||||
// Expanded state returns to the full-width navigation item.
|
||||
const expandedLinkElement = screen.getByTestId('nav-link')
|
||||
expect(expandedLinkElement).toHaveClass('pl-3')
|
||||
expect(expandedLinkElement).toHaveClass('pr-1')
|
||||
|
||||
// Fixed: text now uses max-width animation instead of abrupt show/hide
|
||||
const expandedTextElement = screen.getByText('Orchestrate')
|
||||
expect(expandedTextElement).toBeInTheDocument()
|
||||
expect(expandedTextElement).toHaveClass('max-w-none')
|
||||
expect(expandedTextElement).toHaveClass('opacity-100')
|
||||
|
||||
// The fix provides:
|
||||
// - Opacity transition from 0 to 1
|
||||
// - Max-width transition from 0 to none (prevents squashing)
|
||||
// - No layout shift from consistent padding
|
||||
})
|
||||
|
||||
it('should maintain icon position consistency using wrapper div', () => {
|
||||
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
const iconElement = screen.getByTestId('nav-icon')
|
||||
const iconWrapper = iconElement.parentElement
|
||||
|
||||
// Icon wrapper should center the icon in the collapsed button.
|
||||
expect(iconWrapper).toHaveClass('flex')
|
||||
expect(iconWrapper).toHaveClass('size-5')
|
||||
expect(iconWrapper).toHaveClass('items-center')
|
||||
expect(iconWrapper).toHaveClass('justify-center')
|
||||
|
||||
rerender(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
// In expand mode, wrapper should keep the fixed icon slot.
|
||||
const expandedIconWrapper = screen.getByTestId('nav-icon').parentElement
|
||||
expect(expandedIconWrapper).toHaveClass('flex')
|
||||
expect(expandedIconWrapper).toHaveClass('size-5')
|
||||
expect(expandedIconWrapper).toHaveClass('items-center')
|
||||
expect(expandedIconWrapper).toHaveClass('justify-center')
|
||||
|
||||
// Icon itself uses the fixed glyph size.
|
||||
const expandedIconElement = screen.getByTestId('nav-icon')
|
||||
expect(expandedIconElement).toHaveClass('size-[18px]')
|
||||
expect(iconElement).toHaveClass('shrink-0')
|
||||
|
||||
// This wrapper approach eliminates the icon margin shift issue
|
||||
})
|
||||
|
||||
it('should provide smooth text transition with max-width animation', () => {
|
||||
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
// Text is always in DOM but controlled via CSS classes
|
||||
const collapsedText = screen.getByText('Orchestrate')
|
||||
expect(collapsedText).toBeInTheDocument()
|
||||
expect(collapsedText).toHaveClass('opacity-0')
|
||||
expect(collapsedText).toHaveClass('max-w-0')
|
||||
expect(collapsedText).toHaveClass('overflow-hidden')
|
||||
|
||||
rerender(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
// Text smoothly transitions to visible state
|
||||
const expandedText = screen.getByText('Orchestrate')
|
||||
expect(expandedText).toBeInTheDocument()
|
||||
expect(expandedText).toHaveClass('opacity-100')
|
||||
expect(expandedText).toHaveClass('max-w-none')
|
||||
|
||||
// Fixed: Always present in DOM with smooth CSS transitions
|
||||
// instead of abrupt conditional rendering
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layout Consistency Improvements', () => {
|
||||
it('should maintain consistent padding across all states', () => {
|
||||
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
const linkElement = screen.getByTestId('nav-link')
|
||||
|
||||
// Collapsed state uses a fixed icon-only button.
|
||||
expect(linkElement).toHaveClass('size-8')
|
||||
expect(linkElement).toHaveClass('p-1.5')
|
||||
|
||||
rerender(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
// Expanded state uses text item padding.
|
||||
const expandedLinkElement = screen.getByTestId('nav-link')
|
||||
expect(expandedLinkElement).toHaveClass('pl-3')
|
||||
expect(expandedLinkElement).toHaveClass('pr-1')
|
||||
|
||||
// This consistency eliminates the layout shift issue
|
||||
})
|
||||
|
||||
it('should use wrapper-based icon positioning instead of margin changes', () => {
|
||||
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
const iconElement = screen.getByTestId('nav-icon')
|
||||
const iconWrapper = iconElement.parentElement
|
||||
|
||||
// Collapsed: wrapper centers the icon in the 32px item.
|
||||
expect(iconWrapper).toHaveClass('flex')
|
||||
expect(iconWrapper).toHaveClass('size-5')
|
||||
expect(iconWrapper).toHaveClass('items-center')
|
||||
expect(iconWrapper).toHaveClass('justify-center')
|
||||
|
||||
// Icon itself uses the larger collapsed glyph size.
|
||||
expect(iconElement).toHaveClass('size-[18px]')
|
||||
expect(iconElement).toHaveClass('shrink-0')
|
||||
|
||||
rerender(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
const expandedIconWrapper = screen.getByTestId('nav-icon').parentElement
|
||||
|
||||
// Expanded: wrapper still centers the 18px icon in a 20px slot.
|
||||
expect(expandedIconWrapper).toHaveClass('flex')
|
||||
expect(expandedIconWrapper).toHaveClass('size-5')
|
||||
expect(expandedIconWrapper).toHaveClass('items-center')
|
||||
expect(expandedIconWrapper).toHaveClass('justify-center')
|
||||
|
||||
// Icon keeps the same fixed glyph size.
|
||||
const expandedIconElement = screen.getByTestId('nav-icon')
|
||||
expect(expandedIconElement).toHaveClass('size-[18px]')
|
||||
expect(expandedIconElement).toHaveClass('shrink-0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Active State Handling', () => {
|
||||
it('should handle active state correctly in both modes', () => {
|
||||
// Test non-active state
|
||||
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
|
||||
it('should use the selected icon when the link matches the current route', () => {
|
||||
render(<NavLink {...mockProps} href="/app/123/overview" />)
|
||||
|
||||
let linkElement = screen.getByTestId('nav-link')
|
||||
expect(linkElement).not.toHaveClass('bg-components-menu-item-bg-active')
|
||||
|
||||
// Test with active state (when href matches current segment)
|
||||
const activeProps = {
|
||||
...mockProps,
|
||||
href: '/app/123/overview', // matches mocked segment
|
||||
}
|
||||
|
||||
rerender(<NavLink {...activeProps} mode="expand" />)
|
||||
|
||||
linkElement = screen.getByTestId('nav-link')
|
||||
expect(linkElement).toHaveClass('bg-components-menu-item-bg-active')
|
||||
expect(linkElement).toHaveClass('text-text-accent-light-mode-only')
|
||||
expect(screen.getByRole('link', { name: 'Orchestrate' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/123/overview',
|
||||
)
|
||||
expect(screen.getByTestId('selected-nav-icon')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('normal-nav-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not mark logs active on the annotations pathname', () => {
|
||||
render(<NavLink {...mockProps} href="/app/123/logs" pathname="/app/123/annotations" />)
|
||||
|
||||
const linkElement = screen.getByRole('link', { name: 'Orchestrate' })
|
||||
expect(linkElement).not.toHaveClass('bg-components-menu-item-bg-active')
|
||||
expect(screen.getByTestId('normal-nav-icon')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('selected-nav-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use pathname to mark annotations active when rendered outside the app detail route segment', () => {
|
||||
render(<NavLink {...mockProps} href="/app/123/annotations" pathname="/app/123/annotations" />)
|
||||
|
||||
const linkElement = screen.getByRole('link', { name: 'Orchestrate' })
|
||||
expect(linkElement).toHaveClass('bg-components-menu-item-bg-active')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Text Animation Classes', () => {
|
||||
it('should have proper text classes in collapsed mode', () => {
|
||||
render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
const textElement = screen.getByText('Orchestrate')
|
||||
|
||||
expect(textElement).toHaveClass('overflow-hidden')
|
||||
expect(textElement).toHaveClass('whitespace-nowrap')
|
||||
expect(textElement).toHaveClass('transition-[margin-left,max-width,opacity]')
|
||||
expect(textElement).toHaveClass('duration-200')
|
||||
expect(textElement).toHaveClass('ease-in-out')
|
||||
expect(textElement).toHaveClass('ml-0')
|
||||
expect(textElement).toHaveClass('max-w-0')
|
||||
expect(textElement).toHaveClass('opacity-0')
|
||||
})
|
||||
|
||||
it('should have proper text classes in expanded mode', () => {
|
||||
render(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
const textElement = screen.getByText('Orchestrate')
|
||||
|
||||
expect(textElement).toHaveClass('overflow-hidden')
|
||||
expect(textElement).toHaveClass('whitespace-nowrap')
|
||||
expect(textElement).toHaveClass('transition-[margin-left,max-width,opacity]')
|
||||
expect(textElement).toHaveClass('duration-200')
|
||||
expect(textElement).toHaveClass('ease-in-out')
|
||||
expect(textElement).toHaveClass('ml-2')
|
||||
expect(textElement).toHaveClass('max-w-none')
|
||||
expect(textElement).toHaveClass('opacity-100')
|
||||
expect(screen.getByTestId('selected-nav-icon')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('normal-nav-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -288,34 +82,20 @@ describe('NavLink Animation and Layout Issues', () => {
|
||||
const buttonElement = screen.getByRole('button')
|
||||
expect(buttonElement).toBeInTheDocument()
|
||||
expect(buttonElement).toBeDisabled()
|
||||
expect(buttonElement).toHaveClass('cursor-not-allowed')
|
||||
expect(buttonElement).toHaveClass('opacity-30')
|
||||
})
|
||||
|
||||
it('should maintain consistent styling in disabled state', () => {
|
||||
render(<NavLink {...mockProps} mode="collapse" disabled={true} />)
|
||||
|
||||
const buttonElement = screen.getByRole('button')
|
||||
expect(buttonElement).toHaveClass('size-8')
|
||||
expect(buttonElement).toHaveClass('p-1.5')
|
||||
|
||||
const iconWrapper = screen.getByTestId('nav-icon').parentElement
|
||||
expect(iconWrapper).toHaveClass('size-5')
|
||||
expect(buttonElement).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button Mode', () => {
|
||||
it('should render as an interactive button when href is omitted', () => {
|
||||
it('should render as an interactive button when href is omitted', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
|
||||
|
||||
const buttonElement = screen.getByText('Orchestrate').closest('button')
|
||||
expect(buttonElement).not.toBeNull()
|
||||
expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active')
|
||||
expect(buttonElement).toHaveClass('text-text-accent-light-mode-only')
|
||||
const buttonElement = screen.getByRole('button', { name: 'Orchestrate' })
|
||||
|
||||
buttonElement?.click()
|
||||
await user.click(buttonElement)
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,68 +1,17 @@
|
||||
import { useStore } from '../store'
|
||||
|
||||
const resetStore = () => {
|
||||
useStore.setState({
|
||||
appDetail: undefined,
|
||||
currentLogItem: undefined,
|
||||
currentLogModalActiveTab: 'DETAIL',
|
||||
showPromptLogModal: false,
|
||||
showAgentLogModal: false,
|
||||
showMessageLogModal: false,
|
||||
showAppConfigureFeaturesModal: false,
|
||||
})
|
||||
}
|
||||
|
||||
describe('app store', () => {
|
||||
beforeEach(() => {
|
||||
resetStore()
|
||||
useStore.setState({
|
||||
currentLogModalActiveTab: 'DETAIL',
|
||||
showMessageLogModal: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should expose the default state', () => {
|
||||
expect(useStore.getState()).toEqual(
|
||||
expect.objectContaining({
|
||||
appDetail: undefined,
|
||||
currentLogItem: undefined,
|
||||
currentLogModalActiveTab: 'DETAIL',
|
||||
showPromptLogModal: false,
|
||||
showAgentLogModal: false,
|
||||
showMessageLogModal: false,
|
||||
showAppConfigureFeaturesModal: false,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should update every mutable field through its actions', () => {
|
||||
const appDetail = { id: 'app-1' } as ReturnType<typeof useStore.getState>['appDetail']
|
||||
const currentLogItem = { id: 'message-1' } as ReturnType<
|
||||
typeof useStore.getState
|
||||
>['currentLogItem']
|
||||
|
||||
useStore.getState().setAppDetail(appDetail)
|
||||
useStore.getState().setCurrentLogItem(currentLogItem)
|
||||
useStore.getState().setCurrentLogModalActiveTab('MESSAGE')
|
||||
useStore.getState().setShowPromptLogModal(true)
|
||||
useStore.getState().setShowAgentLogModal(true)
|
||||
useStore.getState().setShowAppConfigureFeaturesModal(true)
|
||||
|
||||
expect(useStore.getState()).toEqual(
|
||||
expect.objectContaining({
|
||||
appDetail,
|
||||
currentLogItem,
|
||||
currentLogModalActiveTab: 'MESSAGE',
|
||||
showPromptLogModal: true,
|
||||
showAgentLogModal: true,
|
||||
showAppConfigureFeaturesModal: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should reset the active tab when the message log modal closes', () => {
|
||||
it('resets the active tab when the message log modal closes', () => {
|
||||
useStore.getState().setCurrentLogModalActiveTab('TRACE')
|
||||
useStore.getState().setShowMessageLogModal(true)
|
||||
|
||||
expect(useStore.getState().showMessageLogModal).toBe(true)
|
||||
expect(useStore.getState().currentLogModalActiveTab).toBe('TRACE')
|
||||
|
||||
useStore.getState().setShowMessageLogModal(false)
|
||||
|
||||
expect(useStore.getState().showMessageLogModal).toBe(false)
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import EmptyElement from '../empty-element'
|
||||
|
||||
describe('EmptyElement', () => {
|
||||
it('should render the empty state copy and supporting icon', () => {
|
||||
const { container } = render(<EmptyElement />)
|
||||
|
||||
expect(screen.getByText('appAnnotation.noData.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('appAnnotation.noData.description')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@ -60,19 +60,16 @@ describe('CSVUploader', () => {
|
||||
clickSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should toggle dragging styles and upload the dropped file', async () => {
|
||||
it('should upload the dropped file', async () => {
|
||||
const file = new File(['content'], 'input.csv', { type: 'text/csv' })
|
||||
renderComponent()
|
||||
const { dropZone, dropContainer } = getDropElements()
|
||||
const { dropContainer } = getDropElements()
|
||||
|
||||
fireEvent.dragEnter(dropContainer)
|
||||
expect(dropZone.className).toContain('border-components-dropzone-border-accent')
|
||||
expect(dropZone.className).toContain('bg-components-dropzone-bg-accent')
|
||||
|
||||
fireEvent.drop(dropContainer, { dataTransfer: { files: [file] } })
|
||||
|
||||
await waitFor(() => expect(updateFile).toHaveBeenCalledWith(file))
|
||||
expect(dropZone.className).not.toContain('border-components-dropzone-border-accent')
|
||||
})
|
||||
|
||||
it('should handle drag over and clear dragging state when leaving through the overlay', () => {
|
||||
|
||||
@ -1,467 +1,83 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import EditItem, { EditItemType, EditTitle } from '../index'
|
||||
|
||||
const defaultProps = {
|
||||
type: EditItemType.Query,
|
||||
content: 'Original content',
|
||||
onSave: vi.fn(),
|
||||
}
|
||||
|
||||
const startEditing = async () => {
|
||||
await userEvent.click(screen.getByText('common.operation.edit'))
|
||||
return screen.getByRole('textbox')
|
||||
}
|
||||
|
||||
describe('EditTitle', () => {
|
||||
it('should render title content correctly', () => {
|
||||
// Arrange
|
||||
const props = { title: 'Test Title' }
|
||||
it('shows the field title', () => {
|
||||
render(<EditTitle title="Query" />)
|
||||
|
||||
// Act
|
||||
render(<EditTitle {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/test title/i)).toBeInTheDocument()
|
||||
// Should contain edit icon (svg element)
|
||||
expect(document.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom className when provided', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
title: 'Test Title',
|
||||
className: 'custom-class',
|
||||
}
|
||||
|
||||
// Act
|
||||
const { container } = render(<EditTitle {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/test title/i)).toBeInTheDocument()
|
||||
expect(container.querySelector('.custom-class')).toBeInTheDocument()
|
||||
expect(screen.getByText('Query')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('EditItem', () => {
|
||||
const defaultProps = {
|
||||
type: EditItemType.Query,
|
||||
content: 'Test content',
|
||||
onSave: vi.fn(),
|
||||
}
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
it('does not offer editing in readonly mode', () => {
|
||||
render(<EditItem {...defaultProps} readonly />)
|
||||
|
||||
expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should render content correctly', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
it('saves updated content', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
render(<EditItem {...defaultProps} onSave={onSave} />)
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
const textbox = await startEditing()
|
||||
await userEvent.clear(textbox)
|
||||
await userEvent.type(textbox, 'Updated content')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/test content/i)).toBeInTheDocument()
|
||||
// Should show item name (query or answer)
|
||||
expect(screen.getByText('appAnnotation.editModal.queryName')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render different item types correctly', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: EditItemType.Answer,
|
||||
content: 'Answer content',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/answer content/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('appAnnotation.editModal.answerName')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show edit controls when not readonly', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide edit controls when readonly', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
readonly: true,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(onSave).toHaveBeenCalledWith('Updated content')
|
||||
})
|
||||
|
||||
// Props tests (REQUIRED)
|
||||
describe('Props', () => {
|
||||
it('should respect readonly prop for edit functionality', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
readonly: true,
|
||||
}
|
||||
it('cancels editing without saving', async () => {
|
||||
const onSave = vi.fn()
|
||||
render(<EditItem {...defaultProps} onSave={onSave} />)
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
await startEditing()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/test content/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display provided content', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
content: 'Custom content for testing',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/custom content for testing/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render appropriate content based on type', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: EditItemType.Query,
|
||||
content: 'Question content',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/question content/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('appAnnotation.editModal.queryName')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Original content')).toBeInTheDocument()
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should activate edit mode when edit button is clicked', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const user = userEvent.setup()
|
||||
it('can restore the original content after saving an edit', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
render(<EditItem {...defaultProps} onSave={onSave} />)
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
const textbox = await startEditing()
|
||||
await userEvent.clear(textbox)
|
||||
await userEvent.type(textbox, 'Updated content')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
await userEvent.click(await screen.findByText('common.operation.delete'))
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should save new content when save button is clicked', async () => {
|
||||
// Arrange
|
||||
const mockSave = vi.fn().mockResolvedValue(undefined)
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onSave: mockSave,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
|
||||
// Type new content
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'Updated content')
|
||||
|
||||
// Save
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
// Assert
|
||||
expect(mockSave).toHaveBeenCalledWith('Updated content')
|
||||
})
|
||||
|
||||
it('should exit edit mode when cancel button is clicked', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/test content/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show content preview while typing', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.type(textarea, 'New content')
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/new content/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSave with correct content when saving', async () => {
|
||||
// Arrange
|
||||
const mockSave = vi.fn().mockResolvedValue(undefined)
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onSave: mockSave,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'Test save content')
|
||||
|
||||
// Save
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
// Assert
|
||||
expect(mockSave).toHaveBeenCalledWith('Test save content')
|
||||
})
|
||||
|
||||
it('should show delete option and restore original content when delete is clicked', async () => {
|
||||
// Arrange
|
||||
const mockSave = vi.fn().mockResolvedValue(undefined)
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onSave: mockSave,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Enter edit mode and change content
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'Modified content')
|
||||
|
||||
// Save to trigger content change
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
// Assert
|
||||
expect(mockSave).toHaveBeenNthCalledWith(1, 'Modified content')
|
||||
expect(await screen.findByText('common.operation.delete')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
expect(mockSave).toHaveBeenNthCalledWith(2, 'Test content')
|
||||
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle keyboard interactions in edit mode', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
|
||||
// Test typing
|
||||
await user.type(textarea, 'Keyboard test')
|
||||
|
||||
// Assert
|
||||
expect(textarea).toHaveValue('Keyboard test')
|
||||
expect(screen.getByText(/keyboard test/i)).toBeInTheDocument()
|
||||
})
|
||||
expect(onSave).toHaveBeenNthCalledWith(1, 'Updated content')
|
||||
expect(onSave).toHaveBeenNthCalledWith(2, 'Original content')
|
||||
})
|
||||
|
||||
// State Management
|
||||
describe('State Management', () => {
|
||||
it('should reset newContent when content prop changes', async () => {
|
||||
// Arrange
|
||||
const { rerender } = render(<EditItem {...defaultProps} />)
|
||||
it('keeps editing available when saving fails', async () => {
|
||||
const onSave = vi.fn().mockRejectedValue(new Error('Save failed'))
|
||||
render(<EditItem {...defaultProps} onSave={onSave} />)
|
||||
|
||||
// Act - Enter edit mode and type something
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'New content')
|
||||
const textbox = await startEditing()
|
||||
await userEvent.type(textbox, ' updated')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
// Rerender with new content prop
|
||||
rerender(<EditItem {...defaultProps} content="Updated content" />)
|
||||
|
||||
// Assert - Textarea value should be reset due to useEffect
|
||||
expect(textarea).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should preserve edit state across content changes', async () => {
|
||||
// Arrange
|
||||
const { rerender } = render(<EditItem {...defaultProps} />)
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act - Enter edit mode
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
|
||||
// Rerender with new content
|
||||
rerender(<EditItem {...defaultProps} content="Updated content" />)
|
||||
|
||||
// Assert - Should still be in edit mode
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases (REQUIRED)
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty content', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
content: '',
|
||||
}
|
||||
|
||||
// Act
|
||||
const { container } = render(<EditItem {...props} />)
|
||||
|
||||
// Assert - Should render without crashing
|
||||
// Check that the component renders properly with empty content
|
||||
expect(container.querySelector('.grow')).toBeInTheDocument()
|
||||
// Should still show edit button
|
||||
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very long content', () => {
|
||||
// Arrange
|
||||
const longContent = 'A'.repeat(1000)
|
||||
const props = {
|
||||
...defaultProps,
|
||||
content: longContent,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(longContent)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle content with special characters', () => {
|
||||
// Arrange
|
||||
const specialContent = 'Content with & < > " \' characters'
|
||||
const props = {
|
||||
...defaultProps,
|
||||
content: specialContent,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(specialContent)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle rapid edit/cancel operations', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Rapid edit/cancel operations
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
await user.click(screen.getByText('common.operation.cancel'))
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
await user.click(screen.getByText('common.operation.cancel'))
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Test content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle save failure gracefully in edit mode', async () => {
|
||||
// Arrange
|
||||
const mockSave = vi.fn().mockRejectedValueOnce(new Error('Save failed'))
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onSave: mockSave,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Enter edit mode and save (should fail)
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.type(textarea, 'New content')
|
||||
|
||||
// Save should fail but not throw
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
// Assert - Should remain in edit mode when save fails
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
|
||||
expect(mockSave).toHaveBeenCalledWith('New content')
|
||||
})
|
||||
|
||||
it('should handle delete action failure gracefully', async () => {
|
||||
// Arrange
|
||||
const mockSave = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined) // First save succeeds
|
||||
.mockRejectedValueOnce(new Error('Delete failed')) // Delete fails
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onSave: mockSave,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Edit content to show delete button
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'Modified content')
|
||||
|
||||
// Save to create new content
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
await screen.findByText('common.operation.delete')
|
||||
|
||||
// Click delete (should fail but not throw)
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
// Assert - Delete action should handle error gracefully
|
||||
expect(mockSave).toHaveBeenCalledTimes(2)
|
||||
expect(mockSave).toHaveBeenNthCalledWith(1, 'Modified content')
|
||||
expect(mockSave).toHaveBeenNthCalledWith(2, 'Test content')
|
||||
|
||||
// When delete fails, the delete button should still be visible (state not changed)
|
||||
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
|
||||
expect(screen.getByText('Modified content')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import HitHistoryNoData from '../hit-history-no-data'
|
||||
|
||||
describe('HitHistoryNoData', () => {
|
||||
it('should render the empty history message', () => {
|
||||
render(<HitHistoryNoData />)
|
||||
|
||||
expect(screen.getByText('appAnnotation.viewModal.noHitHistory')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,31 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import AccessControlDialog from '../access-control-dialog'
|
||||
|
||||
describe('AccessControlDialog', () => {
|
||||
it('should render dialog content when visible', () => {
|
||||
render(
|
||||
<AccessControlDialog show className="custom-dialog">
|
||||
<div>Dialog Content</div>
|
||||
</AccessControlDialog>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog')).toHaveClass('custom-dialog')
|
||||
expect(screen.getByText('Dialog Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should trigger onClose when clicking the close control', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<AccessControlDialog show onClose={onClose}>
|
||||
<div>Dialog Content</div>
|
||||
</AccessControlDialog>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,32 +0,0 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useConfiguration } from '../hooks/use-configuration'
|
||||
import Configuration from '../index'
|
||||
|
||||
const mockView = vi.fn((_: unknown) => <div data-testid="configuration-view" />)
|
||||
|
||||
vi.mock('../configuration-view', () => ({
|
||||
default: (props: unknown) => mockView(props),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-configuration', () => ({
|
||||
useConfiguration: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('Configuration entry', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should pass the hook view model into ConfigurationView', () => {
|
||||
const viewModel = {
|
||||
showLoading: true,
|
||||
}
|
||||
vi.mocked(useConfiguration).mockReturnValue(viewModel as never)
|
||||
|
||||
render(<Configuration />)
|
||||
|
||||
expect(useConfiguration).toHaveBeenCalledTimes(1)
|
||||
expect(mockView).toHaveBeenCalledWith(viewModel)
|
||||
})
|
||||
})
|
||||
@ -1,71 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import FeaturePanel from '../index'
|
||||
|
||||
describe('FeaturePanel', () => {
|
||||
// Rendering behavior for standard layout.
|
||||
describe('Rendering', () => {
|
||||
it('should render the title and children when provided', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<FeaturePanel title="Panel Title">
|
||||
<div>Panel Body</div>
|
||||
</FeaturePanel>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Panel Title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Panel Body')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven presentation details like icons, actions, and spacing.
|
||||
describe('Props', () => {
|
||||
it('should render header icon and right slot and apply header border', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<FeaturePanel
|
||||
title="Feature"
|
||||
headerIcon={<span>Icon</span>}
|
||||
headerRight={<button type="button">Action</button>}
|
||||
hasHeaderBottomBorder={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('Action')).toBeInTheDocument()
|
||||
const header = screen.getByTestId('feature-panel-header')
|
||||
expect(header).toHaveClass('border-b')
|
||||
})
|
||||
|
||||
it('should apply custom className and remove padding when noBodySpacing is true', () => {
|
||||
// Arrange
|
||||
const { container } = render(
|
||||
<FeaturePanel title="Spacing" className="custom-panel" noBodySpacing={true}>
|
||||
<div>Body</div>
|
||||
</FeaturePanel>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const root = container.firstElementChild as HTMLElement
|
||||
expect(root).toHaveClass('custom-panel')
|
||||
expect(root).toHaveClass('pb-0')
|
||||
const body = screen.getByTestId('feature-panel-body')
|
||||
expect(body).not.toHaveClass('mt-1')
|
||||
expect(body).not.toHaveClass('px-3')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases when optional content is missing.
|
||||
describe('Edge Cases', () => {
|
||||
it('should not render the body wrapper when children is undefined', () => {
|
||||
// Arrange
|
||||
render(<FeaturePanel title="No Body" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('No Body')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Panel Body')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('feature-panel-body')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,21 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import GroupName from '../index'
|
||||
|
||||
describe('GroupName', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render name when provided', () => {
|
||||
// Arrange
|
||||
const title = 'Inputs'
|
||||
|
||||
// Act
|
||||
render(<GroupName name={title} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(title)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,70 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import OperationBtn from '../index'
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiAddLine: (props: { className?: string }) => (
|
||||
<svg data-testid="add-icon" className={props.className} />
|
||||
),
|
||||
RiEditLine: (props: { className?: string }) => (
|
||||
<svg data-testid="edit-icon" className={props.className} />
|
||||
),
|
||||
}))
|
||||
|
||||
describe('OperationBtn', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering icons and translation labels
|
||||
describe('Rendering', () => {
|
||||
it('should render passed custom class when provided', () => {
|
||||
// Arrange
|
||||
const customClass = 'custom-class'
|
||||
|
||||
// Act
|
||||
render(<OperationBtn type="add" className={customClass} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.operation.add').parentElement).toHaveClass(customClass)
|
||||
})
|
||||
it('should render add icon when type is add', () => {
|
||||
// Arrange
|
||||
const onClick = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<OperationBtn type="add" onClick={onClick} className="custom-class" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('add-icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.operation.add')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render edit icon when provided', () => {
|
||||
// Arrange
|
||||
const actionName = 'Rename'
|
||||
|
||||
// Act
|
||||
render(<OperationBtn type="edit" actionName={actionName} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('edit-icon')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('add-icon')).toBeNull()
|
||||
expect(screen.getByText(actionName)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Click handling
|
||||
describe('Interactions', () => {
|
||||
it('should execute click handler when button is clicked', () => {
|
||||
// Arrange
|
||||
const onClick = vi.fn()
|
||||
render(<OperationBtn type="add" onClick={onClick} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('common.operation.add'))
|
||||
|
||||
// Assert
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,38 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import VarHighlight from '../index'
|
||||
|
||||
describe('VarHighlight', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering highlighted variable tags
|
||||
describe('Rendering', () => {
|
||||
it('should render braces around the variable name with default styles', () => {
|
||||
// Arrange
|
||||
const props = { name: 'userInput' }
|
||||
|
||||
// Act
|
||||
const { container } = render(<VarHighlight {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('userInput')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('{{')[0]).toBeInTheDocument()
|
||||
expect(screen.getAllByText('}}')[0]).toBeInTheDocument()
|
||||
// CSS modules add a hash to class names, so we check that the class attribute contains 'item'
|
||||
const firstChild = container.firstChild as HTMLElement
|
||||
expect(firstChild.className).toContain('item')
|
||||
})
|
||||
|
||||
it('should apply custom class names when provided', () => {
|
||||
// Arrange
|
||||
const props = { name: 'custom', className: 'mt-2' }
|
||||
|
||||
// Act
|
||||
const { container } = render(<VarHighlight {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toHaveClass('mt-2')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,30 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import CannotQueryDataset from '../cannot-query-dataset'
|
||||
|
||||
describe('CannotQueryDataset WarningMask', () => {
|
||||
it('should render dataset warning copy and action button', () => {
|
||||
const onConfirm = vi.fn()
|
||||
render(<CannotQueryDataset onConfirm={onConfirm} />)
|
||||
|
||||
expect(
|
||||
screen.getByText('appDebug.feature.dataSet.queryVariable.unableToQueryDataSet'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('appDebug.feature.dataSet.queryVariable.unableToQueryDataSetTip'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'appDebug.feature.dataSet.queryVariable.ok' }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should invoke onConfirm when OK button clicked', () => {
|
||||
const onConfirm = vi.fn()
|
||||
render(<CannotQueryDataset onConfirm={onConfirm} />)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'appDebug.feature.dataSet.queryVariable.ok' }),
|
||||
)
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -1,29 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import FormattingChanged from '../formatting-changed'
|
||||
|
||||
describe('FormattingChanged WarningMask', () => {
|
||||
it('should display translation text and both actions', () => {
|
||||
const onConfirm = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
|
||||
render(<FormattingChanged onConfirm={onConfirm} onCancel={onCancel} />)
|
||||
|
||||
expect(screen.getByText('appDebug.formattingChangedTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('appDebug.formattingChangedText')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.refresh/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call callbacks when buttons are clicked', () => {
|
||||
const onConfirm = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
render(<FormattingChanged onConfirm={onConfirm} onCancel={onCancel} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.refresh/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -1,25 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import WarningMask from '../index'
|
||||
|
||||
describe('WarningMask', () => {
|
||||
// Rendering of title, description, and footer content
|
||||
describe('Rendering', () => {
|
||||
it('should display provided title, description, and footer node', () => {
|
||||
const footer = <button type="button">Retry</button>
|
||||
// Arrange
|
||||
render(
|
||||
<WarningMask
|
||||
title="Access Restricted"
|
||||
description="Only workspace owners may modify this section."
|
||||
footer={footer}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Access Restricted')).toBeInTheDocument()
|
||||
expect(screen.getByText('Only workspace owners may modify this section.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,62 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import PromptEditorHeightResizeWrap from '../prompt-editor-height-resize-wrap'
|
||||
|
||||
describe('PromptEditorHeightResizeWrap', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should render children, footer, and hide resize handler when requested', () => {
|
||||
const { container } = render(
|
||||
<PromptEditorHeightResizeWrap
|
||||
className="wrapper"
|
||||
height={150}
|
||||
minHeight={100}
|
||||
onHeightChange={vi.fn()}
|
||||
footer={<div>footer</div>}
|
||||
hideResize
|
||||
>
|
||||
<div>content</div>
|
||||
</PromptEditorHeightResizeWrap>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('content')).toBeInTheDocument()
|
||||
expect(screen.getByText('footer')).toBeInTheDocument()
|
||||
expect(container.querySelector('.cursor-row-resize')).toBeNull()
|
||||
})
|
||||
|
||||
it('should resize height with mouse events and clamp to minHeight', () => {
|
||||
const onHeightChange = vi.fn()
|
||||
|
||||
const { container } = render(
|
||||
<PromptEditorHeightResizeWrap height={150} minHeight={100} onHeightChange={onHeightChange}>
|
||||
<div>content</div>
|
||||
</PromptEditorHeightResizeWrap>,
|
||||
)
|
||||
|
||||
const handle = container.querySelector('.cursor-row-resize')
|
||||
expect(handle).not.toBeNull()
|
||||
|
||||
fireEvent.mouseDown(handle as Element, { clientY: 100 })
|
||||
expect(document.body.style.userSelect).toBe('none')
|
||||
|
||||
fireEvent.mouseMove(document, { clientY: 130 })
|
||||
vi.runAllTimers()
|
||||
expect(onHeightChange).toHaveBeenLastCalledWith(180)
|
||||
|
||||
onHeightChange.mockClear()
|
||||
fireEvent.mouseMove(document, { clientY: -100 })
|
||||
vi.runAllTimers()
|
||||
expect(onHeightChange).toHaveBeenLastCalledWith(100)
|
||||
|
||||
fireEvent.mouseUp(document)
|
||||
expect(document.body.style.userSelect).toBe('')
|
||||
})
|
||||
})
|
||||
@ -1,32 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import HistoryPanel from '../history-panel'
|
||||
|
||||
vi.mock('@/app/components/app/configuration/base/operation-btn', () => ({
|
||||
default: ({ onClick }: { onClick: () => void }) => (
|
||||
<button type="button" data-testid="edit-button" onClick={onClick}>
|
||||
edit
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/configuration/base/feature-panel', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
describe('HistoryPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render warning content when showWarning is true', () => {
|
||||
render(<HistoryPanel showWarning onShowEditModal={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('appDebug.feature.conversationHistory.tip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide warning when showWarning is false', () => {
|
||||
render(<HistoryPanel showWarning={false} onShowEditModal={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByText('appDebug.feature.conversationHistory.tip')).toBeNull()
|
||||
})
|
||||
})
|
||||
@ -1,34 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import InputTypeIcon from '../input-type-icon'
|
||||
|
||||
const mockInputVarTypeIcon = vi.fn(
|
||||
({ type, className }: { type: InputVarType; className?: string }) => (
|
||||
<div data-testid="input-var-type-icon" data-type={type} className={className} />
|
||||
),
|
||||
)
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/input-var-type-icon', () => ({
|
||||
default: (props: { type: InputVarType; className?: string }) => mockInputVarTypeIcon(props),
|
||||
}))
|
||||
|
||||
describe('InputTypeIcon', () => {
|
||||
it('should map string variables to the workflow text-input icon', () => {
|
||||
render(<InputTypeIcon type="string" className="marker" />)
|
||||
|
||||
expect(screen.getByTestId('input-var-type-icon')).toHaveAttribute(
|
||||
'data-type',
|
||||
InputVarType.textInput,
|
||||
)
|
||||
expect(screen.getByTestId('input-var-type-icon')).toHaveClass('marker')
|
||||
})
|
||||
|
||||
it('should map select variables to the workflow select icon', () => {
|
||||
render(<InputTypeIcon type="select" className="marker" />)
|
||||
|
||||
expect(screen.getByTestId('input-var-type-icon')).toHaveAttribute(
|
||||
'data-type',
|
||||
InputVarType.select,
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -1,17 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import ModalFoot from '../modal-foot'
|
||||
|
||||
describe('ModalFoot', () => {
|
||||
it('should trigger cancel and confirm callbacks', () => {
|
||||
const onCancel = vi.fn()
|
||||
const onConfirm = vi.fn()
|
||||
|
||||
render(<ModalFoot onCancel={onCancel} onConfirm={onConfirm} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -1,23 +0,0 @@
|
||||
import { jsonConfigPlaceHolder } from '../config'
|
||||
|
||||
describe('config modal placeholder config', () => {
|
||||
it('should contain a valid object schema example', () => {
|
||||
const parsed = JSON.parse(jsonConfigPlaceHolder) as {
|
||||
type: string
|
||||
properties: {
|
||||
foo: { type: string }
|
||||
bar: {
|
||||
type: string
|
||||
properties: {
|
||||
sub: { type: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(parsed.type).toBe('object')
|
||||
expect(parsed.properties.foo.type).toBe('string')
|
||||
expect(parsed.properties.bar.type).toBe('object')
|
||||
expect(parsed.properties.bar.properties.sub.type).toBe('number')
|
||||
})
|
||||
})
|
||||
@ -1,25 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Field from '../field'
|
||||
|
||||
describe('ConfigModal Field', () => {
|
||||
it('should render the title and children', () => {
|
||||
render(
|
||||
<Field title="Field title">
|
||||
<input aria-label="field-input" />
|
||||
</Field>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Field title')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('field-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the optional hint when requested', () => {
|
||||
render(
|
||||
<Field title="Optional field" isOptional>
|
||||
<input aria-label="optional-field-input" />
|
||||
</Field>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/\(appDebug\.variableConfig\.optional\)/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -31,26 +31,4 @@ describe('TypeSelector', () => {
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith({ value: 'number', name: 'Number' })
|
||||
})
|
||||
|
||||
it('should size popup content to match the trigger width', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<TypeSelector
|
||||
value="text-input"
|
||||
onSelect={vi.fn()}
|
||||
items={[
|
||||
{ value: 'text-input' as any, name: 'Text' },
|
||||
{ value: 'number' as any, name: 'Number' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
|
||||
const [, numberOption] = await screen.findAllByRole('option')
|
||||
const popup = numberOption!.closest('[data-side]')
|
||||
|
||||
expect(popup).toHaveClass('w-(--anchor-width)')
|
||||
})
|
||||
})
|
||||
|
||||
@ -57,15 +57,6 @@ describe('ConfigSelect Component', () => {
|
||||
expect(defaultProps.onChange).toHaveBeenCalledWith([...defaultProps.options, ''])
|
||||
})
|
||||
|
||||
it('applies focus styles on input focus', () => {
|
||||
render(<ConfigSelect {...defaultProps} />)
|
||||
const firstInput = screen.getByDisplayValue('Option 1')
|
||||
|
||||
fireEvent.focus(firstInput)
|
||||
|
||||
expect(firstInput.closest('div')).toHaveClass('border-components-input-border-active')
|
||||
})
|
||||
|
||||
it('updates option values and clears focus styles on blur', () => {
|
||||
render(<ConfigSelect {...defaultProps} />)
|
||||
const firstInput = screen.getByDisplayValue('Option 1')
|
||||
@ -75,19 +66,6 @@ describe('ConfigSelect Component', () => {
|
||||
|
||||
fireEvent.focus(firstInput)
|
||||
fireEvent.blur(firstInput)
|
||||
expect(firstInput.closest('div')).not.toHaveClass('border-components-input-border-active')
|
||||
})
|
||||
|
||||
it('applies delete hover styles', () => {
|
||||
render(<ConfigSelect {...defaultProps} />)
|
||||
const optionContainer = screen.getByDisplayValue('Option 1').closest('div')
|
||||
const deleteButton = screen.getAllByRole('button', { name: 'common.operation.delete' })[0]
|
||||
|
||||
if (!deleteButton) return
|
||||
fireEvent.mouseEnter(deleteButton)
|
||||
expect(optionContainer).toHaveClass('border-components-input-border-destructive')
|
||||
fireEvent.mouseLeave(deleteButton)
|
||||
expect(optionContainer).not.toHaveClass('border-components-input-border-destructive')
|
||||
})
|
||||
|
||||
it('renders empty state correctly', () => {
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import ItemPanel from '../item-panel'
|
||||
|
||||
describe('AgentSetting/ItemPanel', () => {
|
||||
it('should render icon, name, and children content', () => {
|
||||
render(
|
||||
<ItemPanel
|
||||
className="custom"
|
||||
icon={<span>icon</span>}
|
||||
name="Panel name"
|
||||
description="More info"
|
||||
children={<div>child content</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Panel name')).toBeInTheDocument()
|
||||
expect(screen.getByText('child content')).toBeInTheDocument()
|
||||
expect(screen.getByText('icon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,55 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import AutomaticBtn from '../automatic-btn'
|
||||
|
||||
describe('AutomaticBtn', () => {
|
||||
const mockOnClick = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the button with correct text', () => {
|
||||
render(<AutomaticBtn onClick={mockOnClick} />)
|
||||
|
||||
expect(screen.getByText(/(?:^|\.)operation\.automatic(?=$|:)/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the sparkling icon', () => {
|
||||
const { container } = render(<AutomaticBtn onClick={mockOnClick} />)
|
||||
|
||||
// The icon should be an SVG element inside the button
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render as a button element', () => {
|
||||
render(<AutomaticBtn onClick={mockOnClick} />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when button is clicked', () => {
|
||||
render(<AutomaticBtn onClick={mockOnClick} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClick multiple times on multiple clicks', () => {
|
||||
render(<AutomaticBtn onClick={mockOnClick} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,89 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import InstructionEditorInWorkflow from '../instruction-editor-in-workflow'
|
||||
import { GeneratorType } from '../types'
|
||||
|
||||
const mockUseAvailableVarList = vi.fn()
|
||||
const mockUseWorkflowVariableType = vi.fn()
|
||||
const mockGetState = vi.fn()
|
||||
const mockInstructionEditor = vi.fn()
|
||||
const filterResults: boolean[] = []
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: mockGetState,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useWorkflowVariableType: () => mockUseWorkflowVariableType(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
|
||||
default: (
|
||||
nodeId: string,
|
||||
options: { filterVar: (payload: { type: VarType }, selector: string[]) => boolean },
|
||||
) => {
|
||||
filterResults.push(
|
||||
options.filterVar({ type: VarType.string }, ['node-1']),
|
||||
options.filterVar({ type: VarType.file }, ['node-1']),
|
||||
options.filterVar({ type: VarType.arrayFile }, ['node-1']),
|
||||
options.filterVar({ type: VarType.string }, ['node-x']),
|
||||
)
|
||||
return mockUseAvailableVarList(nodeId, options)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../instruction-editor', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
mockInstructionEditor(props)
|
||||
return <div data-testid="instruction-editor">{String(props.editorKey)}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
describe('InstructionEditorInWorkflow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
filterResults.length = 0
|
||||
mockGetState.mockReturnValue({
|
||||
nodesWithInspectVars: [{ nodeId: 'node-1' }],
|
||||
})
|
||||
mockUseWorkflowVariableType.mockReturnValue('var-type-fn')
|
||||
mockUseAvailableVarList.mockReturnValue({
|
||||
availableVars: [{ variable: 'query' }],
|
||||
availableNodes: [{ id: 'node-1', data: { title: 'Node 1', type: 'llm' } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('should filter workflow variables and forward the resolved props to the editor', () => {
|
||||
render(
|
||||
<InstructionEditorInWorkflow
|
||||
nodeId="current-node"
|
||||
value="instruction"
|
||||
editorKey="editor-1"
|
||||
onChange={vi.fn()}
|
||||
generatorType={GeneratorType.prompt}
|
||||
isShowCurrentBlock
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('instruction-editor')).toHaveTextContent('editor-1')
|
||||
expect(filterResults).toEqual([true, false, false, false])
|
||||
expect(mockUseAvailableVarList).toHaveBeenCalledWith(
|
||||
'current-node',
|
||||
expect.objectContaining({
|
||||
onlyLeafNodeVar: false,
|
||||
}),
|
||||
)
|
||||
expect(mockInstructionEditor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
value: 'instruction',
|
||||
availableVars: [{ variable: 'query' }],
|
||||
availableNodes: [{ id: 'node-1', data: { title: 'Node 1', type: 'llm' } }],
|
||||
getVarType: 'var-type-fn',
|
||||
isShowCurrentBlock: true,
|
||||
isShowLastRunBlock: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -1,89 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import PromptResInWorkflow from '../prompt-res-in-workflow'
|
||||
|
||||
const mockUseAvailableVarList = vi.fn()
|
||||
const mockPromptRes = vi.fn()
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
|
||||
default: (...args: unknown[]) => mockUseAvailableVarList(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../prompt-res', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
mockPromptRes(props)
|
||||
return <div data-testid="prompt-res">{String(props.value)}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
describe('PromptResInWorkflow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAvailableVarList.mockReturnValue({
|
||||
availableVars: [{ variable: 'query' }],
|
||||
availableNodes: [
|
||||
{
|
||||
id: 'node-1',
|
||||
data: { title: 'Retriever', type: BlockEnum.KnowledgeRetrieval },
|
||||
width: 120,
|
||||
height: 80,
|
||||
position: { x: 1, y: 2 },
|
||||
},
|
||||
{
|
||||
id: 'start-node',
|
||||
data: { title: 'Start node', type: BlockEnum.Start },
|
||||
width: 100,
|
||||
height: 60,
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should build the workflow variable map and include the synthetic start node entry', () => {
|
||||
render(<PromptResInWorkflow value="prompt" nodeId="node-a" />)
|
||||
|
||||
expect(screen.getByTestId('prompt-res')).toHaveTextContent('prompt')
|
||||
expect(mockUseAvailableVarList).toHaveBeenCalledWith(
|
||||
'node-a',
|
||||
expect.objectContaining({
|
||||
onlyLeafNodeVar: false,
|
||||
}),
|
||||
)
|
||||
expect(mockPromptRes).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workflowVariableBlock: expect.objectContaining({
|
||||
show: true,
|
||||
variables: [{ variable: 'query' }],
|
||||
workflowNodesMap: expect.objectContaining({
|
||||
'node-1': expect.objectContaining({
|
||||
title: 'Retriever',
|
||||
type: BlockEnum.KnowledgeRetrieval,
|
||||
}),
|
||||
sys: expect.objectContaining({
|
||||
title: 'workflow.blocks.start',
|
||||
type: BlockEnum.Start,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should fall back to an empty variable list when the workflow hook returns no variables', () => {
|
||||
mockUseAvailableVarList.mockReturnValueOnce({
|
||||
availableVars: undefined,
|
||||
availableNodes: [],
|
||||
})
|
||||
|
||||
render(<PromptResInWorkflow value="fallback" nodeId="node-b" />)
|
||||
|
||||
expect(mockPromptRes).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workflowVariableBlock: expect.objectContaining({
|
||||
variables: [],
|
||||
workflowNodesMap: {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -1,34 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import PromptRes from '../prompt-res'
|
||||
|
||||
vi.mock('@/app/components/base/prompt-editor', () => ({
|
||||
default: ({
|
||||
value,
|
||||
workflowVariableBlock,
|
||||
}: {
|
||||
value: string
|
||||
workflowVariableBlock: { show: boolean }
|
||||
}) => (
|
||||
<div data-testid="prompt-editor" data-show={String(workflowVariableBlock.show)}>
|
||||
{value}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('PromptRes', () => {
|
||||
it('should render the prompt value and remount when the value changes', () => {
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValueOnce(1000).mockReturnValueOnce(2000)
|
||||
|
||||
const { rerender } = render(<PromptRes value="alpha" workflowVariableBlock={{ show: false }} />)
|
||||
|
||||
expect(screen.getByTestId('prompt-editor')).toHaveTextContent('alpha')
|
||||
expect(screen.getByTestId('prompt-editor')).toHaveAttribute('data-show', 'false')
|
||||
|
||||
rerender(<PromptRes value="beta" workflowVariableBlock={{ show: true }} />)
|
||||
|
||||
expect(screen.getByTestId('prompt-editor')).toHaveTextContent('beta')
|
||||
expect(screen.getByTestId('prompt-editor')).toHaveAttribute('data-show', 'true')
|
||||
|
||||
nowSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@ -1,10 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ResPlaceholder from '../res-placeholder'
|
||||
|
||||
describe('ResPlaceholder', () => {
|
||||
it('should render the placeholder copy', () => {
|
||||
render(<ResPlaceholder />)
|
||||
|
||||
expect(screen.getByText('appDebug.generate.newNoDataLine1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,351 +0,0 @@
|
||||
import type * as React from 'react'
|
||||
import type { Props } from '../var-picker'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ContextVar from '../index'
|
||||
|
||||
// Mock external dependencies only
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => '/test',
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', async () => {
|
||||
const React = await import('react')
|
||||
const PopoverContext = React.createContext({
|
||||
open: false,
|
||||
setOpen: (_open: boolean) => {},
|
||||
})
|
||||
|
||||
const Popover = ({
|
||||
children,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
|
||||
const isControlled = controlledOpen !== undefined
|
||||
const open = isControlled ? !!controlledOpen : uncontrolledOpen
|
||||
const setOpen = (nextOpen: boolean) => {
|
||||
if (!isControlled) setUncontrolledOpen(nextOpen)
|
||||
onOpenChange?.(nextOpen)
|
||||
}
|
||||
|
||||
return <PopoverContext.Provider value={{ open, setOpen }}>{children}</PopoverContext.Provider>
|
||||
}
|
||||
|
||||
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
|
||||
const { open, setOpen } = React.useContext(PopoverContext)
|
||||
return (
|
||||
<div data-testid="popover-trigger" onClick={() => setOpen(!open)}>
|
||||
{render}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverContent = ({
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }) => {
|
||||
const { open } = React.useContext(PopoverContext)
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div data-testid="popover-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
}
|
||||
})
|
||||
|
||||
describe('ContextVar', () => {
|
||||
const mockOptions: Props['options'] = [
|
||||
{ name: 'Variable 1', value: 'var1', type: 'string' },
|
||||
{ name: 'Variable 2', value: 'var2', type: 'number' },
|
||||
]
|
||||
|
||||
const defaultProps: Props = {
|
||||
value: 'var1',
|
||||
options: mockOptions,
|
||||
onChange: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should display query variable selector when options are provided', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show selected variable with proper formatting when value is provided', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByText('var1'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('{{'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('}}'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests (REQUIRED)
|
||||
describe('Props', () => {
|
||||
it('should display selected variable when value prop is provided', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps, value: 'var2' }
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
// Assert - Should display the selected value
|
||||
// Assert - Should display the selected value
|
||||
expect(screen.getByText('var2'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show placeholder text when no value is selected', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
value: undefined,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
// Assert - Should show placeholder instead of variable
|
||||
expect(screen.queryByText('var1')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'),
|
||||
)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display custom tip message when notSelectedVarTip is provided', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
value: undefined,
|
||||
notSelectedVarTip: 'Select a variable',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByText('Select a variable'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom className to VarPicker when provided', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
className: 'custom-class',
|
||||
}
|
||||
|
||||
// Act
|
||||
const { container } = render(<ContextVar {...props} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(container.querySelector('.custom-class'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange when user selects a different variable', async () => {
|
||||
// Arrange
|
||||
const onChange = vi.fn()
|
||||
const props = { ...defaultProps, onChange }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
const varPickerTrigger = screen.getAllByTestId('popover-trigger').at(-1)!
|
||||
|
||||
await user.click(varPickerTrigger!)
|
||||
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
|
||||
|
||||
// Select a different option
|
||||
await user.click(screen.getByText('var2'))
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith('var2')
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle dropdown when clicking the trigger button', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
const varPickerTrigger = screen.getAllByTestId('popover-trigger').at(-1)!
|
||||
|
||||
// Open dropdown
|
||||
await user.click(varPickerTrigger!)
|
||||
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
|
||||
|
||||
// Close dropdown
|
||||
await user.click(varPickerTrigger!)
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases (REQUIRED)
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined value gracefully', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
value: undefined,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title'))!.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'),
|
||||
)!.toBeInTheDocument()
|
||||
expect(screen.queryByText('var1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty options array', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
options: [],
|
||||
value: undefined,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title'))!.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'),
|
||||
)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null value without crashing', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
value: undefined,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title'))!.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'),
|
||||
)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle options with different data types', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
options: [
|
||||
{ name: 'String Var', value: 'strVar', type: 'string' },
|
||||
{ name: 'Number Var', value: '42', type: 'number' },
|
||||
{ name: 'Boolean Var', value: 'true', type: 'boolean' },
|
||||
],
|
||||
value: 'strVar',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByText('strVar'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('{{'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('}}'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render variable names with special characters safely', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
options: [
|
||||
{ name: 'Variable with & < > " \' characters', value: 'specialVar', type: 'string' },
|
||||
],
|
||||
value: 'specialVar',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByText('specialVar'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,507 +1,82 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Props } from '../var-picker'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import VarPicker from '../var-picker'
|
||||
|
||||
// Mock external dependencies only
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => '/test',
|
||||
}))
|
||||
|
||||
type PopoverProps = {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
type PopoverTriggerProps = React.HTMLAttributes<HTMLElement> & {
|
||||
children?: React.ReactNode
|
||||
asChild?: boolean
|
||||
}
|
||||
type PopoverContentProps = React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', () => {
|
||||
const PopoverContext = React.createContext({
|
||||
open: false,
|
||||
onOpenChange: undefined as ((open: boolean) => void) | undefined,
|
||||
})
|
||||
|
||||
const Popover = ({ children, open, onOpenChange }: PopoverProps) => {
|
||||
return (
|
||||
<PopoverContext.Provider value={{ open: !!open, onOpenChange }}>
|
||||
<div data-testid="popover">{children}</div>
|
||||
</PopoverContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverContent = ({ children, ...props }: PopoverContentProps) => {
|
||||
const { open } = React.useContext(PopoverContext)
|
||||
if (!open) return null
|
||||
return (
|
||||
<div data-testid="popover-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverTrigger = ({
|
||||
children,
|
||||
asChild,
|
||||
render,
|
||||
...props
|
||||
}: PopoverTriggerProps & { render?: React.ReactNode }) => {
|
||||
const { open, onOpenChange } = React.useContext(PopoverContext)
|
||||
const content = render ?? children
|
||||
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
|
||||
props.onClick?.(e)
|
||||
if (!props.onClick) onOpenChange?.(!open)
|
||||
}
|
||||
|
||||
if (React.isValidElement(content)) {
|
||||
return React.cloneElement(content, {
|
||||
...props,
|
||||
onClick: handleClick,
|
||||
'data-testid': 'popover-trigger',
|
||||
} as React.HTMLAttributes<HTMLElement>)
|
||||
}
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children, {
|
||||
...props,
|
||||
onClick: handleClick,
|
||||
'data-testid': 'popover-trigger',
|
||||
} as React.HTMLAttributes<HTMLElement>)
|
||||
}
|
||||
return (
|
||||
<div data-testid="popover-trigger" {...props} onClick={handleClick}>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Popover: ({
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => <PopoverContext value={{ open: !!open, onOpenChange }}>{children}</PopoverContext>,
|
||||
PopoverTrigger: ({ render }: { render?: ReactNode }) => {
|
||||
const { open, onOpenChange } = React.use(PopoverContext)
|
||||
return (
|
||||
<button type="button" aria-label="choose variable" onClick={() => onOpenChange?.(!open)}>
|
||||
{render}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
PopoverContent: ({ children }: { children: ReactNode }) => {
|
||||
const { open } = React.use(PopoverContext)
|
||||
return open ? (
|
||||
<div role="dialog" aria-label="variable options">
|
||||
{children}
|
||||
</div>
|
||||
) : null
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const options: Props['options'] = [
|
||||
{ name: 'Variable 1', value: 'var1', type: 'string' },
|
||||
{ name: 'Variable 2', value: 'var2', type: 'number' },
|
||||
]
|
||||
|
||||
describe('VarPicker', () => {
|
||||
const mockOptions: Props['options'] = [
|
||||
{ name: 'Variable 1', value: 'var1', type: 'string' },
|
||||
{ name: 'Variable 2', value: 'var2', type: 'number' },
|
||||
{ name: 'Variable 3', value: 'var3', type: 'boolean' },
|
||||
]
|
||||
|
||||
const defaultProps: Props = {
|
||||
value: 'var1',
|
||||
options: mockOptions,
|
||||
onChange: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
it('shows the selected variable', () => {
|
||||
render(<VarPicker value="var1" options={options} onChange={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: 'choose variable' })).toHaveTextContent('var1')
|
||||
})
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should render variable picker with dropdown trigger', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByTestId('popover-trigger'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('var1'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display selected variable with type icon when value is provided', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByText('var1'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('{{'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('}}'))!.toBeInTheDocument()
|
||||
// IconTypeIcon should be rendered (check for svg icon)
|
||||
// IconTypeIcon should be rendered (check for svg icon)
|
||||
expect(document.querySelector('svg'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show placeholder text when no value is selected', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
value: undefined,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.queryByText('var1')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'),
|
||||
)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display custom tip message when notSelectedVarTip is provided', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
value: undefined,
|
||||
notSelectedVarTip: 'Select a variable',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByText('Select a variable'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dropdown indicator icon', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert - Trigger should be present
|
||||
// Assert - Trigger should be present
|
||||
expect(screen.getByTestId('popover-trigger'))!.toBeInTheDocument()
|
||||
})
|
||||
it('shows the configured empty-selection message', () => {
|
||||
render(
|
||||
<VarPicker
|
||||
value={undefined}
|
||||
options={options}
|
||||
onChange={vi.fn()}
|
||||
notSelectedVarTip="Choose the query variable"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Choose the query variable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Props tests (REQUIRED)
|
||||
describe('Props', () => {
|
||||
it('should apply custom className to wrapper', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
className: 'custom-class',
|
||||
}
|
||||
it('selects a variable and closes the options', async () => {
|
||||
const onChange = vi.fn()
|
||||
render(<VarPicker value="var1" options={options} onChange={onChange} />)
|
||||
|
||||
// Act
|
||||
const { container } = render(<VarPicker {...props} />)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'choose variable' }))
|
||||
await userEvent.click(screen.getByText('var2'))
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(container.querySelector('.custom-class'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom triggerClassName to trigger button', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
triggerClassName: 'custom-trigger-class',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByTestId('popover-trigger'))!.toHaveClass('custom-trigger-class')
|
||||
})
|
||||
|
||||
it('should display selected value with proper formatting', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
value: 'customVar',
|
||||
options: [{ name: 'Custom Variable', value: 'customVar', type: 'string' }],
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByText('customVar'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('{{'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('}}'))!.toBeInTheDocument()
|
||||
})
|
||||
expect(onChange).toHaveBeenCalledWith('var2')
|
||||
expect(screen.queryByRole('dialog', { name: 'variable options' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should open dropdown when clicking the trigger button', async () => {
|
||||
// Arrange
|
||||
const onChange = vi.fn()
|
||||
const props = { ...defaultProps, onChange }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
await user.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange and close dropdown when selecting an option', async () => {
|
||||
// Arrange
|
||||
const onChange = vi.fn()
|
||||
const props = { ...defaultProps, onChange }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Open dropdown
|
||||
await user.click(screen.getByTestId('popover-trigger'))
|
||||
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
|
||||
|
||||
// Select a different option
|
||||
const options = screen.getAllByText('var2')
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
await user.click(options[0]!)
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith('var2')
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle dropdown when clicking trigger button multiple times', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
const trigger = screen.getByTestId('popover-trigger')
|
||||
|
||||
// Open dropdown
|
||||
await user.click(trigger)
|
||||
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
|
||||
|
||||
// Close dropdown
|
||||
await user.click(trigger)
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// State Management
|
||||
describe('State Management', () => {
|
||||
it('should initialize with closed dropdown', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle dropdown state on trigger click', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
const trigger = screen.getByTestId('popover-trigger')
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
|
||||
// Open dropdown
|
||||
await user.click(trigger)
|
||||
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
|
||||
|
||||
// Close dropdown
|
||||
await user.click(trigger)
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve selected value when dropdown is closed without selection', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Open and close dropdown without selecting anything
|
||||
const trigger = screen.getByTestId('popover-trigger')
|
||||
await user.click(trigger)
|
||||
await user.click(trigger)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByText('var1'))!.toBeInTheDocument() // Original value still displayed
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases (REQUIRED)
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined value gracefully', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
value: undefined,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(
|
||||
screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'),
|
||||
)!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover-trigger'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty options array', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
options: [],
|
||||
value: undefined,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByTestId('popover-trigger'))!.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'),
|
||||
)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null value without crashing', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
value: undefined,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(
|
||||
screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'),
|
||||
)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle variable names with special characters safely', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
options: [
|
||||
{ name: 'Variable with & < > " \' characters', value: 'specialVar', type: 'string' },
|
||||
],
|
||||
value: 'specialVar',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByText('specialVar'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle long variable names', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
options: [
|
||||
{
|
||||
name: 'A very long variable name that should be truncated',
|
||||
value: 'longVar',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
value: 'longVar',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByText('longVar'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover-trigger'))!.toBeInTheDocument()
|
||||
})
|
||||
it('shows the empty state when no variables are available', async () => {
|
||||
render(<VarPicker value={undefined} options={[]} onChange={vi.fn()} />)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'choose variable' }))
|
||||
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.noVar')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -630,7 +630,6 @@ describe('ChatItem', () => {
|
||||
payload: { message: 'Hello', files: [] },
|
||||
})
|
||||
|
||||
// Should still call handleSend without crashing
|
||||
expect(handleSend).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useDebugWithMultipleModelContext } from '../context'
|
||||
import { DebugWithMultipleModelContextProvider } from '../context-provider'
|
||||
|
||||
const ContextConsumer = () => {
|
||||
const value = useDebugWithMultipleModelContext()
|
||||
return (
|
||||
<div>
|
||||
<div>{value.multipleModelConfigs.length}</div>
|
||||
<button onClick={() => value.onMultipleModelConfigsChange(true, value.multipleModelConfigs)}>
|
||||
change-multiple
|
||||
</button>
|
||||
<button onClick={() => value.onDebugWithMultipleModelChange(value.multipleModelConfigs[0]!)}>
|
||||
change-single
|
||||
</button>
|
||||
<div>{String(value.checkCanSend?.())}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('DebugWithMultipleModelContextProvider', () => {
|
||||
it('should expose the provided context value to descendants', () => {
|
||||
const onMultipleModelConfigsChange = vi.fn()
|
||||
const onDebugWithMultipleModelChange = vi.fn()
|
||||
const checkCanSend = vi.fn(() => true)
|
||||
const multipleModelConfigs = [{ model: 'gpt-4o' }] as unknown as []
|
||||
|
||||
render(
|
||||
<DebugWithMultipleModelContextProvider
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
onMultipleModelConfigsChange={onMultipleModelConfigsChange}
|
||||
onDebugWithMultipleModelChange={onDebugWithMultipleModelChange}
|
||||
checkCanSend={checkCanSend}
|
||||
>
|
||||
<ContextConsumer />
|
||||
</DebugWithMultipleModelContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('1'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('true'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,229 +0,0 @@
|
||||
import type { ModelAndParameter } from '../../types'
|
||||
import type { DebugWithMultipleModelContextType } from '../context'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useDebugWithMultipleModelContext } from '../context'
|
||||
import { DebugWithMultipleModelContextProvider } from '../context-provider'
|
||||
|
||||
const createModelAndParameter = (
|
||||
overrides: Partial<ModelAndParameter> = {},
|
||||
): ModelAndParameter => ({
|
||||
id: 'model-1',
|
||||
model: 'gpt-3.5-turbo',
|
||||
provider: 'openai',
|
||||
parameters: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const TestConsumer = () => {
|
||||
const context = useDebugWithMultipleModelContext()
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="configs-count">{context.multipleModelConfigs.length}</span>
|
||||
<span data-testid="has-check-can-send">{context.checkCanSend ? 'yes' : 'no'}</span>
|
||||
<button
|
||||
data-testid="call-on-change"
|
||||
onClick={() => context.onMultipleModelConfigsChange(true, [])}
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
<button
|
||||
data-testid="call-on-debug-change"
|
||||
onClick={() => context.onDebugWithMultipleModelChange(createModelAndParameter())}
|
||||
>
|
||||
Debug Change
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('DebugWithMultipleModelContext', () => {
|
||||
describe('useDebugWithMultipleModelContext', () => {
|
||||
it('should return default values when used outside provider', () => {
|
||||
render(<TestConsumer />)
|
||||
|
||||
expect(screen.getByTestId('configs-count')).toHaveTextContent('0')
|
||||
expect(screen.getByTestId('has-check-can-send')).toHaveTextContent('no')
|
||||
})
|
||||
|
||||
it('should return default noop functions that do not throw', () => {
|
||||
render(<TestConsumer />)
|
||||
|
||||
// These should not throw when called
|
||||
expect(() => {
|
||||
screen.getByTestId('call-on-change').click()
|
||||
}).not.toThrow()
|
||||
|
||||
expect(() => {
|
||||
screen.getByTestId('call-on-debug-change').click()
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DebugWithMultipleModelContextProvider', () => {
|
||||
it('should provide multipleModelConfigs to children', () => {
|
||||
const multipleModelConfigs = [
|
||||
createModelAndParameter({ id: 'model-1' }),
|
||||
createModelAndParameter({ id: 'model-2' }),
|
||||
]
|
||||
|
||||
render(
|
||||
<DebugWithMultipleModelContextProvider
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
onMultipleModelConfigsChange={vi.fn()}
|
||||
onDebugWithMultipleModelChange={vi.fn()}
|
||||
>
|
||||
<TestConsumer />
|
||||
</DebugWithMultipleModelContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('configs-count')).toHaveTextContent('2')
|
||||
})
|
||||
|
||||
it('should provide checkCanSend function to children', () => {
|
||||
const checkCanSend = vi.fn(() => true)
|
||||
|
||||
render(
|
||||
<DebugWithMultipleModelContextProvider
|
||||
multipleModelConfigs={[]}
|
||||
onMultipleModelConfigsChange={vi.fn()}
|
||||
onDebugWithMultipleModelChange={vi.fn()}
|
||||
checkCanSend={checkCanSend}
|
||||
>
|
||||
<TestConsumer />
|
||||
</DebugWithMultipleModelContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('has-check-can-send')).toHaveTextContent('yes')
|
||||
})
|
||||
|
||||
it('should call onMultipleModelConfigsChange when invoked from context', () => {
|
||||
const onMultipleModelConfigsChange = vi.fn()
|
||||
|
||||
render(
|
||||
<DebugWithMultipleModelContextProvider
|
||||
multipleModelConfigs={[]}
|
||||
onMultipleModelConfigsChange={onMultipleModelConfigsChange}
|
||||
onDebugWithMultipleModelChange={vi.fn()}
|
||||
>
|
||||
<TestConsumer />
|
||||
</DebugWithMultipleModelContextProvider>,
|
||||
)
|
||||
|
||||
screen.getByTestId('call-on-change').click()
|
||||
|
||||
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(true, [])
|
||||
})
|
||||
|
||||
it('should call onDebugWithMultipleModelChange when invoked from context', () => {
|
||||
const onDebugWithMultipleModelChange = vi.fn()
|
||||
|
||||
render(
|
||||
<DebugWithMultipleModelContextProvider
|
||||
multipleModelConfigs={[]}
|
||||
onMultipleModelConfigsChange={vi.fn()}
|
||||
onDebugWithMultipleModelChange={onDebugWithMultipleModelChange}
|
||||
>
|
||||
<TestConsumer />
|
||||
</DebugWithMultipleModelContextProvider>,
|
||||
)
|
||||
|
||||
screen.getByTestId('call-on-debug-change').click()
|
||||
|
||||
expect(onDebugWithMultipleModelChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'model-1' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle undefined checkCanSend', () => {
|
||||
render(
|
||||
<DebugWithMultipleModelContextProvider
|
||||
multipleModelConfigs={[]}
|
||||
onMultipleModelConfigsChange={vi.fn()}
|
||||
onDebugWithMultipleModelChange={vi.fn()}
|
||||
checkCanSend={undefined}
|
||||
>
|
||||
<TestConsumer />
|
||||
</DebugWithMultipleModelContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('has-check-can-send')).toHaveTextContent('no')
|
||||
})
|
||||
|
||||
it('should render children correctly', () => {
|
||||
render(
|
||||
<DebugWithMultipleModelContextProvider
|
||||
multipleModelConfigs={[]}
|
||||
onMultipleModelConfigsChange={vi.fn()}
|
||||
onDebugWithMultipleModelChange={vi.fn()}
|
||||
>
|
||||
<div data-testid="child-element">Child Content</div>
|
||||
</DebugWithMultipleModelContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('child-element')).toHaveTextContent('Child Content')
|
||||
})
|
||||
|
||||
it('should update context when props change', () => {
|
||||
const { rerender } = render(
|
||||
<DebugWithMultipleModelContextProvider
|
||||
multipleModelConfigs={[createModelAndParameter()]}
|
||||
onMultipleModelConfigsChange={vi.fn()}
|
||||
onDebugWithMultipleModelChange={vi.fn()}
|
||||
>
|
||||
<TestConsumer />
|
||||
</DebugWithMultipleModelContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('configs-count')).toHaveTextContent('1')
|
||||
|
||||
rerender(
|
||||
<DebugWithMultipleModelContextProvider
|
||||
multipleModelConfigs={[
|
||||
createModelAndParameter(),
|
||||
createModelAndParameter({ id: 'model-2' }),
|
||||
]}
|
||||
onMultipleModelConfigsChange={vi.fn()}
|
||||
onDebugWithMultipleModelChange={vi.fn()}
|
||||
>
|
||||
<TestConsumer />
|
||||
</DebugWithMultipleModelContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('configs-count')).toHaveTextContent('2')
|
||||
})
|
||||
|
||||
it('should pass all context values correctly', () => {
|
||||
const contextValues: DebugWithMultipleModelContextType = {
|
||||
multipleModelConfigs: [createModelAndParameter()],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
checkCanSend: () => true,
|
||||
}
|
||||
|
||||
const FullTestConsumer = () => {
|
||||
const context = useDebugWithMultipleModelContext()
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="configs">{JSON.stringify(context.multipleModelConfigs)}</span>
|
||||
<span data-testid="has-on-change">{typeof context.onMultipleModelConfigsChange}</span>
|
||||
<span data-testid="has-on-debug-change">
|
||||
{typeof context.onDebugWithMultipleModelChange}
|
||||
</span>
|
||||
<span data-testid="has-check">{typeof context.checkCanSend}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<DebugWithMultipleModelContextProvider {...contextValues}>
|
||||
<FullTestConsumer />
|
||||
</DebugWithMultipleModelContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('configs')).toHaveTextContent('model-1')
|
||||
expect(screen.getByTestId('has-on-change')).toHaveTextContent('function')
|
||||
expect(screen.getByTestId('has-on-debug-change')).toHaveTextContent('function')
|
||||
expect(screen.getByTestId('has-check')).toHaveTextContent('function')
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user