test(web): remove low-value unit tests (#39193)

This commit is contained in:
yyh 2026-07-17 16:59:26 +08:00 committed by GitHub
parent af4b65b295
commit 5fd06fafe0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
995 changed files with 3147 additions and 99493 deletions

View File

@ -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

View File

@ -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')
})
})
})

View File

@ -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()
})
})
})

View File

@ -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(?=$|:)/),
)
})
})
})

View File

@ -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()
})
})
})

View File

@ -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()
})
})
})

View File

@ -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()
})
})
})
})

View File

@ -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(

View File

@ -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)
})
})

View File

@ -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()
})
})
})

View File

@ -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')
})
})
})

View File

@ -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)
})
})
})

View File

@ -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')
})
})
})

View File

@ -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)
})
})
})
})

View File

@ -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)
})
})
})

View File

@ -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')
})
})
})

View File

@ -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)
})
})
})

View File

@ -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.',
)
}
})
})
})
})

View File

@ -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()
})
})

View File

@ -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()
})
})

View File

@ -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`)
})
})
})

View File

@ -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])
})
})

View File

@ -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()
})
})
})

View File

@ -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)
})
})
})
})

View File

@ -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()
})
})
})

View File

@ -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()
})
})
})

View File

@ -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)
})
})
})

View File

@ -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()
})
})

View File

@ -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()
}
})
})
})

View File

@ -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')
})
})
})

View File

@ -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')
})
})

View File

@ -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')
})
})
})

View File

@ -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)
})
})
})

View File

@ -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)
})
})
})

View File

@ -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()
})
})
})

View File

@ -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')
})
})
})

View File

@ -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'])
})
})
})

View File

@ -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([])
})
})
})

View File

@ -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
})
})
})

View File

@ -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()
})
})

View File

@ -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',
)
})
})

View File

@ -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')
})
})

View File

@ -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)
})
})

View File

@ -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')
})
})

View File

@ -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()
})
})

View File

@ -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)
})
})
})

View File

@ -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()
})
})
})

View File

@ -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')
})
})
})

View File

@ -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)
})
})
})

View File

@ -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(

View File

@ -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()
})
})
})

View File

@ -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' }),
}),
)
})
})
})

View File

@ -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')
})
})

View File

@ -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')

View File

@ -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()
})
})
})

View File

@ -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')
})
})

View File

@ -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')
})
})
})

View File

@ -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()
})
})

View File

@ -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' })
})
})
})

View File

@ -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')
})
})
})

View File

@ -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', () => {

View File

@ -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>,
)
})
})
})

View File

@ -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)
})
})
})

View File

@ -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')
})
})

View File

@ -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()
})
})
})

View File

@ -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'))

View File

@ -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)
})
})

View File

@ -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)

View File

@ -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()
})
})

View File

@ -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', () => {

View File

@ -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()
})
})

View File

@ -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()
})
})

View File

@ -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)
})
})
})

View File

@ -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)
})
})

View File

@ -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()
})
})
})

View File

@ -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()
})
})
})

View File

@ -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)
})
})
})

View File

@ -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')
})
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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()
})
})
})

View File

@ -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('')
})
})

View File

@ -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()
})
})

View File

@ -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,
)
})
})

View File

@ -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)
})
})

View File

@ -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')
})
})

View File

@ -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()
})
})

View File

@ -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)')
})
})

View File

@ -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', () => {

View File

@ -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()
})
})

View File

@ -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)
})
})
})

View File

@ -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,
}),
)
})
})

View File

@ -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: {},
}),
}),
)
})
})

View File

@ -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()
})
})

View File

@ -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()
})
})

View File

@ -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()
})
})
})

View File

@ -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()
})
})

View File

@ -630,7 +630,6 @@ describe('ChatItem', () => {
payload: { message: 'Hello', files: [] },
})
// Should still call handleSend without crashing
expect(handleSend).toHaveBeenCalled()
})

View File

@ -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()
})
})

View File

@ -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