test: add unit tests for PluginPage and related components (#30908)

Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
This commit is contained in:
Coding On Star 2026-01-15 10:56:02 +08:00 committed by GitHub
parent 4955de5905
commit bdd8d5b470
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 3870 additions and 243 deletions

View File

@ -897,6 +897,58 @@ describe('Icon', () => {
const iconDiv = container.firstChild as HTMLElement
expect(iconDiv).toHaveStyle({ backgroundImage: 'url(/icon?name=test&size=large)' })
})
it('should not render status indicators when src is object with installed=true', () => {
render(<Icon src={{ content: '🎉', background: '#fff' }} installed={true} />)
// Status indicators should not render for object src
expect(screen.queryByTestId('ri-check-line')).not.toBeInTheDocument()
})
it('should not render status indicators when src is object with installFailed=true', () => {
render(<Icon src={{ content: '🎉', background: '#fff' }} installFailed={true} />)
// Status indicators should not render for object src
expect(screen.queryByTestId('ri-close-line')).not.toBeInTheDocument()
})
it('should render object src with all size variants', () => {
const sizes: Array<'xs' | 'tiny' | 'small' | 'medium' | 'large'> = ['xs', 'tiny', 'small', 'medium', 'large']
sizes.forEach((size) => {
const { unmount } = render(<Icon src={{ content: '🔗', background: '#fff' }} size={size} />)
expect(screen.getByTestId('app-icon')).toHaveAttribute('data-size', size)
unmount()
})
})
it('should render object src with custom className', () => {
const { container } = render(
<Icon src={{ content: '🎉', background: '#fff' }} className="custom-object-icon" />,
)
expect(container.querySelector('.custom-object-icon')).toBeInTheDocument()
})
it('should pass correct props to AppIcon for object src', () => {
render(<Icon src={{ content: '😀', background: '#123456' }} />)
const appIcon = screen.getByTestId('app-icon')
expect(appIcon).toHaveAttribute('data-icon', '😀')
expect(appIcon).toHaveAttribute('data-background', '#123456')
expect(appIcon).toHaveAttribute('data-icon-type', 'emoji')
})
it('should render inner icon only when shouldUseMcpIcon returns true', () => {
// Test with MCP icon content
const { unmount } = render(<Icon src={{ content: '🔗', background: '#fff' }} />)
expect(screen.getByTestId('inner-icon')).toBeInTheDocument()
unmount()
// Test without MCP icon content
render(<Icon src={{ content: '🎉', background: '#fff' }} />)
expect(screen.queryByTestId('inner-icon')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,123 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import mocks
import { useGlobalPublicStore } from '@/context/global-public-context'
import { PluginPageContext, PluginPageContextProvider, usePluginPageContext } from './context'
// Mock dependencies
vi.mock('nuqs', () => ({
useQueryState: vi.fn(() => ['plugins', vi.fn()]),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('../hooks', () => ({
PLUGIN_PAGE_TABS_MAP: {
plugins: 'plugins',
marketplace: 'discover',
},
usePluginPageTabs: () => [
{ value: 'plugins', text: 'Plugins' },
{ value: 'discover', text: 'Explore Marketplace' },
],
}))
// Helper function to mock useGlobalPublicStore with marketplace setting
const mockGlobalPublicStore = (enableMarketplace: boolean) => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
const state = { systemFeatures: { enable_marketplace: enableMarketplace } }
return selector(state as Parameters<typeof selector>[0])
})
}
// Test component that uses the context
const TestConsumer = () => {
const containerRef = usePluginPageContext(v => v.containerRef)
const options = usePluginPageContext(v => v.options)
const activeTab = usePluginPageContext(v => v.activeTab)
return (
<div>
<span data-testid="has-container-ref">{containerRef ? 'true' : 'false'}</span>
<span data-testid="options-count">{options.length}</span>
<span data-testid="active-tab">{activeTab}</span>
{options.map((opt: { value: string, text: string }) => (
<span key={opt.value} data-testid={`option-${opt.value}`}>{opt.text}</span>
))}
</div>
)
}
describe('PluginPageContext', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('PluginPageContextProvider', () => {
it('should provide context values to children', () => {
mockGlobalPublicStore(true)
render(
<PluginPageContextProvider>
<TestConsumer />
</PluginPageContextProvider>,
)
expect(screen.getByTestId('has-container-ref')).toHaveTextContent('true')
expect(screen.getByTestId('options-count')).toHaveTextContent('2')
})
it('should include marketplace tab when enable_marketplace is true', () => {
mockGlobalPublicStore(true)
render(
<PluginPageContextProvider>
<TestConsumer />
</PluginPageContextProvider>,
)
expect(screen.getByTestId('option-plugins')).toBeInTheDocument()
expect(screen.getByTestId('option-discover')).toBeInTheDocument()
})
it('should filter out marketplace tab when enable_marketplace is false', () => {
mockGlobalPublicStore(false)
render(
<PluginPageContextProvider>
<TestConsumer />
</PluginPageContextProvider>,
)
expect(screen.getByTestId('option-plugins')).toBeInTheDocument()
expect(screen.queryByTestId('option-discover')).not.toBeInTheDocument()
expect(screen.getByTestId('options-count')).toHaveTextContent('1')
})
})
describe('usePluginPageContext', () => {
it('should select specific context values', () => {
mockGlobalPublicStore(true)
render(
<PluginPageContextProvider>
<TestConsumer />
</PluginPageContextProvider>,
)
// activeTab should be 'plugins' from the mock
expect(screen.getByTestId('active-tab')).toHaveTextContent('plugins')
})
})
describe('Default Context Values', () => {
it('should have empty options by default from context', () => {
// Test that the context has proper default values by checking the exported constant
// The PluginPageContext is created with default values including empty options array
expect(PluginPageContext).toBeDefined()
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -207,6 +207,7 @@ const PluginPage = ({
popupContent={t('privilege.title', { ns: 'plugin' })}
>
<Button
data-testid="plugin-settings-button"
className="group h-full w-full p-2 text-components-button-secondary-text"
onClick={setShowPluginSettingModal}
>

View File

@ -0,0 +1,219 @@
import type { FC, ReactNode } from 'react'
import type { PluginStatus } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import {
RiCheckboxCircleFill,
RiErrorWarningFill,
RiLoaderLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import CardIcon from '@/app/components/plugins/card/base/card-icon'
import { useGetLanguage } from '@/context/i18n'
// Types
type PluginItemProps = {
plugin: PluginStatus
getIconUrl: (icon: string) => string
language: Locale
statusIcon: ReactNode
statusText: string
statusClassName?: string
action?: ReactNode
}
type PluginSectionProps = {
title: string
count: number
plugins: PluginStatus[]
getIconUrl: (icon: string) => string
language: Locale
statusIcon: ReactNode
defaultStatusText: string
statusClassName?: string
headerAction?: ReactNode
renderItemAction?: (plugin: PluginStatus) => ReactNode
}
type PluginTaskListProps = {
runningPlugins: PluginStatus[]
successPlugins: PluginStatus[]
errorPlugins: PluginStatus[]
getIconUrl: (icon: string) => string
onClearAll: () => void
onClearErrors: () => void
onClearSingle: (taskId: string, pluginId: string) => void
}
// Plugin Item Component
const PluginItem: FC<PluginItemProps> = ({
plugin,
getIconUrl,
language,
statusIcon,
statusText,
statusClassName,
action,
}) => {
return (
<div className="flex items-center rounded-lg p-2 hover:bg-state-base-hover">
<div className="relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge">
{statusIcon}
<CardIcon
size="tiny"
src={getIconUrl(plugin.icon)}
/>
</div>
<div className="grow">
<div className="system-md-regular truncate text-text-secondary">
{plugin.labels[language]}
</div>
<div className={`system-xs-regular ${statusClassName || 'text-text-tertiary'}`}>
{statusText}
</div>
</div>
{action}
</div>
)
}
// Plugin Section Component
const PluginSection: FC<PluginSectionProps> = ({
title,
count,
plugins,
getIconUrl,
language,
statusIcon,
defaultStatusText,
statusClassName,
headerAction,
renderItemAction,
}) => {
if (plugins.length === 0)
return null
return (
<>
<div className="system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary">
{title}
{' '}
(
{count}
)
{headerAction}
</div>
<div className="max-h-[200px] overflow-y-auto">
{plugins.map(plugin => (
<PluginItem
key={plugin.plugin_unique_identifier}
plugin={plugin}
getIconUrl={getIconUrl}
language={language}
statusIcon={statusIcon}
statusText={plugin.message || defaultStatusText}
statusClassName={statusClassName}
action={renderItemAction?.(plugin)}
/>
))}
</div>
</>
)
}
// Main Plugin Task List Component
const PluginTaskList: FC<PluginTaskListProps> = ({
runningPlugins,
successPlugins,
errorPlugins,
getIconUrl,
onClearAll,
onClearErrors,
onClearSingle,
}) => {
const { t } = useTranslation()
const language = useGetLanguage()
return (
<div className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
{/* Running Plugins Section */}
{runningPlugins.length > 0 && (
<PluginSection
title={t('task.installing', { ns: 'plugin' })}
count={runningPlugins.length}
plugins={runningPlugins}
getIconUrl={getIconUrl}
language={language}
statusIcon={
<RiLoaderLine className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 animate-spin text-text-accent" />
}
defaultStatusText={t('task.installing', { ns: 'plugin' })}
/>
)}
{/* Success Plugins Section */}
{successPlugins.length > 0 && (
<PluginSection
title={t('task.installed', { ns: 'plugin' })}
count={successPlugins.length}
plugins={successPlugins}
getIconUrl={getIconUrl}
language={language}
statusIcon={
<RiCheckboxCircleFill className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-success" />
}
defaultStatusText={t('task.installed', { ns: 'plugin' })}
statusClassName="text-text-success"
headerAction={(
<Button
className="shrink-0"
size="small"
variant="ghost"
onClick={onClearAll}
>
{t('task.clearAll', { ns: 'plugin' })}
</Button>
)}
/>
)}
{/* Error Plugins Section */}
{errorPlugins.length > 0 && (
<PluginSection
title={t('task.installError', { ns: 'plugin', errorLength: errorPlugins.length })}
count={errorPlugins.length}
plugins={errorPlugins}
getIconUrl={getIconUrl}
language={language}
statusIcon={
<RiErrorWarningFill className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-destructive" />
}
defaultStatusText={t('task.installError', { ns: 'plugin', errorLength: errorPlugins.length })}
statusClassName="text-text-destructive break-all"
headerAction={(
<Button
className="shrink-0"
size="small"
variant="ghost"
onClick={onClearErrors}
>
{t('task.clearAll', { ns: 'plugin' })}
</Button>
)}
renderItemAction={plugin => (
<Button
className="shrink-0"
size="small"
variant="ghost"
onClick={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
>
{t('operation.clear', { ns: 'common' })}
</Button>
)}
/>
)}
</div>
)
}
export default PluginTaskList

View File

@ -0,0 +1,96 @@
import type { FC } from 'react'
import {
RiCheckboxCircleFill,
RiErrorWarningFill,
RiInstallLine,
} from '@remixicon/react'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import Tooltip from '@/app/components/base/tooltip'
import DownloadingIcon from '@/app/components/header/plugins-nav/downloading-icon'
import { cn } from '@/utils/classnames'
export type TaskStatusIndicatorProps = {
tip: string
isInstalling: boolean
isInstallingWithSuccess: boolean
isInstallingWithError: boolean
isSuccess: boolean
isFailed: boolean
successPluginsLength: number
runningPluginsLength: number
totalPluginsLength: number
onClick: () => void
}
const TaskStatusIndicator: FC<TaskStatusIndicatorProps> = ({
tip,
isInstalling,
isInstallingWithSuccess,
isInstallingWithError,
isSuccess,
isFailed,
successPluginsLength,
runningPluginsLength,
totalPluginsLength,
onClick,
}) => {
const showDownloadingIcon = isInstalling || isInstallingWithError
const showErrorStyle = isInstallingWithError || isFailed
const showSuccessIcon = isSuccess || (successPluginsLength > 0 && runningPluginsLength === 0)
return (
<Tooltip
popupContent={tip}
asChild
offset={8}
>
<div
className={cn(
'relative flex h-8 w-8 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-components-button-secondary-bg-hover',
showErrorStyle && 'cursor-pointer border-components-button-destructive-secondary-border-hover bg-state-destructive-hover hover:bg-state-destructive-hover-alt',
(isInstalling || isInstallingWithSuccess || isSuccess) && 'cursor-pointer hover:bg-components-button-secondary-bg-hover',
)}
id="plugin-task-trigger"
onClick={onClick}
>
{/* Main Icon */}
{showDownloadingIcon
? <DownloadingIcon />
: (
<RiInstallLine
className={cn(
'h-4 w-4 text-components-button-secondary-text',
showErrorStyle && 'text-components-button-destructive-secondary-text',
)}
/>
)}
{/* Status Indicator Badge */}
<div className="absolute -right-1 -top-1">
{(isInstalling || isInstallingWithSuccess) && (
<ProgressCircle
percentage={(totalPluginsLength > 0 ? successPluginsLength / totalPluginsLength : 0) * 100}
circleFillColor="fill-components-progress-brand-bg"
/>
)}
{isInstallingWithError && (
<ProgressCircle
percentage={(totalPluginsLength > 0 ? runningPluginsLength / totalPluginsLength : 0) * 100}
circleFillColor="fill-components-progress-brand-bg"
sectorFillColor="fill-components-progress-error-border"
circleStrokeColor="stroke-components-progress-error-border"
/>
)}
{showSuccessIcon && !isInstalling && !isInstallingWithSuccess && !isInstallingWithError && (
<RiCheckboxCircleFill className="h-3.5 w-3.5 text-text-success" />
)}
{isFailed && (
<RiErrorWarningFill className="h-3.5 w-3.5 text-text-destructive" />
)}
</div>
</div>
</Tooltip>
)
}
export default TaskStatusIndicator

View File

@ -0,0 +1,856 @@
import type { PluginStatus } from '@/app/components/plugins/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TaskStatus } from '@/app/components/plugins/types'
// Import mocked modules
import { useMutationClearTaskPlugin, usePluginTaskList } from '@/service/use-plugins'
import PluginTaskList from './components/plugin-task-list'
import TaskStatusIndicator from './components/task-status-indicator'
import { usePluginTaskStatus } from './hooks'
import PluginTasks from './index'
// Mock external dependencies
vi.mock('@/service/use-plugins', () => ({
usePluginTaskList: vi.fn(),
useMutationClearTaskPlugin: vi.fn(),
}))
vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({
default: () => ({
getIconUrl: (icon: string) => `https://example.com/${icon}`,
}),
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en_US',
}))
// Helper to create mock plugin
const createMockPlugin = (overrides: Partial<PluginStatus> = {}): PluginStatus => ({
plugin_unique_identifier: `plugin-${Math.random().toString(36).substr(2, 9)}`,
plugin_id: 'test-plugin',
status: TaskStatus.running,
message: '',
icon: 'test-icon.png',
labels: {
en_US: 'Test Plugin',
zh_Hans: '测试插件',
} as Record<string, string>,
taskId: 'task-1',
...overrides,
})
// Helper to setup mock hook returns
const setupMocks = (plugins: PluginStatus[] = []) => {
const mockMutateAsync = vi.fn().mockResolvedValue({})
const mockHandleRefetch = vi.fn()
vi.mocked(usePluginTaskList).mockReturnValue({
pluginTasks: plugins.length > 0
? [{ id: 'task-1', plugins, created_at: '', updated_at: '', status: 'running', total_plugins: plugins.length, completed_plugins: 0 }]
: [],
handleRefetch: mockHandleRefetch,
} as any)
vi.mocked(useMutationClearTaskPlugin).mockReturnValue({
mutateAsync: mockMutateAsync,
} as any)
return { mockMutateAsync, mockHandleRefetch }
}
// ============================================================================
// usePluginTaskStatus Hook Tests
// ============================================================================
describe('usePluginTaskStatus Hook', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Plugin categorization', () => {
it('should categorize running plugins correctly', () => {
const runningPlugin = createMockPlugin({ status: TaskStatus.running })
setupMocks([runningPlugin])
const TestComponent = () => {
const { runningPlugins, runningPluginsLength } = usePluginTaskStatus()
return (
<div>
<span data-testid="running-count">{runningPluginsLength}</span>
<span data-testid="running-id">{runningPlugins[0]?.plugin_unique_identifier}</span>
</div>
)
}
render(<TestComponent />)
expect(screen.getByTestId('running-count')).toHaveTextContent('1')
expect(screen.getByTestId('running-id')).toHaveTextContent(runningPlugin.plugin_unique_identifier)
})
it('should categorize success plugins correctly', () => {
const successPlugin = createMockPlugin({ status: TaskStatus.success })
setupMocks([successPlugin])
const TestComponent = () => {
const { successPlugins, successPluginsLength } = usePluginTaskStatus()
return (
<div>
<span data-testid="success-count">{successPluginsLength}</span>
<span data-testid="success-id">{successPlugins[0]?.plugin_unique_identifier}</span>
</div>
)
}
render(<TestComponent />)
expect(screen.getByTestId('success-count')).toHaveTextContent('1')
expect(screen.getByTestId('success-id')).toHaveTextContent(successPlugin.plugin_unique_identifier)
})
it('should categorize error plugins correctly', () => {
const errorPlugin = createMockPlugin({ status: TaskStatus.failed, message: 'Install failed' })
setupMocks([errorPlugin])
const TestComponent = () => {
const { errorPlugins, errorPluginsLength } = usePluginTaskStatus()
return (
<div>
<span data-testid="error-count">{errorPluginsLength}</span>
<span data-testid="error-id">{errorPlugins[0]?.plugin_unique_identifier}</span>
</div>
)
}
render(<TestComponent />)
expect(screen.getByTestId('error-count')).toHaveTextContent('1')
expect(screen.getByTestId('error-id')).toHaveTextContent(errorPlugin.plugin_unique_identifier)
})
it('should categorize mixed plugins correctly', () => {
const plugins = [
createMockPlugin({ status: TaskStatus.running, plugin_unique_identifier: 'running-1' }),
createMockPlugin({ status: TaskStatus.success, plugin_unique_identifier: 'success-1' }),
createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'error-1' }),
]
setupMocks(plugins)
const TestComponent = () => {
const { runningPluginsLength, successPluginsLength, errorPluginsLength, totalPluginsLength } = usePluginTaskStatus()
return (
<div>
<span data-testid="running">{runningPluginsLength}</span>
<span data-testid="success">{successPluginsLength}</span>
<span data-testid="error">{errorPluginsLength}</span>
<span data-testid="total">{totalPluginsLength}</span>
</div>
)
}
render(<TestComponent />)
expect(screen.getByTestId('running')).toHaveTextContent('1')
expect(screen.getByTestId('success')).toHaveTextContent('1')
expect(screen.getByTestId('error')).toHaveTextContent('1')
expect(screen.getByTestId('total')).toHaveTextContent('3')
})
})
describe('Status flags', () => {
it('should set isInstalling when only running plugins exist', () => {
setupMocks([createMockPlugin({ status: TaskStatus.running })])
const TestComponent = () => {
const { isInstalling, isInstallingWithSuccess, isInstallingWithError, isSuccess, isFailed } = usePluginTaskStatus()
return (
<div>
<span data-testid="isInstalling">{String(isInstalling)}</span>
<span data-testid="isInstallingWithSuccess">{String(isInstallingWithSuccess)}</span>
<span data-testid="isInstallingWithError">{String(isInstallingWithError)}</span>
<span data-testid="isSuccess">{String(isSuccess)}</span>
<span data-testid="isFailed">{String(isFailed)}</span>
</div>
)
}
render(<TestComponent />)
expect(screen.getByTestId('isInstalling')).toHaveTextContent('true')
expect(screen.getByTestId('isInstallingWithSuccess')).toHaveTextContent('false')
expect(screen.getByTestId('isInstallingWithError')).toHaveTextContent('false')
expect(screen.getByTestId('isSuccess')).toHaveTextContent('false')
expect(screen.getByTestId('isFailed')).toHaveTextContent('false')
})
it('should set isInstallingWithSuccess when running and success plugins exist', () => {
setupMocks([
createMockPlugin({ status: TaskStatus.running }),
createMockPlugin({ status: TaskStatus.success }),
])
const TestComponent = () => {
const { isInstallingWithSuccess } = usePluginTaskStatus()
return <span data-testid="flag">{String(isInstallingWithSuccess)}</span>
}
render(<TestComponent />)
expect(screen.getByTestId('flag')).toHaveTextContent('true')
})
it('should set isInstallingWithError when running and error plugins exist', () => {
setupMocks([
createMockPlugin({ status: TaskStatus.running }),
createMockPlugin({ status: TaskStatus.failed }),
])
const TestComponent = () => {
const { isInstallingWithError } = usePluginTaskStatus()
return <span data-testid="flag">{String(isInstallingWithError)}</span>
}
render(<TestComponent />)
expect(screen.getByTestId('flag')).toHaveTextContent('true')
})
it('should set isSuccess when all plugins succeeded', () => {
setupMocks([
createMockPlugin({ status: TaskStatus.success }),
createMockPlugin({ status: TaskStatus.success }),
])
const TestComponent = () => {
const { isSuccess } = usePluginTaskStatus()
return <span data-testid="flag">{String(isSuccess)}</span>
}
render(<TestComponent />)
expect(screen.getByTestId('flag')).toHaveTextContent('true')
})
it('should set isFailed when no running plugins and some failed', () => {
setupMocks([
createMockPlugin({ status: TaskStatus.success }),
createMockPlugin({ status: TaskStatus.failed }),
])
const TestComponent = () => {
const { isFailed } = usePluginTaskStatus()
return <span data-testid="flag">{String(isFailed)}</span>
}
render(<TestComponent />)
expect(screen.getByTestId('flag')).toHaveTextContent('true')
})
})
describe('handleClearErrorPlugin', () => {
it('should call mutateAsync and handleRefetch', async () => {
const { mockMutateAsync, mockHandleRefetch } = setupMocks([
createMockPlugin({ status: TaskStatus.failed }),
])
const TestComponent = () => {
const { handleClearErrorPlugin } = usePluginTaskStatus()
return (
<button onClick={() => handleClearErrorPlugin('task-1', 'plugin-1')}>
Clear
</button>
)
}
render(<TestComponent />)
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
taskId: 'task-1',
pluginId: 'plugin-1',
})
expect(mockHandleRefetch).toHaveBeenCalled()
})
})
})
})
// ============================================================================
// TaskStatusIndicator Component Tests
// ============================================================================
describe('TaskStatusIndicator Component', () => {
const defaultProps = {
tip: 'Test tooltip',
isInstalling: false,
isInstallingWithSuccess: false,
isInstallingWithError: false,
isSuccess: false,
isFailed: false,
successPluginsLength: 0,
runningPluginsLength: 0,
totalPluginsLength: 1,
onClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<TaskStatusIndicator {...defaultProps} />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should render with correct id', () => {
render(<TaskStatusIndicator {...defaultProps} />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
})
describe('Icon display', () => {
it('should show downloading icon when installing', () => {
render(<TaskStatusIndicator {...defaultProps} isInstalling />)
// DownloadingIcon is rendered when isInstalling is true
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should show downloading icon when installing with error', () => {
render(<TaskStatusIndicator {...defaultProps} isInstallingWithError />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should show install icon when not installing', () => {
render(<TaskStatusIndicator {...defaultProps} isSuccess />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
})
describe('Status badge', () => {
it('should show progress circle when installing', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isInstalling
successPluginsLength={1}
totalPluginsLength={3}
/>,
)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should show progress circle when installing with success', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isInstallingWithSuccess
successPluginsLength={2}
totalPluginsLength={3}
/>,
)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should show error progress circle when installing with error', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isInstallingWithError
runningPluginsLength={1}
totalPluginsLength={3}
/>,
)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should show success icon when all completed successfully', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isSuccess
successPluginsLength={3}
runningPluginsLength={0}
totalPluginsLength={3}
/>,
)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should show error icon when failed', () => {
render(<TaskStatusIndicator {...defaultProps} isFailed />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should apply error styles when installing with error', () => {
render(<TaskStatusIndicator {...defaultProps} isInstallingWithError />)
const trigger = document.getElementById('plugin-task-trigger')
expect(trigger).toHaveClass('bg-state-destructive-hover')
})
it('should apply error styles when failed', () => {
render(<TaskStatusIndicator {...defaultProps} isFailed />)
const trigger = document.getElementById('plugin-task-trigger')
expect(trigger).toHaveClass('bg-state-destructive-hover')
})
it('should apply cursor-pointer when clickable', () => {
render(<TaskStatusIndicator {...defaultProps} isInstalling />)
const trigger = document.getElementById('plugin-task-trigger')
expect(trigger).toHaveClass('cursor-pointer')
})
})
describe('User interactions', () => {
it('should call onClick when clicked', () => {
const handleClick = vi.fn()
render(<TaskStatusIndicator {...defaultProps} onClick={handleClick} />)
fireEvent.click(document.getElementById('plugin-task-trigger')!)
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
})
// ============================================================================
// PluginTaskList Component Tests
// ============================================================================
describe('PluginTaskList Component', () => {
const defaultProps = {
runningPlugins: [] as PluginStatus[],
successPlugins: [] as PluginStatus[],
errorPlugins: [] as PluginStatus[],
getIconUrl: (icon: string) => `https://example.com/${icon}`,
onClearAll: vi.fn(),
onClearErrors: vi.fn(),
onClearSingle: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing with empty lists', () => {
render(<PluginTaskList {...defaultProps} />)
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
})
it('should render running plugins section when plugins exist', () => {
const runningPlugins = [createMockPlugin({ status: TaskStatus.running })]
render(<PluginTaskList {...defaultProps} runningPlugins={runningPlugins} />)
// Translation key is returned as text in tests, multiple matches expected (title + status)
expect(screen.getAllByText(/task\.installing/i).length).toBeGreaterThan(0)
// Verify section container is rendered
expect(document.querySelector('.max-h-\\[200px\\]')).toBeInTheDocument()
})
it('should render success plugins section when plugins exist', () => {
const successPlugins = [createMockPlugin({ status: TaskStatus.success })]
render(<PluginTaskList {...defaultProps} successPlugins={successPlugins} />)
// Translation key is returned as text in tests, multiple matches expected
expect(screen.getAllByText(/task\.installed/i).length).toBeGreaterThan(0)
})
it('should render error plugins section when plugins exist', () => {
const errorPlugins = [createMockPlugin({ status: TaskStatus.failed, message: 'Error occurred' })]
render(<PluginTaskList {...defaultProps} errorPlugins={errorPlugins} />)
expect(screen.getByText('Error occurred')).toBeInTheDocument()
})
it('should render all sections when all types exist', () => {
render(
<PluginTaskList
{...defaultProps}
runningPlugins={[createMockPlugin({ status: TaskStatus.running })]}
successPlugins={[createMockPlugin({ status: TaskStatus.success })]}
errorPlugins={[createMockPlugin({ status: TaskStatus.failed })]}
/>,
)
// All sections should be present
expect(document.querySelectorAll('.max-h-\\[200px\\]').length).toBe(3)
})
})
describe('User interactions', () => {
it('should call onClearAll when clear all button is clicked in success section', () => {
const handleClearAll = vi.fn()
const successPlugins = [createMockPlugin({ status: TaskStatus.success })]
render(
<PluginTaskList
{...defaultProps}
successPlugins={successPlugins}
onClearAll={handleClearAll}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /task\.clearAll/i }))
expect(handleClearAll).toHaveBeenCalledTimes(1)
})
it('should call onClearErrors when clear all button is clicked in error section', () => {
const handleClearErrors = vi.fn()
const errorPlugins = [createMockPlugin({ status: TaskStatus.failed })]
render(
<PluginTaskList
{...defaultProps}
errorPlugins={errorPlugins}
onClearErrors={handleClearErrors}
/>,
)
const clearButtons = screen.getAllByRole('button')
fireEvent.click(clearButtons.find(btn => btn.textContent?.includes('task.clearAll'))!)
expect(handleClearErrors).toHaveBeenCalledTimes(1)
})
it('should call onClearSingle with correct args when individual clear is clicked', () => {
const handleClearSingle = vi.fn()
const errorPlugin = createMockPlugin({
status: TaskStatus.failed,
plugin_unique_identifier: 'error-plugin-1',
taskId: 'task-123',
})
render(
<PluginTaskList
{...defaultProps}
errorPlugins={[errorPlugin]}
onClearSingle={handleClearSingle}
/>,
)
// The individual clear button has the text 'operation.clear'
fireEvent.click(screen.getByRole('button', { name: /operation\.clear/i }))
expect(handleClearSingle).toHaveBeenCalledWith('task-123', 'error-plugin-1')
})
})
describe('Plugin display', () => {
it('should display plugin name from labels', () => {
const plugin = createMockPlugin({
status: TaskStatus.running,
labels: { en_US: 'My Test Plugin' } as Record<string, string>,
})
render(<PluginTaskList {...defaultProps} runningPlugins={[plugin]} />)
expect(screen.getByText('My Test Plugin')).toBeInTheDocument()
})
it('should display plugin message when available', () => {
const plugin = createMockPlugin({
status: TaskStatus.success,
message: 'Successfully installed!',
})
render(<PluginTaskList {...defaultProps} successPlugins={[plugin]} />)
expect(screen.getByText('Successfully installed!')).toBeInTheDocument()
})
it('should display multiple plugins in each section', () => {
const runningPlugins = [
createMockPlugin({ status: TaskStatus.running, labels: { en_US: 'Plugin A' } as Record<string, string> }),
createMockPlugin({ status: TaskStatus.running, labels: { en_US: 'Plugin B' } as Record<string, string> }),
]
render(<PluginTaskList {...defaultProps} runningPlugins={runningPlugins} />)
expect(screen.getByText('Plugin A')).toBeInTheDocument()
expect(screen.getByText('Plugin B')).toBeInTheDocument()
// Count is rendered, verify multiple items are in list
expect(document.querySelectorAll('.hover\\:bg-state-base-hover').length).toBe(2)
})
})
})
// ============================================================================
// PluginTasks Main Component Tests
// ============================================================================
describe('PluginTasks Component', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should return null when no plugins exist', () => {
setupMocks([])
const { container } = render(<PluginTasks />)
expect(container.firstChild).toBeNull()
})
it('should render when plugins exist', () => {
setupMocks([createMockPlugin({ status: TaskStatus.running })])
render(<PluginTasks />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
})
describe('Tooltip text (tip memoization)', () => {
it('should show installing tip when isInstalling', () => {
setupMocks([createMockPlugin({ status: TaskStatus.running })])
render(<PluginTasks />)
// The component renders with a tooltip, we verify it exists
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should show success tip when all succeeded', () => {
setupMocks([createMockPlugin({ status: TaskStatus.success })])
render(<PluginTasks />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should show error tip when some failed', () => {
setupMocks([
createMockPlugin({ status: TaskStatus.success }),
createMockPlugin({ status: TaskStatus.failed }),
])
render(<PluginTasks />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
})
describe('Popover interaction', () => {
it('should toggle popover when trigger is clicked and status allows', () => {
setupMocks([createMockPlugin({ status: TaskStatus.running })])
render(<PluginTasks />)
// Click to open
fireEvent.click(document.getElementById('plugin-task-trigger')!)
// The popover content should be visible (PluginTaskList)
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
})
it('should not toggle when status does not allow', () => {
// Setup with no actionable status (edge case - should not happen in practice)
setupMocks([createMockPlugin({ status: TaskStatus.running })])
render(<PluginTasks />)
// Component should still render
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
})
describe('Clear handlers', () => {
it('should clear all completed plugins when onClearAll is called', async () => {
const { mockMutateAsync } = setupMocks([
createMockPlugin({ status: TaskStatus.success, plugin_unique_identifier: 'success-1' }),
createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'error-1' }),
])
render(<PluginTasks />)
// Open popover
fireEvent.click(document.getElementById('plugin-task-trigger')!)
// Wait for popover content to render
await waitFor(() => {
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
})
// Find and click clear all button
const clearButtons = screen.getAllByRole('button')
const clearAllButton = clearButtons.find(btn => btn.textContent?.includes('clearAll'))
if (clearAllButton)
fireEvent.click(clearAllButton)
// Verify mutateAsync was called for each completed plugin
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
})
})
it('should clear only error plugins when onClearErrors is called', async () => {
const { mockMutateAsync } = setupMocks([
createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'error-1' }),
])
render(<PluginTasks />)
// Open popover
fireEvent.click(document.getElementById('plugin-task-trigger')!)
await waitFor(() => {
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
})
// Find and click the clear all button in error section
const clearButtons = screen.getAllByRole('button')
if (clearButtons.length > 0)
fireEvent.click(clearButtons[0])
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
})
})
it('should clear single plugin when onClearSingle is called', async () => {
const { mockMutateAsync } = setupMocks([
createMockPlugin({
status: TaskStatus.failed,
plugin_unique_identifier: 'error-plugin',
taskId: 'task-1',
}),
])
render(<PluginTasks />)
// Open popover
fireEvent.click(document.getElementById('plugin-task-trigger')!)
await waitFor(() => {
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
})
// Find and click individual clear button (usually the last one)
const clearButtons = screen.getAllByRole('button')
const individualClearButton = clearButtons[clearButtons.length - 1]
fireEvent.click(individualClearButton)
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
taskId: 'task-1',
pluginId: 'error-plugin',
})
})
})
})
describe('Edge cases', () => {
it('should handle empty plugin tasks array', () => {
setupMocks([])
const { container } = render(<PluginTasks />)
expect(container.firstChild).toBeNull()
})
it('should handle single running plugin', () => {
setupMocks([createMockPlugin({ status: TaskStatus.running })])
render(<PluginTasks />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should handle many plugins', () => {
const manyPlugins = Array.from({ length: 10 }, (_, i) =>
createMockPlugin({
status: i % 3 === 0 ? TaskStatus.running : i % 3 === 1 ? TaskStatus.success : TaskStatus.failed,
plugin_unique_identifier: `plugin-${i}`,
}))
setupMocks(manyPlugins)
render(<PluginTasks />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should handle plugins with empty labels', () => {
const plugin = createMockPlugin({
status: TaskStatus.running,
labels: {} as Record<string, string>,
})
setupMocks([plugin])
render(<PluginTasks />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should handle plugins with long messages', () => {
const plugin = createMockPlugin({
status: TaskStatus.failed,
message: 'A'.repeat(500),
})
setupMocks([plugin])
render(<PluginTasks />)
// Open popover
fireEvent.click(document.getElementById('plugin-task-trigger')!)
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
})
})
})
// ============================================================================
// Integration Tests
// ============================================================================
describe('PluginTasks Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should show correct UI flow from installing to success', async () => {
// Start with installing state
setupMocks([createMockPlugin({ status: TaskStatus.running })])
const { rerender } = render(<PluginTasks />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
// Simulate completion by re-rendering with success
setupMocks([createMockPlugin({ status: TaskStatus.success })])
rerender(<PluginTasks />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should show correct UI flow from installing to failure', async () => {
// Start with installing state
setupMocks([createMockPlugin({ status: TaskStatus.running })])
const { rerender } = render(<PluginTasks />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
// Simulate failure by re-rendering with failed
setupMocks([createMockPlugin({ status: TaskStatus.failed, message: 'Network error' })])
rerender(<PluginTasks />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should handle mixed status during installation', () => {
setupMocks([
createMockPlugin({ status: TaskStatus.running, plugin_unique_identifier: 'p1' }),
createMockPlugin({ status: TaskStatus.success, plugin_unique_identifier: 'p2' }),
createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'p3' }),
])
render(<PluginTasks />)
// Open popover
fireEvent.click(document.getElementById('plugin-task-trigger')!)
// All sections should be visible
const sections = document.querySelectorAll('.max-h-\\[200px\\]')
expect(sections.length).toBe(3)
})
})

View File

@ -1,33 +1,21 @@
import {
RiCheckboxCircleFill,
RiErrorWarningFill,
RiInstallLine,
RiLoaderLine,
} from '@remixicon/react'
import {
useCallback,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import Tooltip from '@/app/components/base/tooltip'
import DownloadingIcon from '@/app/components/header/plugins-nav/downloading-icon'
import CardIcon from '@/app/components/plugins/card/base/card-icon'
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
import { useGetLanguage } from '@/context/i18n'
import { cn } from '@/utils/classnames'
import PluginTaskList from './components/plugin-task-list'
import TaskStatusIndicator from './components/task-status-indicator'
import { usePluginTaskStatus } from './hooks'
const PluginTasks = () => {
const { t } = useTranslation()
const language = useGetLanguage()
const [open, setOpen] = useState(false)
const {
errorPlugins,
@ -46,35 +34,7 @@ const PluginTasks = () => {
} = usePluginTaskStatus()
const { getIconUrl } = useGetIcon()
const handleClearAllWithModal = useCallback(async () => {
// Clear all completed plugins (success and error) but keep running ones
const completedPlugins = [...successPlugins, ...errorPlugins]
// Clear all completed plugins individually
for (const plugin of completedPlugins)
await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier)
// Only close modal if no plugins are still installing
if (runningPluginsLength === 0)
setOpen(false)
}, [successPlugins, errorPlugins, handleClearErrorPlugin, runningPluginsLength])
const handleClearErrorsWithModal = useCallback(async () => {
// Clear only error plugins, not all plugins
for (const plugin of errorPlugins)
await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier)
// Only close modal if no plugins are still installing
if (runningPluginsLength === 0)
setOpen(false)
}, [errorPlugins, handleClearErrorPlugin, runningPluginsLength])
const handleClearSingleWithModal = useCallback(async (taskId: string, pluginId: string) => {
await handleClearErrorPlugin(taskId, pluginId)
// Only close modal if no plugins are still installing
if (runningPluginsLength === 0)
setOpen(false)
}, [handleClearErrorPlugin, runningPluginsLength])
// Generate tooltip text based on status
const tip = useMemo(() => {
if (isInstallingWithError)
return t('task.installingWithError', { ns: 'plugin', installingLength: runningPluginsLength, successLength: successPluginsLength, errorLength: errorPluginsLength })
@ -99,8 +59,38 @@ const PluginTasks = () => {
t,
])
// Show icon if there are any plugin tasks (completed, running, or failed)
// Only hide when there are absolutely no plugin tasks
// Generic clear function that handles clearing and modal closing
const clearPluginsAndClose = useCallback(async (
plugins: Array<{ taskId: string, plugin_unique_identifier: string }>,
) => {
for (const plugin of plugins)
await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier)
if (runningPluginsLength === 0)
setOpen(false)
}, [handleClearErrorPlugin, runningPluginsLength])
// Clear handlers using the generic function
const handleClearAll = useCallback(
() => clearPluginsAndClose([...successPlugins, ...errorPlugins]),
[clearPluginsAndClose, successPlugins, errorPlugins],
)
const handleClearErrors = useCallback(
() => clearPluginsAndClose(errorPlugins),
[clearPluginsAndClose, errorPlugins],
)
const handleClearSingle = useCallback(
(taskId: string, pluginId: string) => clearPluginsAndClose([{ taskId, plugin_unique_identifier: pluginId }]),
[clearPluginsAndClose],
)
const handleTriggerClick = useCallback(() => {
if (isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess)
setOpen(v => !v)
}, [isFailed, isInstalling, isInstallingWithSuccess, isInstallingWithError, isSuccess])
// Hide when no plugin tasks
if (totalPluginsLength === 0)
return null
@ -115,206 +105,30 @@ const PluginTasks = () => {
crossAxis: 79,
}}
>
<PortalToFollowElemTrigger
onClick={() => {
if (isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess)
setOpen(v => !v)
}}
>
<Tooltip
popupContent={tip}
asChild
offset={8}
>
<div
className={cn(
'relative flex h-8 w-8 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-components-button-secondary-bg-hover',
(isInstallingWithError || isFailed) && 'cursor-pointer border-components-button-destructive-secondary-border-hover bg-state-destructive-hover hover:bg-state-destructive-hover-alt',
(isInstalling || isInstallingWithSuccess || isSuccess) && 'cursor-pointer hover:bg-components-button-secondary-bg-hover',
)}
id="plugin-task-trigger"
>
{
(isInstalling || isInstallingWithError) && (
<DownloadingIcon />
)
}
{
!(isInstalling || isInstallingWithError) && (
<RiInstallLine
className={cn(
'h-4 w-4 text-components-button-secondary-text',
(isInstallingWithError || isFailed) && 'text-components-button-destructive-secondary-text',
)}
/>
)
}
<div className="absolute -right-1 -top-1">
{
(isInstalling || isInstallingWithSuccess) && (
<ProgressCircle
percentage={successPluginsLength / totalPluginsLength * 100}
circleFillColor="fill-components-progress-brand-bg"
/>
)
}
{
isInstallingWithError && (
<ProgressCircle
percentage={runningPluginsLength / totalPluginsLength * 100}
circleFillColor="fill-components-progress-brand-bg"
sectorFillColor="fill-components-progress-error-border"
circleStrokeColor="stroke-components-progress-error-border"
/>
)
}
{
(isSuccess || (successPluginsLength > 0 && runningPluginsLength === 0 && errorPluginsLength === 0)) && (
<RiCheckboxCircleFill className="h-3.5 w-3.5 text-text-success" />
)
}
{
isFailed && (
<RiErrorWarningFill className="h-3.5 w-3.5 text-text-destructive" />
)
}
</div>
</div>
</Tooltip>
<PortalToFollowElemTrigger onClick={handleTriggerClick}>
<TaskStatusIndicator
tip={tip}
isInstalling={isInstalling}
isInstallingWithSuccess={isInstallingWithSuccess}
isInstallingWithError={isInstallingWithError}
isSuccess={isSuccess}
isFailed={isFailed}
successPluginsLength={successPluginsLength}
runningPluginsLength={runningPluginsLength}
totalPluginsLength={totalPluginsLength}
onClick={() => {}}
/>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[11]">
<div className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
{/* Running Plugins */}
{runningPlugins.length > 0 && (
<>
<div className="system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary">
{t('task.installing', { ns: 'plugin' })}
{' '}
(
{runningPlugins.length}
)
</div>
<div className="max-h-[200px] overflow-y-auto">
{runningPlugins.map(runningPlugin => (
<div
key={runningPlugin.plugin_unique_identifier}
className="flex items-center rounded-lg p-2 hover:bg-state-base-hover"
>
<div className="relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge">
<RiLoaderLine className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 animate-spin text-text-accent" />
<CardIcon
size="tiny"
src={getIconUrl(runningPlugin.icon)}
/>
</div>
<div className="grow">
<div className="system-md-regular truncate text-text-secondary">
{runningPlugin.labels[language]}
</div>
<div className="system-xs-regular text-text-tertiary">
{t('task.installing', { ns: 'plugin' })}
</div>
</div>
</div>
))}
</div>
</>
)}
{/* Success Plugins */}
{successPlugins.length > 0 && (
<>
<div className="system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary">
{t('task.installed', { ns: 'plugin' })}
{' '}
(
{successPlugins.length}
)
<Button
className="shrink-0"
size="small"
variant="ghost"
onClick={() => handleClearAllWithModal()}
>
{t('task.clearAll', { ns: 'plugin' })}
</Button>
</div>
<div className="max-h-[200px] overflow-y-auto">
{successPlugins.map(successPlugin => (
<div
key={successPlugin.plugin_unique_identifier}
className="flex items-center rounded-lg p-2 hover:bg-state-base-hover"
>
<div className="relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge">
<RiCheckboxCircleFill className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-success" />
<CardIcon
size="tiny"
src={getIconUrl(successPlugin.icon)}
/>
</div>
<div className="grow">
<div className="system-md-regular truncate text-text-secondary">
{successPlugin.labels[language]}
</div>
<div className="system-xs-regular text-text-success">
{successPlugin.message || t('task.installed', { ns: 'plugin' })}
</div>
</div>
</div>
))}
</div>
</>
)}
{/* Error Plugins */}
{errorPlugins.length > 0 && (
<>
<div className="system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary">
{t('task.installError', { ns: 'plugin', errorLength: errorPlugins.length })}
<Button
className="shrink-0"
size="small"
variant="ghost"
onClick={() => handleClearErrorsWithModal()}
>
{t('task.clearAll', { ns: 'plugin' })}
</Button>
</div>
<div className="max-h-[200px] overflow-y-auto">
{errorPlugins.map(errorPlugin => (
<div
key={errorPlugin.plugin_unique_identifier}
className="flex items-center rounded-lg p-2 hover:bg-state-base-hover"
>
<div className="relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge">
<RiErrorWarningFill className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-destructive" />
<CardIcon
size="tiny"
src={getIconUrl(errorPlugin.icon)}
/>
</div>
<div className="grow">
<div className="system-md-regular truncate text-text-secondary">
{errorPlugin.labels[language]}
</div>
<div className="system-xs-regular break-all text-text-destructive">
{errorPlugin.message}
</div>
</div>
<Button
className="shrink-0"
size="small"
variant="ghost"
onClick={() => handleClearSingleWithModal(errorPlugin.taskId, errorPlugin.plugin_unique_identifier)}
>
{t('operation.clear', { ns: 'common' })}
</Button>
</div>
))}
</div>
</>
)}
</div>
<PluginTaskList
runningPlugins={runningPlugins}
successPlugins={successPlugins}
errorPlugins={errorPlugins}
getIconUrl={getIconUrl}
onClearAll={handleClearAll}
onClearErrors={handleClearErrors}
onClearSingle={handleClearSingle}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>

View File

@ -0,0 +1,388 @@
import { renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import mocks for assertions
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useInvalidateReferenceSettings, useMutationReferenceSettings, useReferenceSettings } from '@/service/use-plugins'
import Toast from '../../base/toast'
import { PermissionType } from '../types'
import useReferenceSetting, { useCanInstallPluginFromMarketplace } from './use-reference-setting'
// Mock dependencies
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@/service/use-plugins', () => ({
useReferenceSettings: vi.fn(),
useMutationReferenceSettings: vi.fn(),
useInvalidateReferenceSettings: vi.fn(),
}))
vi.mock('../../base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
describe('useReferenceSetting Hook', () => {
beforeEach(() => {
vi.clearAllMocks()
// Default mocks
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
} as ReturnType<typeof useAppContext>)
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.everyone,
debug_permission: PermissionType.everyone,
},
},
} as ReturnType<typeof useReferenceSettings>)
vi.mocked(useMutationReferenceSettings).mockReturnValue({
mutate: vi.fn(),
isPending: false,
} as unknown as ReturnType<typeof useMutationReferenceSettings>)
vi.mocked(useInvalidateReferenceSettings).mockReturnValue(vi.fn())
})
describe('hasPermission logic', () => {
it('should return false when permission is undefined', () => {
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: undefined,
debug_permission: undefined,
},
},
} as unknown as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canManagement).toBe(false)
expect(result.current.canDebugger).toBe(false)
})
it('should return false when permission is noOne', () => {
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.noOne,
debug_permission: PermissionType.noOne,
},
},
} as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canManagement).toBe(false)
expect(result.current.canDebugger).toBe(false)
})
it('should return true when permission is everyone', () => {
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.everyone,
debug_permission: PermissionType.everyone,
},
},
} as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canManagement).toBe(true)
expect(result.current.canDebugger).toBe(true)
})
it('should return isAdmin when permission is admin and user is manager', () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false,
} as ReturnType<typeof useAppContext>)
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.admin,
debug_permission: PermissionType.admin,
},
},
} as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canManagement).toBe(true)
expect(result.current.canDebugger).toBe(true)
})
it('should return isAdmin when permission is admin and user is owner', () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: true,
} as ReturnType<typeof useAppContext>)
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.admin,
debug_permission: PermissionType.admin,
},
},
} as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canManagement).toBe(true)
expect(result.current.canDebugger).toBe(true)
})
it('should return false when permission is admin and user is not admin', () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
} as ReturnType<typeof useAppContext>)
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.admin,
debug_permission: PermissionType.admin,
},
},
} as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canManagement).toBe(false)
expect(result.current.canDebugger).toBe(false)
})
})
describe('canSetPermissions', () => {
it('should be true when user is workspace manager', () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false,
} as ReturnType<typeof useAppContext>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canSetPermissions).toBe(true)
})
it('should be true when user is workspace owner', () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: true,
} as ReturnType<typeof useAppContext>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canSetPermissions).toBe(true)
})
it('should be false when user is neither manager nor owner', () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
} as ReturnType<typeof useAppContext>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canSetPermissions).toBe(false)
})
})
describe('setReferenceSettings callback', () => {
it('should call invalidateReferenceSettings and show toast on success', async () => {
const mockInvalidate = vi.fn()
vi.mocked(useInvalidateReferenceSettings).mockReturnValue(mockInvalidate)
let onSuccessCallback: (() => void) | undefined
vi.mocked(useMutationReferenceSettings).mockImplementation((options) => {
onSuccessCallback = options?.onSuccess as () => void
return {
mutate: vi.fn(),
isPending: false,
} as unknown as ReturnType<typeof useMutationReferenceSettings>
})
renderHook(() => useReferenceSetting())
// Trigger the onSuccess callback
if (onSuccessCallback)
onSuccessCallback()
await waitFor(() => {
expect(mockInvalidate).toHaveBeenCalled()
expect(Toast.notify).toHaveBeenCalledWith({
type: 'success',
message: 'api.actionSuccess',
})
})
})
})
describe('returned values', () => {
it('should return referenceSetting data', () => {
const mockData = {
permission: {
install_permission: PermissionType.everyone,
debug_permission: PermissionType.everyone,
},
}
vi.mocked(useReferenceSettings).mockReturnValue({
data: mockData,
} as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.referenceSetting).toEqual(mockData)
})
it('should return isUpdatePending from mutation', () => {
vi.mocked(useMutationReferenceSettings).mockReturnValue({
mutate: vi.fn(),
isPending: true,
} as unknown as ReturnType<typeof useMutationReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.isUpdatePending).toBe(true)
})
it('should handle null data', () => {
vi.mocked(useReferenceSettings).mockReturnValue({
data: null,
} as unknown as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canManagement).toBe(false)
expect(result.current.canDebugger).toBe(false)
})
})
})
describe('useCanInstallPluginFromMarketplace Hook', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false,
} as ReturnType<typeof useAppContext>)
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.everyone,
debug_permission: PermissionType.everyone,
},
},
} as ReturnType<typeof useReferenceSettings>)
vi.mocked(useMutationReferenceSettings).mockReturnValue({
mutate: vi.fn(),
isPending: false,
} as unknown as ReturnType<typeof useMutationReferenceSettings>)
vi.mocked(useInvalidateReferenceSettings).mockReturnValue(vi.fn())
})
it('should return true when marketplace is enabled and canManagement is true', () => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
const state = {
systemFeatures: {
enable_marketplace: true,
},
}
return selector(state as Parameters<typeof selector>[0])
})
const { result } = renderHook(() => useCanInstallPluginFromMarketplace())
expect(result.current.canInstallPluginFromMarketplace).toBe(true)
})
it('should return false when marketplace is disabled', () => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
const state = {
systemFeatures: {
enable_marketplace: false,
},
}
return selector(state as Parameters<typeof selector>[0])
})
const { result } = renderHook(() => useCanInstallPluginFromMarketplace())
expect(result.current.canInstallPluginFromMarketplace).toBe(false)
})
it('should return false when canManagement is false', () => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
const state = {
systemFeatures: {
enable_marketplace: true,
},
}
return selector(state as Parameters<typeof selector>[0])
})
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.noOne,
debug_permission: PermissionType.noOne,
},
},
} as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useCanInstallPluginFromMarketplace())
expect(result.current.canInstallPluginFromMarketplace).toBe(false)
})
it('should return false when both marketplace is disabled and canManagement is false', () => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
const state = {
systemFeatures: {
enable_marketplace: false,
},
}
return selector(state as Parameters<typeof selector>[0])
})
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.noOne,
debug_permission: PermissionType.noOne,
},
},
} as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useCanInstallPluginFromMarketplace())
expect(result.current.canInstallPluginFromMarketplace).toBe(false)
})
})

View File

@ -0,0 +1,487 @@
import type { RefObject } from 'react'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useUploader } from './use-uploader'
describe('useUploader Hook', () => {
let mockContainerRef: RefObject<HTMLDivElement | null>
let mockOnFileChange: (file: File | null) => void
let mockContainer: HTMLDivElement
beforeEach(() => {
vi.clearAllMocks()
mockContainer = document.createElement('div')
document.body.appendChild(mockContainer)
mockContainerRef = { current: mockContainer }
mockOnFileChange = vi.fn()
})
afterEach(() => {
if (mockContainer.parentNode)
document.body.removeChild(mockContainer)
})
describe('Initial State', () => {
it('should return initial state with dragging false', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
expect(result.current.dragging).toBe(false)
expect(result.current.fileUploader.current).toBeNull()
expect(result.current.fileChangeHandle).not.toBeNull()
expect(result.current.removeFile).not.toBeNull()
})
it('should return null handlers when disabled', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
enabled: false,
}),
)
expect(result.current.dragging).toBe(false)
expect(result.current.fileChangeHandle).toBeNull()
expect(result.current.removeFile).toBeNull()
})
})
describe('Drag Events', () => {
it('should handle dragenter and set dragging to true', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
value: { types: ['Files'] },
})
act(() => {
mockContainer.dispatchEvent(dragEnterEvent)
})
expect(result.current.dragging).toBe(true)
})
it('should not set dragging when dragenter without Files type', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
value: { types: ['text/plain'] },
})
act(() => {
mockContainer.dispatchEvent(dragEnterEvent)
})
expect(result.current.dragging).toBe(false)
})
it('should handle dragover event', () => {
renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true })
act(() => {
mockContainer.dispatchEvent(dragOverEvent)
})
// dragover should prevent default and stop propagation
expect(mockContainer).toBeInTheDocument()
})
it('should handle dragleave when relatedTarget is null', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
// First set dragging to true
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
value: { types: ['Files'] },
})
act(() => {
mockContainer.dispatchEvent(dragEnterEvent)
})
expect(result.current.dragging).toBe(true)
// Then trigger dragleave with null relatedTarget
const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true })
Object.defineProperty(dragLeaveEvent, 'relatedTarget', {
value: null,
})
act(() => {
mockContainer.dispatchEvent(dragLeaveEvent)
})
expect(result.current.dragging).toBe(false)
})
it('should handle dragleave when relatedTarget is outside container', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
// First set dragging to true
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
value: { types: ['Files'] },
})
act(() => {
mockContainer.dispatchEvent(dragEnterEvent)
})
expect(result.current.dragging).toBe(true)
// Create element outside container
const outsideElement = document.createElement('div')
document.body.appendChild(outsideElement)
// Trigger dragleave with relatedTarget outside container
const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true })
Object.defineProperty(dragLeaveEvent, 'relatedTarget', {
value: outsideElement,
})
act(() => {
mockContainer.dispatchEvent(dragLeaveEvent)
})
expect(result.current.dragging).toBe(false)
document.body.removeChild(outsideElement)
})
it('should not set dragging to false when relatedTarget is inside container', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
// First set dragging to true
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
value: { types: ['Files'] },
})
act(() => {
mockContainer.dispatchEvent(dragEnterEvent)
})
expect(result.current.dragging).toBe(true)
// Create element inside container
const insideElement = document.createElement('div')
mockContainer.appendChild(insideElement)
// Trigger dragleave with relatedTarget inside container
const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true })
Object.defineProperty(dragLeaveEvent, 'relatedTarget', {
value: insideElement,
})
act(() => {
mockContainer.dispatchEvent(dragLeaveEvent)
})
// Should still be dragging since relatedTarget is inside container
expect(result.current.dragging).toBe(true)
})
})
describe('Drop Events', () => {
it('should handle drop event with files', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
// First set dragging to true
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
value: { types: ['Files'] },
})
act(() => {
mockContainer.dispatchEvent(dragEnterEvent)
})
// Create mock file
const file = new File(['content'], 'test.difypkg', { type: 'application/octet-stream' })
// Trigger drop event
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: [file] },
})
act(() => {
mockContainer.dispatchEvent(dropEvent)
})
expect(result.current.dragging).toBe(false)
expect(mockOnFileChange).toHaveBeenCalledWith(file)
})
it('should not call onFileChange when drop has no dataTransfer', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
// Set dragging first
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
value: { types: ['Files'] },
})
act(() => {
mockContainer.dispatchEvent(dragEnterEvent)
})
// Drop without dataTransfer
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
// No dataTransfer property
act(() => {
mockContainer.dispatchEvent(dropEvent)
})
expect(result.current.dragging).toBe(false)
expect(mockOnFileChange).not.toHaveBeenCalled()
})
it('should not call onFileChange when drop has empty files array', () => {
renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: [] },
})
act(() => {
mockContainer.dispatchEvent(dropEvent)
})
expect(mockOnFileChange).not.toHaveBeenCalled()
})
})
describe('File Change Handler', () => {
it('should call onFileChange with file from input', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
const file = new File(['content'], 'test.difypkg', { type: 'application/octet-stream' })
const mockEvent = {
target: {
files: [file],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle?.(mockEvent)
})
expect(mockOnFileChange).toHaveBeenCalledWith(file)
})
it('should call onFileChange with null when no files', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
const mockEvent = {
target: {
files: null,
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle?.(mockEvent)
})
expect(mockOnFileChange).toHaveBeenCalledWith(null)
})
})
describe('Remove File', () => {
it('should call onFileChange with null', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
act(() => {
result.current.removeFile?.()
})
expect(mockOnFileChange).toHaveBeenCalledWith(null)
})
it('should handle removeFile when fileUploader has a value', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
// Create a mock input element with value property
const mockInput = {
value: 'test.difypkg',
}
// Override the fileUploader ref
Object.defineProperty(result.current.fileUploader, 'current', {
value: mockInput,
writable: true,
})
act(() => {
result.current.removeFile?.()
})
expect(mockOnFileChange).toHaveBeenCalledWith(null)
expect(mockInput.value).toBe('')
})
it('should handle removeFile when fileUploader is null', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
// fileUploader.current is null by default
act(() => {
result.current.removeFile?.()
})
expect(mockOnFileChange).toHaveBeenCalledWith(null)
})
})
describe('Enabled/Disabled State', () => {
it('should not add event listeners when disabled', () => {
const addEventListenerSpy = vi.spyOn(mockContainer, 'addEventListener')
renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
enabled: false,
}),
)
expect(addEventListenerSpy).not.toHaveBeenCalled()
})
it('should add event listeners when enabled', () => {
const addEventListenerSpy = vi.spyOn(mockContainer, 'addEventListener')
renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
enabled: true,
}),
)
expect(addEventListenerSpy).toHaveBeenCalledWith('dragenter', expect.any(Function))
expect(addEventListenerSpy).toHaveBeenCalledWith('dragover', expect.any(Function))
expect(addEventListenerSpy).toHaveBeenCalledWith('dragleave', expect.any(Function))
expect(addEventListenerSpy).toHaveBeenCalledWith('drop', expect.any(Function))
})
it('should remove event listeners on cleanup', () => {
const removeEventListenerSpy = vi.spyOn(mockContainer, 'removeEventListener')
const { unmount } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
enabled: true,
}),
)
unmount()
expect(removeEventListenerSpy).toHaveBeenCalledWith('dragenter', expect.any(Function))
expect(removeEventListenerSpy).toHaveBeenCalledWith('dragover', expect.any(Function))
expect(removeEventListenerSpy).toHaveBeenCalledWith('dragleave', expect.any(Function))
expect(removeEventListenerSpy).toHaveBeenCalledWith('drop', expect.any(Function))
})
it('should return false for dragging when disabled', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
enabled: false,
}),
)
expect(result.current.dragging).toBe(false)
})
})
describe('Container Ref Edge Cases', () => {
it('should handle null containerRef.current', () => {
const nullRef: RefObject<HTMLDivElement | null> = { current: null }
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: nullRef,
}),
)
expect(result.current.dragging).toBe(false)
})
})
})

View File

@ -0,0 +1,550 @@
import type { FetchWorkflowDraftResponse } from '@/types/workflow'
import { cleanup, render, screen } from '@testing-library/react'
import * as React from 'react'
import { BlockEnum } from '@/app/components/workflow/types'
// Import real utility functions (pure functions, no side effects)
// Import mocked modules for manipulation
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { usePipelineInit } from './hooks'
import RagPipelineWrapper from './index'
import { processNodesWithoutDataSource } from './utils'
// Mock: Context - need to control return values
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: vi.fn(),
}))
// Mock: Hook with API calls
vi.mock('./hooks', () => ({
usePipelineInit: vi.fn(),
}))
// Mock: Store creator
vi.mock('./store', () => ({
createRagPipelineSliceSlice: vi.fn(() => ({})),
}))
// Mock: Utility with complex workflow dependencies (generateNewNode, etc.)
vi.mock('./utils', () => ({
processNodesWithoutDataSource: vi.fn((nodes, viewport) => ({
nodes,
viewport,
})),
}))
// Mock: Complex component with useParams, Toast, API calls
vi.mock('./components/conversion', () => ({
default: () => <div data-testid="conversion-component">Conversion Component</div>,
}))
// Mock: Complex component with many hooks and workflow dependencies
vi.mock('./components/rag-pipeline-main', () => ({
default: ({ nodes, edges, viewport }: any) => (
<div data-testid="rag-pipeline-main">
<span data-testid="nodes-count">{nodes?.length ?? 0}</span>
<span data-testid="edges-count">{edges?.length ?? 0}</span>
<span data-testid="viewport-zoom">{viewport?.zoom ?? 'none'}</span>
</div>
),
}))
// Mock: Complex component with ReactFlow and many providers
vi.mock('@/app/components/workflow', () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-default-context">{children}</div>
),
}))
// Mock: Context provider
vi.mock('@/app/components/workflow/context', () => ({
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-context-provider">{children}</div>
),
}))
// Type assertions for mocked functions
const mockUseDatasetDetailContextWithSelector = vi.mocked(useDatasetDetailContextWithSelector)
const mockUsePipelineInit = vi.mocked(usePipelineInit)
const mockProcessNodesWithoutDataSource = vi.mocked(processNodesWithoutDataSource)
// Helper to mock selector with actual execution (increases function coverage)
// This executes the real selector function: s => s.dataset?.pipeline_id
const mockSelectorWithDataset = (pipelineId: string | null | undefined) => {
mockUseDatasetDetailContextWithSelector.mockImplementation((selector: (state: any) => any) => {
const mockState = { dataset: pipelineId ? { pipeline_id: pipelineId } : null }
return selector(mockState)
})
}
// Test data factory
const createMockWorkflowData = (overrides?: Partial<FetchWorkflowDraftResponse>): FetchWorkflowDraftResponse => ({
graph: {
nodes: [
{ id: 'node-1', type: 'custom', data: { type: BlockEnum.Start, title: 'Start' }, position: { x: 100, y: 100 } },
{ id: 'node-2', type: 'custom', data: { type: BlockEnum.End, title: 'End' }, position: { x: 300, y: 100 } },
],
edges: [
{ id: 'edge-1', source: 'node-1', target: 'node-2', type: 'custom' },
],
viewport: { x: 0, y: 0, zoom: 1 },
},
hash: 'test-hash-123',
updated_at: 1234567890,
tool_published: false,
environment_variables: [],
...overrides,
} as FetchWorkflowDraftResponse)
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('RagPipelineWrapper', () => {
describe('Rendering', () => {
it('should render Conversion component when pipelineId is null', () => {
mockSelectorWithDataset(null)
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('conversion-component')).toBeInTheDocument()
expect(screen.queryByTestId('workflow-context-provider')).not.toBeInTheDocument()
})
it('should render Conversion component when pipelineId is undefined', () => {
mockSelectorWithDataset(undefined)
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('conversion-component')).toBeInTheDocument()
})
it('should render Conversion component when pipelineId is empty string', () => {
mockSelectorWithDataset('')
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('conversion-component')).toBeInTheDocument()
})
it('should render WorkflowContextProvider when pipelineId exists', () => {
mockSelectorWithDataset('pipeline-123')
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
expect(screen.queryByTestId('conversion-component')).not.toBeInTheDocument()
})
})
describe('Props Variations', () => {
it('should pass injectWorkflowStoreSliceFn to WorkflowContextProvider', () => {
mockSelectorWithDataset('pipeline-456')
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
})
})
})
describe('RagPipeline', () => {
beforeEach(() => {
// Default setup for RagPipeline tests - execute real selector function
mockSelectorWithDataset('pipeline-123')
})
describe('Loading State', () => {
it('should render Loading component when isLoading is true', () => {
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
render(<RagPipelineWrapper />)
// Real Loading component has role="status"
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should render Loading component when data is undefined', () => {
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should render Loading component when both data is undefined and isLoading is true', () => {
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
render(<RagPipelineWrapper />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
})
describe('Data Loaded State', () => {
it('should render RagPipelineMain when data is loaded', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument()
expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
})
it('should pass processed nodes to RagPipelineMain', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('nodes-count').textContent).toBe('2')
})
it('should pass edges to RagPipelineMain', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('edges-count').textContent).toBe('1')
})
it('should pass viewport to RagPipelineMain', () => {
const mockData = createMockWorkflowData({
graph: {
nodes: [],
edges: [],
viewport: { x: 100, y: 200, zoom: 1.5 },
},
})
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('viewport-zoom').textContent).toBe('1.5')
})
})
describe('Memoization Logic', () => {
it('should process nodes through initialNodes when data is loaded', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
// initialNodes is a real function - verify nodes are rendered
// The real initialNodes processes nodes and adds position data
expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument()
})
it('should process edges through initialEdges when data is loaded', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
// initialEdges is a real function - verify component renders with edges
expect(screen.getByTestId('edges-count').textContent).toBe('1')
})
it('should call processNodesWithoutDataSource with nodesData and viewport', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(mockProcessNodesWithoutDataSource).toHaveBeenCalled()
})
it('should not process nodes when data is undefined', () => {
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false })
render(<RagPipelineWrapper />)
// When data is undefined, Loading is shown, processNodesWithoutDataSource is not called
expect(mockProcessNodesWithoutDataSource).not.toHaveBeenCalled()
})
it('should use memoized values when data reference is same', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
const { rerender } = render(<RagPipelineWrapper />)
// Clear mock call count after initial render
mockProcessNodesWithoutDataSource.mockClear()
// Rerender with same data reference (no change to mockUsePipelineInit)
rerender(<RagPipelineWrapper />)
// processNodesWithoutDataSource should not be called again due to useMemo
// Note: React strict mode may cause double render, so we check it's not excessive
expect(mockProcessNodesWithoutDataSource.mock.calls.length).toBeLessThanOrEqual(1)
})
})
describe('Edge Cases', () => {
it('should handle empty nodes array', () => {
const mockData = createMockWorkflowData({
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('nodes-count').textContent).toBe('0')
})
it('should handle empty edges array', () => {
const mockData = createMockWorkflowData({
graph: {
nodes: [{ id: 'node-1', type: 'custom', data: { type: BlockEnum.Start, title: 'Start', desc: '' }, position: { x: 0, y: 0 } }],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('edges-count').textContent).toBe('0')
})
it('should handle undefined viewport', () => {
const mockData = createMockWorkflowData({
graph: {
nodes: [],
edges: [],
viewport: undefined as any,
},
})
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument()
})
it('should handle null viewport', () => {
const mockData = createMockWorkflowData({
graph: {
nodes: [],
edges: [],
viewport: null as any,
},
})
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument()
})
it('should handle large number of nodes', () => {
const largeNodesArray = Array.from({ length: 100 }, (_, i) => ({
id: `node-${i}`,
type: 'custom',
data: { type: BlockEnum.Start, title: `Node ${i}`, desc: '' },
position: { x: i * 100, y: 0 },
}))
const mockData = createMockWorkflowData({
graph: {
nodes: largeNodesArray,
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('nodes-count').textContent).toBe('100')
})
it('should handle viewport with edge case zoom values', () => {
const mockData = createMockWorkflowData({
graph: {
nodes: [],
edges: [],
viewport: { x: -1000, y: -1000, zoom: 0.25 },
},
})
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('viewport-zoom').textContent).toBe('0.25')
})
it('should handle viewport with maximum zoom', () => {
const mockData = createMockWorkflowData({
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 4 },
},
})
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('viewport-zoom').textContent).toBe('4')
})
})
describe('Component Integration', () => {
it('should render WorkflowWithDefaultContext as wrapper', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('workflow-default-context')).toBeInTheDocument()
})
it('should nest RagPipelineMain inside WorkflowWithDefaultContext', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
const workflowContext = screen.getByTestId('workflow-default-context')
const ragPipelineMain = screen.getByTestId('rag-pipeline-main')
expect(workflowContext).toContainElement(ragPipelineMain)
})
})
})
describe('processNodesWithoutDataSource utility integration', () => {
beforeEach(() => {
mockSelectorWithDataset('pipeline-123')
})
it('should process nodes through processNodesWithoutDataSource', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
mockProcessNodesWithoutDataSource.mockReturnValue({
nodes: [{ id: 'processed-node', type: 'custom', data: { type: BlockEnum.Start, title: 'Processed', desc: '' }, position: { x: 0, y: 0 } }] as any,
viewport: { x: 0, y: 0, zoom: 2 },
})
render(<RagPipelineWrapper />)
expect(mockProcessNodesWithoutDataSource).toHaveBeenCalled()
expect(screen.getByTestId('nodes-count').textContent).toBe('1')
expect(screen.getByTestId('viewport-zoom').textContent).toBe('2')
})
it('should handle processNodesWithoutDataSource returning modified viewport', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
mockProcessNodesWithoutDataSource.mockReturnValue({
nodes: [],
viewport: { x: 500, y: 500, zoom: 0.5 },
})
render(<RagPipelineWrapper />)
expect(screen.getByTestId('viewport-zoom').textContent).toBe('0.5')
})
})
describe('Conditional Rendering Flow', () => {
it('should transition from loading to loaded state', () => {
mockSelectorWithDataset('pipeline-123')
// Start with loading state
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
const { rerender } = render(<RagPipelineWrapper />)
// Real Loading component has role="status"
expect(screen.getByRole('status')).toBeInTheDocument()
// Transition to loaded state
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
rerender(<RagPipelineWrapper />)
expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument()
})
it('should switch from Conversion to Pipeline when pipelineId becomes available', () => {
// Start without pipelineId
mockSelectorWithDataset(null)
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false })
const { rerender } = render(<RagPipelineWrapper />)
expect(screen.getByTestId('conversion-component')).toBeInTheDocument()
// PipelineId becomes available
mockSelectorWithDataset('new-pipeline-id')
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
rerender(<RagPipelineWrapper />)
expect(screen.queryByTestId('conversion-component')).not.toBeInTheDocument()
// Real Loading component has role="status"
expect(screen.getByRole('status')).toBeInTheDocument()
})
})
describe('Error Handling', () => {
beforeEach(() => {
mockSelectorWithDataset('pipeline-123')
})
it('should throw when graph nodes is null', () => {
const mockData = {
graph: {
nodes: null as any,
edges: null as any,
viewport: { x: 0, y: 0, zoom: 1 },
},
hash: 'test',
updated_at: 123,
} as FetchWorkflowDraftResponse
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
// Suppress console.error for expected error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
// Real initialNodes will throw when nodes is null
// This documents the component's current behavior - it requires valid nodes array
expect(() => render(<RagPipelineWrapper />)).toThrow()
consoleSpy.mockRestore()
})
it('should throw when graph property is missing', () => {
const mockData = {
hash: 'test',
updated_at: 123,
} as unknown as FetchWorkflowDraftResponse
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
// Suppress console.error for expected error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
// When graph is undefined, component throws because data.graph.nodes is accessed
// This documents the component's current behavior - it requires graph to be present
expect(() => render(<RagPipelineWrapper />)).toThrow()
consoleSpy.mockRestore()
})
})