mirror of https://github.com/langgenius/dify.git
573 lines
20 KiB
TypeScript
573 lines
20 KiB
TypeScript
import type { FilterState } from '../filter-management'
|
|
import type { SystemFeatures } from '@/types/feature'
|
|
import { act, fireEvent, render, screen } from '@testing-library/react'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { defaultSystemFeatures, InstallationScope } from '@/types/feature'
|
|
|
|
// ==================== Imports (after mocks) ====================
|
|
|
|
import Empty from './index'
|
|
|
|
// ==================== Mock Setup ====================
|
|
|
|
// Use vi.hoisted to define ALL mock state and functions
|
|
const {
|
|
mockSetActiveTab,
|
|
mockUseInstalledPluginList,
|
|
mockState,
|
|
} = vi.hoisted(() => {
|
|
const state = {
|
|
filters: {
|
|
categories: [] as string[],
|
|
tags: [] as string[],
|
|
searchQuery: '',
|
|
} as FilterState,
|
|
systemFeatures: {
|
|
enable_marketplace: true,
|
|
plugin_installation_permission: {
|
|
plugin_installation_scope: 'all' as const,
|
|
restrict_to_marketplace_only: false,
|
|
},
|
|
} as Partial<SystemFeatures>,
|
|
pluginList: { plugins: [] as Array<{ id: string }> } as { plugins: Array<{ id: string }> } | undefined,
|
|
}
|
|
return {
|
|
mockSetActiveTab: vi.fn(),
|
|
mockUseInstalledPluginList: vi.fn(() => ({ data: state.pluginList })),
|
|
mockState: state,
|
|
}
|
|
})
|
|
|
|
// Mock plugin page context
|
|
vi.mock('../context', () => ({
|
|
usePluginPageContext: (selector: (value: any) => any) => {
|
|
const contextValue = {
|
|
filters: mockState.filters,
|
|
setActiveTab: mockSetActiveTab,
|
|
}
|
|
return selector(contextValue)
|
|
},
|
|
}))
|
|
|
|
// Mock global public store (Zustand store)
|
|
vi.mock('@/context/global-public-context', () => ({
|
|
useGlobalPublicStore: (selector: (state: any) => any) => {
|
|
return selector({
|
|
systemFeatures: {
|
|
...defaultSystemFeatures,
|
|
...mockState.systemFeatures,
|
|
},
|
|
})
|
|
},
|
|
}))
|
|
|
|
// Mock useInstalledPluginList hook
|
|
vi.mock('@/service/use-plugins', () => ({
|
|
useInstalledPluginList: () => mockUseInstalledPluginList(),
|
|
}))
|
|
|
|
// Mock InstallFromGitHub component
|
|
vi.mock('@/app/components/plugins/install-plugin/install-from-github', () => ({
|
|
default: ({ onClose }: { onSuccess: () => void, onClose: () => void }) => (
|
|
<div data-testid="install-from-github-modal">
|
|
<button data-testid="github-modal-close" onClick={onClose}>Close</button>
|
|
<button data-testid="github-modal-success">Success</button>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
// Mock InstallFromLocalPackage component
|
|
vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', () => ({
|
|
default: ({ file, onClose }: { file: File, onSuccess: () => void, onClose: () => void }) => (
|
|
<div data-testid="install-from-local-modal" data-file-name={file.name}>
|
|
<button data-testid="local-modal-close" onClick={onClose}>Close</button>
|
|
<button data-testid="local-modal-success">Success</button>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
// Mock Line component
|
|
vi.mock('../../marketplace/empty/line', () => ({
|
|
default: ({ className }: { className?: string }) => <div data-testid="line-component" className={className} />,
|
|
}))
|
|
|
|
// ==================== Test Utilities ====================
|
|
|
|
const resetMockState = () => {
|
|
mockState.filters = { categories: [], tags: [], searchQuery: '' }
|
|
mockState.systemFeatures = {
|
|
enable_marketplace: true,
|
|
plugin_installation_permission: {
|
|
plugin_installation_scope: InstallationScope.ALL,
|
|
restrict_to_marketplace_only: false,
|
|
},
|
|
}
|
|
mockState.pluginList = { plugins: [] }
|
|
mockUseInstalledPluginList.mockReturnValue({ data: mockState.pluginList })
|
|
}
|
|
|
|
const setMockFilters = (filters: Partial<FilterState>) => {
|
|
mockState.filters = { ...mockState.filters, ...filters }
|
|
}
|
|
|
|
const setMockSystemFeatures = (features: Partial<SystemFeatures>) => {
|
|
mockState.systemFeatures = { ...mockState.systemFeatures, ...features }
|
|
}
|
|
|
|
const setMockPluginList = (list: { plugins: Array<{ id: string }> } | undefined) => {
|
|
mockState.pluginList = list
|
|
mockUseInstalledPluginList.mockReturnValue({ data: list })
|
|
}
|
|
|
|
const createMockFile = (name: string, type = 'application/octet-stream'): File => {
|
|
return new File(['test'], name, { type })
|
|
}
|
|
|
|
// Helper to wait for useEffect to complete (single tick)
|
|
const flushEffects = async () => {
|
|
await act(async () => {})
|
|
}
|
|
|
|
// ==================== Tests ====================
|
|
|
|
describe('Empty Component', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
resetMockState()
|
|
})
|
|
|
|
// ==================== Rendering Tests ====================
|
|
describe('Rendering', () => {
|
|
it('should render basic structure correctly', async () => {
|
|
// Arrange & Act
|
|
const { container } = render(<Empty />)
|
|
await flushEffects()
|
|
|
|
// Assert - file input
|
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
|
expect(fileInput).toBeInTheDocument()
|
|
expect(fileInput.style.display).toBe('none')
|
|
expect(fileInput.accept).toBe('.difypkg,.difybndl')
|
|
|
|
// Assert - skeleton cards
|
|
const skeletonCards = container.querySelectorAll('.rounded-xl.bg-components-card-bg')
|
|
expect(skeletonCards).toHaveLength(20)
|
|
|
|
// Assert - group icon container
|
|
const iconContainer = document.querySelector('.size-14')
|
|
expect(iconContainer).toBeInTheDocument()
|
|
|
|
// Assert - line components
|
|
const lines = screen.getAllByTestId('line-component')
|
|
expect(lines).toHaveLength(4)
|
|
})
|
|
})
|
|
|
|
// ==================== Text Display Tests (useMemo) ====================
|
|
describe('Text Display (useMemo)', () => {
|
|
it('should display "noInstalled" text when plugin list is empty', async () => {
|
|
// Arrange
|
|
setMockPluginList({ plugins: [] })
|
|
|
|
// Act
|
|
render(<Empty />)
|
|
await flushEffects()
|
|
|
|
// Assert
|
|
expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should display "notFound" text when filters are active with plugins', async () => {
|
|
// Arrange
|
|
setMockPluginList({ plugins: [{ id: 'plugin-1' }] })
|
|
|
|
// Test categories filter
|
|
setMockFilters({ categories: ['model'] })
|
|
const { rerender } = render(<Empty />)
|
|
await flushEffects()
|
|
expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument()
|
|
|
|
// Test tags filter
|
|
setMockFilters({ categories: [], tags: ['tag1'] })
|
|
rerender(<Empty />)
|
|
await flushEffects()
|
|
expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument()
|
|
|
|
// Test searchQuery filter
|
|
setMockFilters({ tags: [], searchQuery: 'test query' })
|
|
rerender(<Empty />)
|
|
await flushEffects()
|
|
expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should prioritize "noInstalled" over "notFound" when no plugins exist', async () => {
|
|
// Arrange
|
|
setMockFilters({ categories: ['model'], searchQuery: 'test' })
|
|
setMockPluginList({ plugins: [] })
|
|
|
|
// Act
|
|
render(<Empty />)
|
|
await flushEffects()
|
|
|
|
// Assert
|
|
expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
// ==================== Install Methods Tests (useEffect) ====================
|
|
describe('Install Methods (useEffect)', () => {
|
|
it('should render all three install methods when marketplace enabled and not restricted', async () => {
|
|
// Arrange
|
|
setMockSystemFeatures({
|
|
enable_marketplace: true,
|
|
plugin_installation_permission: {
|
|
plugin_installation_scope: InstallationScope.ALL,
|
|
restrict_to_marketplace_only: false,
|
|
},
|
|
})
|
|
|
|
// Act
|
|
render(<Empty />)
|
|
await flushEffects()
|
|
|
|
// Assert
|
|
const buttons = screen.getAllByRole('button')
|
|
expect(buttons).toHaveLength(3)
|
|
expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument()
|
|
expect(screen.getByText('plugin.source.github')).toBeInTheDocument()
|
|
expect(screen.getByText('plugin.source.local')).toBeInTheDocument()
|
|
|
|
// Verify button order
|
|
const buttonTexts = buttons.map(btn => btn.textContent)
|
|
expect(buttonTexts[0]).toContain('plugin.source.marketplace')
|
|
expect(buttonTexts[1]).toContain('plugin.source.github')
|
|
expect(buttonTexts[2]).toContain('plugin.source.local')
|
|
})
|
|
|
|
it('should render only marketplace method when restricted to marketplace only', async () => {
|
|
// Arrange
|
|
setMockSystemFeatures({
|
|
enable_marketplace: true,
|
|
plugin_installation_permission: {
|
|
plugin_installation_scope: InstallationScope.ALL,
|
|
restrict_to_marketplace_only: true,
|
|
},
|
|
})
|
|
|
|
// Act
|
|
render(<Empty />)
|
|
await flushEffects()
|
|
|
|
// Assert
|
|
const buttons = screen.getAllByRole('button')
|
|
expect(buttons).toHaveLength(1)
|
|
expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument()
|
|
expect(screen.queryByText('plugin.source.github')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('plugin.source.local')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('should render github and local methods when marketplace is disabled', async () => {
|
|
// Arrange
|
|
setMockSystemFeatures({
|
|
enable_marketplace: false,
|
|
plugin_installation_permission: {
|
|
plugin_installation_scope: InstallationScope.ALL,
|
|
restrict_to_marketplace_only: false,
|
|
},
|
|
})
|
|
|
|
// Act
|
|
render(<Empty />)
|
|
await flushEffects()
|
|
|
|
// Assert
|
|
const buttons = screen.getAllByRole('button')
|
|
expect(buttons).toHaveLength(2)
|
|
expect(screen.queryByText('plugin.source.marketplace')).not.toBeInTheDocument()
|
|
expect(screen.getByText('plugin.source.github')).toBeInTheDocument()
|
|
expect(screen.getByText('plugin.source.local')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render no methods when marketplace disabled and restricted', async () => {
|
|
// Arrange
|
|
setMockSystemFeatures({
|
|
enable_marketplace: false,
|
|
plugin_installation_permission: {
|
|
plugin_installation_scope: InstallationScope.ALL,
|
|
restrict_to_marketplace_only: true,
|
|
},
|
|
})
|
|
|
|
// Act
|
|
render(<Empty />)
|
|
await flushEffects()
|
|
|
|
// Assert
|
|
const buttons = screen.queryAllByRole('button')
|
|
expect(buttons).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
// ==================== User Interactions Tests ====================
|
|
describe('User Interactions', () => {
|
|
it('should call setActiveTab with "discover" when marketplace button is clicked', async () => {
|
|
// Arrange
|
|
render(<Empty />)
|
|
await flushEffects()
|
|
|
|
// Act
|
|
fireEvent.click(screen.getByText('plugin.source.marketplace'))
|
|
|
|
// Assert
|
|
expect(mockSetActiveTab).toHaveBeenCalledWith('discover')
|
|
})
|
|
|
|
it('should open and close GitHub modal correctly', async () => {
|
|
// Arrange
|
|
render(<Empty />)
|
|
await flushEffects()
|
|
|
|
// Assert - initially no modal
|
|
expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument()
|
|
|
|
// Act - open modal
|
|
fireEvent.click(screen.getByText('plugin.source.github'))
|
|
|
|
// Assert - modal is open
|
|
expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument()
|
|
|
|
// Act - close modal
|
|
fireEvent.click(screen.getByTestId('github-modal-close'))
|
|
|
|
// Assert - modal is closed
|
|
expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('should trigger file input click when local button is clicked', async () => {
|
|
// Arrange
|
|
render(<Empty />)
|
|
await flushEffects()
|
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
|
const clickSpy = vi.spyOn(fileInput, 'click')
|
|
|
|
// Act
|
|
fireEvent.click(screen.getByText('plugin.source.local'))
|
|
|
|
// Assert
|
|
expect(clickSpy).toHaveBeenCalled()
|
|
})
|
|
|
|
it('should open and close local modal when file is selected', async () => {
|
|
// Arrange
|
|
render(<Empty />)
|
|
await flushEffects()
|
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
|
const mockFile = createMockFile('test-plugin.difypkg')
|
|
|
|
// Assert - initially no modal
|
|
expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
|
|
|
|
// Act - select file
|
|
Object.defineProperty(fileInput, 'files', { value: [mockFile], writable: true })
|
|
fireEvent.change(fileInput)
|
|
|
|
// Assert - modal is open with correct file
|
|
expect(screen.getByTestId('install-from-local-modal')).toBeInTheDocument()
|
|
expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'test-plugin.difypkg')
|
|
|
|
// Act - close modal
|
|
fireEvent.click(screen.getByTestId('local-modal-close'))
|
|
|
|
// Assert - modal is closed
|
|
expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('should not open local modal when no file is selected', async () => {
|
|
// Arrange
|
|
render(<Empty />)
|
|
await flushEffects()
|
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
|
|
|
// Act - trigger change with empty files
|
|
Object.defineProperty(fileInput, 'files', { value: [], writable: true })
|
|
fireEvent.change(fileInput)
|
|
|
|
// Assert
|
|
expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
// ==================== State Management Tests ====================
|
|
describe('State Management', () => {
|
|
it('should maintain modal state correctly and allow reopening', async () => {
|
|
// Arrange
|
|
render(<Empty />)
|
|
await flushEffects()
|
|
|
|
// Act - Open, close, and reopen GitHub modal
|
|
fireEvent.click(screen.getByText('plugin.source.github'))
|
|
expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByTestId('github-modal-close'))
|
|
expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByText('plugin.source.github'))
|
|
expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should update selectedFile state when file is selected', async () => {
|
|
// Arrange
|
|
render(<Empty />)
|
|
await flushEffects()
|
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
|
|
|
// Act - select .difypkg file
|
|
Object.defineProperty(fileInput, 'files', { value: [createMockFile('my-plugin.difypkg')], writable: true })
|
|
fireEvent.change(fileInput)
|
|
expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'my-plugin.difypkg')
|
|
|
|
// Close and select .difybndl file
|
|
fireEvent.click(screen.getByTestId('local-modal-close'))
|
|
Object.defineProperty(fileInput, 'files', { value: [createMockFile('test-bundle.difybndl')], writable: true })
|
|
fireEvent.change(fileInput)
|
|
expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'test-bundle.difybndl')
|
|
})
|
|
})
|
|
|
|
// ==================== Side Effects Tests ====================
|
|
describe('Side Effects', () => {
|
|
it('should update installMethods when system features change', async () => {
|
|
// Arrange - Start with marketplace enabled
|
|
setMockSystemFeatures({
|
|
enable_marketplace: true,
|
|
plugin_installation_permission: {
|
|
plugin_installation_scope: InstallationScope.ALL,
|
|
restrict_to_marketplace_only: false,
|
|
},
|
|
})
|
|
|
|
const { rerender } = render(<Empty />)
|
|
await flushEffects()
|
|
|
|
// Assert initial state - 3 methods
|
|
expect(screen.getAllByRole('button')).toHaveLength(3)
|
|
|
|
// Act - Restrict to marketplace only
|
|
setMockSystemFeatures({
|
|
enable_marketplace: true,
|
|
plugin_installation_permission: {
|
|
plugin_installation_scope: InstallationScope.ALL,
|
|
restrict_to_marketplace_only: true,
|
|
},
|
|
})
|
|
rerender(<Empty />)
|
|
await flushEffects()
|
|
|
|
// Assert - Only marketplace button
|
|
expect(screen.getAllByRole('button')).toHaveLength(1)
|
|
expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should update text when pluginList or filters change', async () => {
|
|
// Arrange
|
|
setMockPluginList({ plugins: [] })
|
|
const { rerender } = render(<Empty />)
|
|
await flushEffects()
|
|
|
|
// Assert initial state
|
|
expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument()
|
|
|
|
// Act - Update to have plugins with filters
|
|
setMockFilters({ categories: ['tool'] })
|
|
setMockPluginList({ plugins: [{ id: 'plugin-1' }] })
|
|
rerender(<Empty />)
|
|
await flushEffects()
|
|
|
|
// Assert
|
|
expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
// ==================== Edge Cases ====================
|
|
describe('Edge Cases', () => {
|
|
it('should handle undefined/null plugin data gracefully', () => {
|
|
// Test undefined plugin list
|
|
setMockPluginList(undefined)
|
|
expect(() => render(<Empty />)).not.toThrow()
|
|
|
|
// Test null plugins array
|
|
mockUseInstalledPluginList.mockReturnValue({ data: { plugins: null as any } })
|
|
expect(() => render(<Empty />)).not.toThrow()
|
|
})
|
|
|
|
it('should handle file input edge cases', async () => {
|
|
// Arrange
|
|
render(<Empty />)
|
|
await flushEffects()
|
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
|
|
|
// Test undefined files
|
|
Object.defineProperty(fileInput, 'files', { value: undefined, writable: true })
|
|
fireEvent.change(fileInput)
|
|
expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
// ==================== React.memo Tests ====================
|
|
describe('React.memo Behavior', () => {
|
|
it('should be wrapped with React.memo and have displayName', () => {
|
|
// Assert
|
|
expect(Empty).toBeDefined()
|
|
expect((Empty as any).$$typeof?.toString()).toContain('Symbol')
|
|
expect((Empty as any).displayName || (Empty as any).type?.displayName).toBeDefined()
|
|
})
|
|
})
|
|
|
|
// ==================== Modal Callbacks Tests ====================
|
|
describe('Modal Callbacks', () => {
|
|
it('should handle modal onSuccess callbacks (noop)', async () => {
|
|
// Arrange
|
|
render(<Empty />)
|
|
await flushEffects()
|
|
|
|
// Test GitHub modal onSuccess
|
|
fireEvent.click(screen.getByText('plugin.source.github'))
|
|
fireEvent.click(screen.getByTestId('github-modal-success'))
|
|
expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument()
|
|
|
|
// Close GitHub modal and test Local modal onSuccess
|
|
fireEvent.click(screen.getByTestId('github-modal-close'))
|
|
|
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
|
Object.defineProperty(fileInput, 'files', { value: [createMockFile('test-plugin.difypkg')], writable: true })
|
|
fireEvent.change(fileInput)
|
|
|
|
fireEvent.click(screen.getByTestId('local-modal-success'))
|
|
expect(screen.getByTestId('install-from-local-modal')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
// ==================== Conditional Modal Rendering ====================
|
|
describe('Conditional Modal Rendering', () => {
|
|
it('should only render one modal at a time and require file for local modal', async () => {
|
|
// Arrange
|
|
render(<Empty />)
|
|
await flushEffects()
|
|
|
|
// Assert - no modals initially
|
|
expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument()
|
|
expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
|
|
|
|
// Open GitHub modal - only GitHub modal visible
|
|
fireEvent.click(screen.getByText('plugin.source.github'))
|
|
expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument()
|
|
expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
|
|
|
|
// Click local button - triggers file input, no modal yet (no file selected)
|
|
fireEvent.click(screen.getByText('plugin.source.local'))
|
|
// GitHub modal should still be visible, local modal requires file selection
|
|
expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|