refactor: enhance plugin management UI with error handling, improved rendering, and new components

This commit is contained in:
CodingOnStar
2026-03-06 16:27:26 +08:00
parent ff4e4a8d64
commit fced2f9e65
18 changed files with 1795 additions and 171 deletions

View File

@ -0,0 +1,348 @@
import type { PluginInfoFromMarketPlace, PluginStatus } from '@/app/components/plugins/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { PluginCategoryEnum, TaskStatus } from '@/app/components/plugins/types'
import { fetchPluginInfoFromMarketPlace } from '@/service/plugins'
import ErrorPluginItem from '../error-plugin-item'
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: ({ src, size }: { src: string, size: string }) => (
<div data-testid="card-icon" data-src={src} data-size={size} />
),
}))
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
default: ({ uniqueIdentifier, onClose, onSuccess }: { uniqueIdentifier: string, onClose: () => void, onSuccess: () => void }) => (
<div data-testid="install-modal" data-uid={uniqueIdentifier}>
<button onClick={onClose}>Close modal</button>
<button onClick={onSuccess}>Success</button>
</div>
),
}))
vi.mock('@/service/plugins', () => ({
fetchPluginInfoFromMarketPlace: vi.fn(),
}))
const mockFetch = vi.mocked(fetchPluginInfoFromMarketPlace)
const mockGetIconUrl = vi.fn((icon: string) => `https://icons/${icon}`)
function createMarketplaceResponse(identifier: string, version: string) {
return {
data: {
plugin: {
category: PluginCategoryEnum.tool,
latest_package_identifier: identifier,
latest_version: version,
} satisfies PluginInfoFromMarketPlace,
version: { version },
},
}
}
const createPlugin = (overrides: Partial<PluginStatus> = {}): PluginStatus => ({
plugin_unique_identifier: 'org/plugin:1.0.0',
plugin_id: 'org/plugin',
status: TaskStatus.failed,
message: '',
icon: 'icon.png',
labels: { en_US: 'Test Plugin' } as PluginStatus['labels'],
taskId: 'task-1',
...overrides,
})
describe('ErrorPluginItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render plugin name', () => {
render(
<ErrorPluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.getByText('Test Plugin')).toBeInTheDocument()
})
it('should render error status icon', () => {
const { container } = render(
<ErrorPluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(container.querySelector('.i-ri-error-warning-fill')).toBeInTheDocument()
})
it('should apply destructive text styling', () => {
render(
<ErrorPluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
const errorText = screen.getByText(/plugin\.task\.errorMsg\.marketplace/i)
expect(errorText.closest('.text-text-destructive')).toBeInTheDocument()
})
})
describe('Source detection and error messages', () => {
it('should show marketplace error message for marketplace plugins', () => {
render(
<ErrorPluginItem
plugin={createPlugin({ plugin_id: 'org/my-plugin' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.getByText(/plugin\.task\.errorMsg\.marketplace/)).toBeInTheDocument()
})
it('should show github error message for github plugins', () => {
render(
<ErrorPluginItem
plugin={createPlugin({ plugin_id: 'https://github.com/user/repo' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.getByText(/plugin\.task\.errorMsg\.github/)).toBeInTheDocument()
})
it('should show unknown error message for unknown source plugins', () => {
render(
<ErrorPluginItem
plugin={createPlugin({ plugin_id: 'local-only-plugin' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.getByText(/plugin\.task\.errorMsg\.unknown/)).toBeInTheDocument()
})
it('should show plugin.message when available instead of default error', () => {
render(
<ErrorPluginItem
plugin={createPlugin({ message: 'Custom error occurred' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.getByText('Custom error occurred')).toBeInTheDocument()
})
})
describe('Action buttons', () => {
it('should show "Install from Marketplace" button for marketplace plugins', () => {
render(
<ErrorPluginItem
plugin={createPlugin({ plugin_id: 'org/my-plugin' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.getByText(/plugin\.task\.installFromMarketplace/)).toBeInTheDocument()
})
it('should show "Install from GitHub" button for github plugins', () => {
render(
<ErrorPluginItem
plugin={createPlugin({ plugin_id: 'https://github.com/user/repo' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.getByText(/plugin\.task\.installFromGithub/)).toBeInTheDocument()
})
it('should not show action button for unknown source plugins', () => {
render(
<ErrorPluginItem
plugin={createPlugin({ plugin_id: 'local-only-plugin' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.queryByText(/plugin\.task\.installFrom/)).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClear when clear button is clicked', () => {
const onClear = vi.fn()
render(
<ErrorPluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={onClear}
/>,
)
// The clear button (×) is from PluginItem
const buttons = screen.getAllByRole('button')
const clearButton = buttons.find(btn => !btn.textContent?.includes('plugin.task'))
fireEvent.click(clearButton!)
expect(onClear).toHaveBeenCalledTimes(1)
})
it('should fetch marketplace info and show install modal on button click', async () => {
mockFetch.mockResolvedValueOnce(createMarketplaceResponse('org/my-plugin:2.0.0', '2.0.0'))
render(
<ErrorPluginItem
plugin={createPlugin({ plugin_id: 'org/my-plugin' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
fireEvent.click(screen.getByText(/plugin\.task\.installFromMarketplace/))
await waitFor(() => {
expect(screen.getByTestId('install-modal')).toBeInTheDocument()
})
expect(mockFetch).toHaveBeenCalledWith({ org: 'org', name: 'my-plugin' })
expect(screen.getByTestId('install-modal')).toHaveAttribute('data-uid', 'org/my-plugin:2.0.0')
})
it('should close install modal when onClose is called', async () => {
mockFetch.mockResolvedValueOnce(createMarketplaceResponse('org/my-plugin:2.0.0', '2.0.0'))
render(
<ErrorPluginItem
plugin={createPlugin({ plugin_id: 'org/my-plugin' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
fireEvent.click(screen.getByText(/plugin\.task\.installFromMarketplace/))
await waitFor(() => {
expect(screen.getByTestId('install-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('Close modal'))
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
})
it('should silently handle fetch failure', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'))
render(
<ErrorPluginItem
plugin={createPlugin({ plugin_id: 'org/my-plugin' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
fireEvent.click(screen.getByText(/plugin\.task\.installFromMarketplace/))
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled()
})
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
})
it('should not fetch when plugin_id has fewer than 2 parts', async () => {
render(
<ErrorPluginItem
plugin={createPlugin({ plugin_id: 'single-part' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
// Unknown source won't render the marketplace button, so nothing to click
expect(screen.queryByText(/plugin\.task\.installFromMarketplace/)).not.toBeInTheDocument()
expect(mockFetch).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should detect github source with github in URL', () => {
render(
<ErrorPluginItem
plugin={createPlugin({ plugin_id: 'http://github.com/user/repo' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.getByText(/plugin\.task\.installFromGithub/)).toBeInTheDocument()
})
it('should close install modal when onSuccess is called', async () => {
mockFetch.mockResolvedValueOnce(createMarketplaceResponse('org/p:1.0.0', '1.0.0'))
render(
<ErrorPluginItem
plugin={createPlugin({ plugin_id: 'org/p' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
fireEvent.click(screen.getByText(/plugin\.task\.installFromMarketplace/))
await waitFor(() => {
expect(screen.getByTestId('install-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('Success'))
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
})
it('should detect github source when id contains github keyword', () => {
render(
<ErrorPluginItem
plugin={createPlugin({ plugin_id: 'my-github-plugin' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.getByText(/plugin\.task\.installFromGithub/)).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,202 @@
import type { PluginStatus } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { TaskStatus } from '@/app/components/plugins/types'
import PluginItem from '../plugin-item'
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: ({ src, size }: { src: string, size: string }) => (
<div data-testid="card-icon" data-src={src} data-size={size} />
),
}))
const mockGetIconUrl = vi.fn((icon: string) => `https://example.com/icons/${icon}`)
const createPlugin = (overrides: Partial<PluginStatus> = {}): PluginStatus => ({
plugin_unique_identifier: 'org/plugin:1.0.0',
plugin_id: 'org/plugin',
status: TaskStatus.running,
message: '',
icon: 'icon.png',
labels: {
en_US: 'Test Plugin',
zh_Hans: '测试插件',
} as PluginStatus['labels'],
taskId: 'task-1',
...overrides,
})
describe('PluginItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render plugin name based on language', () => {
render(
<PluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span data-testid="status-icon" />}
statusText="Installing..."
/>,
)
expect(screen.getByText('Test Plugin')).toBeInTheDocument()
})
it('should render status text', () => {
render(
<PluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span data-testid="status-icon" />}
statusText="Installing... please wait"
/>,
)
expect(screen.getByText('Installing... please wait')).toBeInTheDocument()
})
it('should render status icon', () => {
render(
<PluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span data-testid="status-icon" />}
statusText="status"
/>,
)
expect(screen.getByTestId('status-icon')).toBeInTheDocument()
})
it('should pass icon url to CardIcon', () => {
render(
<PluginItem
plugin={createPlugin({ icon: 'my-icon.svg' })}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span />}
statusText="status"
/>,
)
expect(mockGetIconUrl).toHaveBeenCalledWith('my-icon.svg')
const cardIcon = screen.getByTestId('card-icon')
expect(cardIcon).toHaveAttribute('data-src', 'https://example.com/icons/my-icon.svg')
expect(cardIcon).toHaveAttribute('data-size', 'small')
})
})
describe('Props', () => {
it('should apply custom statusClassName', () => {
render(
<PluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span />}
statusText="done"
statusClassName="text-text-success"
/>,
)
expect(screen.getByText('done').className).toContain('text-text-success')
})
it('should apply default statusClassName when not provided', () => {
render(
<PluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span />}
statusText="done"
/>,
)
expect(screen.getByText('done').className).toContain('text-text-tertiary')
})
it('should render action when provided', () => {
render(
<PluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span />}
statusText="status"
action={<button>Install</button>}
/>,
)
expect(screen.getByRole('button', { name: /install/i })).toBeInTheDocument()
})
it('should not render action when not provided', () => {
render(
<PluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span />}
statusText="status"
/>,
)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('should render zh-Hans label when language is zh_Hans', () => {
render(
<PluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="zh_Hans"
statusIcon={<span />}
statusText="status"
/>,
)
expect(screen.getByText('测试插件')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should render clear button when onClear is provided', () => {
const handleClear = vi.fn()
render(
<PluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span />}
statusText="status"
onClear={handleClear}
/>,
)
const clearButton = screen.getByRole('button')
fireEvent.click(clearButton)
expect(handleClear).toHaveBeenCalledTimes(1)
})
it('should not render clear button when onClear is not provided', () => {
render(
<PluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span />}
statusText="status"
/>,
)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,163 @@
import type { PluginStatus } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { TaskStatus } from '@/app/components/plugins/types'
import PluginSection from '../plugin-section'
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: ({ src, size }: { src: string, size: string }) => (
<div data-testid="card-icon" data-src={src} data-size={size} />
),
}))
const mockGetIconUrl = vi.fn((icon: string) => `https://icons/${icon}`)
const createPlugin = (id: string, name: string, message = ''): PluginStatus => ({
plugin_unique_identifier: id,
plugin_id: `org/${name.toLowerCase()}`,
status: TaskStatus.running,
message,
icon: `${name.toLowerCase()}.png`,
labels: { en_US: name, zh_Hans: name } as PluginStatus['labels'],
taskId: 'task-1',
})
const defaultProps = {
title: 'Installing plugins',
count: 2,
plugins: [createPlugin('p1', 'PluginA'), createPlugin('p2', 'PluginB')],
getIconUrl: mockGetIconUrl,
language: 'en_US' as const,
statusIcon: <span data-testid="status-icon" />,
defaultStatusText: 'Default status',
}
describe('PluginSection', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render title and count', () => {
render(<PluginSection {...defaultProps} />)
expect(screen.getByText(/installing plugins/i)).toBeInTheDocument()
expect(screen.getByText(/installing plugins/i).textContent).toContain('2')
})
it('should render all plugin items', () => {
render(<PluginSection {...defaultProps} />)
expect(screen.getByText('PluginA')).toBeInTheDocument()
expect(screen.getByText('PluginB')).toBeInTheDocument()
})
it('should render status icons for each plugin', () => {
render(<PluginSection {...defaultProps} />)
expect(screen.getAllByTestId('status-icon')).toHaveLength(2)
})
})
describe('Props', () => {
it('should return null when plugins array is empty', () => {
const { container } = render(
<PluginSection {...defaultProps} plugins={[]} />,
)
expect(container.innerHTML).toBe('')
})
it('should use plugin.message as statusText when available', () => {
const plugins = [createPlugin('p1', 'PluginA', 'Custom message')]
render(<PluginSection {...defaultProps} plugins={plugins} count={1} />)
expect(screen.getByText('Custom message')).toBeInTheDocument()
})
it('should use defaultStatusText when plugin has no message', () => {
const plugins = [createPlugin('p1', 'PluginA', '')]
render(<PluginSection {...defaultProps} plugins={plugins} count={1} />)
expect(screen.getByText('Default status')).toBeInTheDocument()
})
it('should apply statusClassName to items', () => {
const plugins = [createPlugin('p1', 'PluginA')]
render(
<PluginSection
{...defaultProps}
plugins={plugins}
count={1}
statusClassName="text-text-success"
/>,
)
expect(screen.getByText('Default status').className).toContain('text-text-success')
})
it('should render headerAction when provided', () => {
render(
<PluginSection
{...defaultProps}
headerAction={<button>Clear all</button>}
/>,
)
expect(screen.getByRole('button', { name: /clear all/i })).toBeInTheDocument()
})
it('should not render headerAction when not provided', () => {
render(<PluginSection {...defaultProps} />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('should render item actions via renderItemAction', () => {
render(
<PluginSection
{...defaultProps}
renderItemAction={plugin => (
<button>{`Action ${plugin.labels.en_US}`}</button>
)}
/>,
)
expect(screen.getByRole('button', { name: /action plugina/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /action pluginb/i })).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClearSingle with taskId and plugin identifier', () => {
const onClearSingle = vi.fn()
render(
<PluginSection
{...defaultProps}
onClearSingle={onClearSingle}
/>,
)
// Clear buttons are rendered when onClearSingle is provided
const clearButtons = screen.getAllByRole('button')
fireEvent.click(clearButtons[0])
expect(onClearSingle).toHaveBeenCalledWith('task-1', 'p1')
})
it('should not render clear buttons when onClearSingle is not provided', () => {
render(<PluginSection {...defaultProps} />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle single plugin', () => {
const plugins = [createPlugin('p1', 'Solo')]
render(<PluginSection {...defaultProps} plugins={plugins} count={1} />)
expect(screen.getByText('Solo')).toBeInTheDocument()
expect(screen.getByText(/solo/i).closest('.max-h-\\[300px\\]')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,250 @@
import type { PluginStatus } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { TaskStatus } from '@/app/components/plugins/types'
import PluginTaskList from '../plugin-task-list'
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: ({ src, size }: { src: string, size: string }) => (
<div data-testid="card-icon" data-src={src} data-size={size} />
),
}))
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
default: () => <div data-testid="install-modal" />,
}))
vi.mock('@/service/plugins', () => ({
fetchPluginInfoFromMarketPlace: vi.fn(),
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en_US',
}))
const mockGetIconUrl = vi.fn((icon: string) => `https://icons/${icon}`)
const createPlugin = (id: string, name: string, overrides: Partial<PluginStatus> = {}): PluginStatus => ({
plugin_unique_identifier: id,
plugin_id: `org/${name.toLowerCase()}`,
status: TaskStatus.running,
message: '',
icon: `${name.toLowerCase()}.png`,
labels: { en_US: name } as PluginStatus['labels'],
taskId: 'task-1',
...overrides,
})
const runningPlugins = [
createPlugin('r1', 'OpenAI', { status: TaskStatus.running }),
createPlugin('r2', 'Anthropic', { status: TaskStatus.running }),
]
const successPlugins = [
createPlugin('s1', 'Google', { status: TaskStatus.success }),
]
const errorPlugins = [
createPlugin('e1', 'DALLE', { status: TaskStatus.failed, plugin_id: 'org/dalle' }),
]
describe('PluginTaskList', () => {
const defaultProps = {
runningPlugins: [] as PluginStatus[],
successPlugins: [] as PluginStatus[],
errorPlugins: [] as PluginStatus[],
getIconUrl: mockGetIconUrl,
onClearAll: vi.fn(),
onClearErrors: vi.fn(),
onClearSingle: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render empty container when no plugins', () => {
const { container } = render(<PluginTaskList {...defaultProps} />)
const wrapper = container.firstElementChild!
expect(wrapper).toBeInTheDocument()
expect(wrapper.children).toHaveLength(0)
})
it('should render running section when running plugins exist', () => {
const { container } = render(<PluginTaskList {...defaultProps} runningPlugins={runningPlugins} />)
// Header contains the title text
const headers = container.querySelectorAll('.system-sm-semibold-uppercase')
expect(headers).toHaveLength(1)
expect(headers[0].textContent).toContain('plugin.task.installing')
expect(screen.getByText('OpenAI')).toBeInTheDocument()
expect(screen.getByText('Anthropic')).toBeInTheDocument()
})
it('should render success section when success plugins exist', () => {
const { container } = render(<PluginTaskList {...defaultProps} successPlugins={successPlugins} />)
const headers = container.querySelectorAll('.system-sm-semibold-uppercase')
expect(headers).toHaveLength(1)
expect(headers[0].textContent).toContain('plugin.task.installed')
expect(screen.getByText('Google')).toBeInTheDocument()
})
it('should render error section when error plugins exist', () => {
const { container } = render(<PluginTaskList {...defaultProps} errorPlugins={errorPlugins} />)
const headers = container.querySelectorAll('.system-sm-semibold-uppercase')
expect(headers).toHaveLength(1)
expect(headers[0].textContent).toContain('plugin.task.installedError')
expect(screen.getByText('DALLE')).toBeInTheDocument()
})
it('should render all three sections simultaneously', () => {
render(
<PluginTaskList
{...defaultProps}
runningPlugins={runningPlugins}
successPlugins={successPlugins}
errorPlugins={errorPlugins}
/>,
)
expect(screen.getByText('OpenAI')).toBeInTheDocument()
expect(screen.getByText('Google')).toBeInTheDocument()
expect(screen.getByText('DALLE')).toBeInTheDocument()
})
})
describe('Clear actions', () => {
it('should show Clear all button in success section', () => {
render(<PluginTaskList {...defaultProps} successPlugins={successPlugins} />)
const clearButtons = screen.getAllByText(/plugin\.task\.clearAll/)
expect(clearButtons).toHaveLength(1)
})
it('should call onClearAll when success section Clear all is clicked', () => {
const onClearAll = vi.fn()
render(
<PluginTaskList
{...defaultProps}
successPlugins={successPlugins}
onClearAll={onClearAll}
/>,
)
fireEvent.click(screen.getByText(/plugin\.task\.clearAll/))
expect(onClearAll).toHaveBeenCalledTimes(1)
})
it('should show Clear all button in error section', () => {
render(<PluginTaskList {...defaultProps} errorPlugins={errorPlugins} />)
const clearButtons = screen.getAllByText(/plugin\.task\.clearAll/)
expect(clearButtons).toHaveLength(1)
})
it('should call onClearErrors when error section Clear all is clicked', () => {
const onClearErrors = vi.fn()
render(
<PluginTaskList
{...defaultProps}
errorPlugins={errorPlugins}
onClearErrors={onClearErrors}
/>,
)
fireEvent.click(screen.getByText(/plugin\.task\.clearAll/))
expect(onClearErrors).toHaveBeenCalledTimes(1)
})
it('should call onClearSingle from success section clear button', () => {
const onClearSingle = vi.fn()
render(
<PluginTaskList
{...defaultProps}
successPlugins={successPlugins}
onClearSingle={onClearSingle}
/>,
)
// The × close button from PluginItem (rendered inside PluginSection)
const closeButtons = screen.getAllByRole('button')
const clearItemBtn = closeButtons.find(btn => !btn.textContent?.includes('plugin.task'))
if (clearItemBtn)
fireEvent.click(clearItemBtn)
expect(onClearSingle).toHaveBeenCalledWith('task-1', 's1')
})
})
describe('Running section', () => {
it('should not render clear buttons for running plugins', () => {
render(<PluginTaskList {...defaultProps} runningPlugins={runningPlugins} />)
// Running section has no headerAction and no onClearSingle
expect(screen.queryByText(/plugin\.task\.clearAll/)).not.toBeInTheDocument()
})
it('should show installing hint as status text', () => {
render(<PluginTaskList {...defaultProps} runningPlugins={runningPlugins} />)
// defaultStatusText is t('task.installingHint', { ns: 'plugin' })
const hintTexts = screen.getAllByText(/plugin\.task\.installingHint/)
expect(hintTexts.length).toBeGreaterThan(0)
})
})
describe('Error section clear single', () => {
it('should call onClearSingle from error item clear button', () => {
const onClearSingle = vi.fn()
render(
<PluginTaskList
{...defaultProps}
errorPlugins={errorPlugins}
onClearSingle={onClearSingle}
/>,
)
// Find the × close button inside error items (not the "Clear all" button)
const allButtons = screen.getAllByRole('button')
const clearItemBtn = allButtons.find(btn =>
!btn.textContent?.includes('plugin.task')
&& !btn.textContent?.includes('installFrom'),
)
if (clearItemBtn)
fireEvent.click(clearItemBtn)
expect(onClearSingle).toHaveBeenCalledWith('task-1', 'e1')
})
})
describe('Edge Cases', () => {
it('should not render sections for empty plugin arrays', () => {
const { container } = render(
<PluginTaskList
{...defaultProps}
runningPlugins={[]}
successPlugins={[]}
errorPlugins={[]}
/>,
)
expect(container.querySelector('.w-\\[360px\\]')!.children).toHaveLength(0)
})
it('should render error section with multiple error plugins', () => {
const multipleErrors = [
createPlugin('e1', 'PluginA', { status: TaskStatus.failed, plugin_id: 'org/a' }),
createPlugin('e2', 'PluginB', { status: TaskStatus.failed, plugin_id: 'https://github.com/b' }),
createPlugin('e3', 'PluginC', { status: TaskStatus.failed, plugin_id: 'local-only' }),
]
render(<PluginTaskList {...defaultProps} errorPlugins={multipleErrors} />)
expect(screen.getByText('PluginA')).toBeInTheDocument()
expect(screen.getByText('PluginB')).toBeInTheDocument()
expect(screen.getByText('PluginC')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,237 @@
import { fireEvent, render, screen } from '@testing-library/react'
import TaskStatusIndicator from '../task-status-indicator'
vi.mock('@/app/components/base/progress-bar/progress-circle', () => ({
default: ({ percentage }: { percentage: number }) => (
<div data-testid="progress-circle" data-percentage={percentage} />
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
<div data-testid="tooltip" data-tip={popupContent}>{children}</div>
),
}))
vi.mock('@/app/components/header/plugins-nav/downloading-icon', () => ({
default: () => <span data-testid="downloading-icon" />,
}))
const defaultProps = {
tip: 'Installing plugins',
isInstalling: false,
isInstallingWithSuccess: false,
isInstallingWithError: false,
isSuccess: false,
isFailed: false,
successPluginsLength: 0,
runningPluginsLength: 0,
totalPluginsLength: 0,
onClick: vi.fn(),
}
describe('TaskStatusIndicator', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<TaskStatusIndicator {...defaultProps} />)
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
})
it('should pass tip to tooltip', () => {
render(<TaskStatusIndicator {...defaultProps} tip="My tip" />)
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-tip', 'My tip')
})
it('should render install icon by default', () => {
const { container } = render(<TaskStatusIndicator {...defaultProps} />)
// RiInstallLine renders as svg
expect(container.querySelector('svg')).toBeInTheDocument()
expect(screen.queryByTestId('downloading-icon')).not.toBeInTheDocument()
})
})
describe('Installing state', () => {
it('should show downloading icon when isInstalling', () => {
render(<TaskStatusIndicator {...defaultProps} isInstalling />)
expect(screen.getByTestId('downloading-icon')).toBeInTheDocument()
})
it('should show downloading icon when isInstallingWithError', () => {
render(<TaskStatusIndicator {...defaultProps} isInstallingWithError />)
expect(screen.getByTestId('downloading-icon')).toBeInTheDocument()
})
it('should show progress circle when isInstalling', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isInstalling
successPluginsLength={2}
totalPluginsLength={5}
/>,
)
const progress = screen.getByTestId('progress-circle')
expect(progress).toHaveAttribute('data-percentage', '40')
})
it('should show progress circle when isInstallingWithSuccess', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isInstallingWithSuccess
successPluginsLength={3}
totalPluginsLength={4}
/>,
)
const progress = screen.getByTestId('progress-circle')
expect(progress).toHaveAttribute('data-percentage', '75')
})
it('should show error progress circle when isInstallingWithError', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isInstallingWithError
runningPluginsLength={1}
totalPluginsLength={3}
/>,
)
const progress = screen.getByTestId('progress-circle')
expect(progress).toBeInTheDocument()
})
it('should handle zero totalPluginsLength without division error', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isInstalling
totalPluginsLength={0}
/>,
)
const progress = screen.getByTestId('progress-circle')
expect(progress).toHaveAttribute('data-percentage', '0')
})
})
describe('Success state', () => {
it('should show success icon when isSuccess', () => {
const { container } = render(
<TaskStatusIndicator
{...defaultProps}
isSuccess
successPluginsLength={3}
totalPluginsLength={3}
/>,
)
// RiCheckboxCircleFill is rendered as svg with text-text-success
const successIcon = container.querySelector('.text-text-success')
expect(successIcon).toBeInTheDocument()
})
it('should show success icon when successPlugins > 0 and no running plugins', () => {
const { container } = render(
<TaskStatusIndicator
{...defaultProps}
successPluginsLength={2}
runningPluginsLength={0}
totalPluginsLength={2}
/>,
)
const successIcon = container.querySelector('.text-text-success')
expect(successIcon).toBeInTheDocument()
})
it('should not show success icon during installing states', () => {
const { container } = render(
<TaskStatusIndicator
{...defaultProps}
isInstalling
successPluginsLength={1}
runningPluginsLength={1}
totalPluginsLength={2}
/>,
)
// Progress circle shown instead of success icon
expect(screen.getByTestId('progress-circle')).toBeInTheDocument()
expect(container.querySelector('.text-text-success')).not.toBeInTheDocument()
})
})
describe('Failed state', () => {
it('should show error icon when isFailed', () => {
const { container } = render(
<TaskStatusIndicator
{...defaultProps}
isFailed
totalPluginsLength={2}
/>,
)
const errorIcon = container.querySelector('.text-text-destructive')
expect(errorIcon).toBeInTheDocument()
})
it('should apply destructive styling when isFailed', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isFailed
totalPluginsLength={1}
/>,
)
const button = document.getElementById('plugin-task-trigger')!
expect(button.className).toContain('bg-state-destructive-hover')
})
it('should apply destructive styling when isInstallingWithError', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isInstallingWithError
totalPluginsLength={2}
/>,
)
const button = document.getElementById('plugin-task-trigger')!
expect(button.className).toContain('bg-state-destructive-hover')
})
})
describe('User Interactions', () => {
it('should call onClick when clicked', () => {
const onClick = vi.fn()
render(<TaskStatusIndicator {...defaultProps} onClick={onClick} />)
const button = document.getElementById('plugin-task-trigger')!
fireEvent.click(button)
expect(onClick).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should apply cursor-pointer for interactive states', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isSuccess
successPluginsLength={1}
totalPluginsLength={1}
/>,
)
const button = document.getElementById('plugin-task-trigger')!
expect(button.className).toContain('cursor-pointer')
})
it('should not show any badge indicators when all flags are false', () => {
render(<TaskStatusIndicator {...defaultProps} />)
expect(screen.queryByTestId('progress-circle')).not.toBeInTheDocument()
const button = document.getElementById('plugin-task-trigger')!
// No success or error icons in the badge area
expect(button.querySelector('.text-text-success')).not.toBeInTheDocument()
expect(button.querySelector('.text-text-destructive')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,140 @@
import type { FC } from 'react'
import type { Plugin, PluginStatus } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import { fetchPluginInfoFromMarketPlace } from '@/service/plugins'
import PluginItem from './plugin-item'
type PluginSource = 'marketplace' | 'github' | 'unknown'
function getPluginSource(pluginId: string): PluginSource {
if (pluginId.includes('/') && !pluginId.startsWith('http'))
return 'marketplace'
if (pluginId.startsWith('http') || pluginId.includes('github'))
return 'github'
return 'unknown'
}
type ErrorPluginItemProps = {
plugin: PluginStatus
getIconUrl: (icon: string) => string
language: Locale
onClear: () => void
}
const ErrorPluginItem: FC<ErrorPluginItemProps> = ({ plugin, getIconUrl, language, onClear }) => {
const { t } = useTranslation()
const source = getPluginSource(plugin.plugin_id)
const [showInstallModal, setShowInstallModal] = useState(false)
const [installPayload, setInstallPayload] = useState<{ uniqueIdentifier: string, manifest: Plugin } | null>(null)
const [isFetching, setIsFetching] = useState(false)
const handleInstallFromMarketplace = useCallback(async () => {
const parts = plugin.plugin_id.split('/')
if (parts.length < 2)
return
const [org, name] = parts
setIsFetching(true)
try {
const response = await fetchPluginInfoFromMarketPlace({ org, name })
const info = response.data.plugin
const manifest: Plugin = {
plugin_id: plugin.plugin_id,
type: info.category as Plugin['type'],
category: info.category,
name,
org,
version: info.latest_version,
latest_version: info.latest_version,
latest_package_identifier: info.latest_package_identifier,
label: plugin.labels,
brief: {},
description: {},
icon: plugin.icon,
verified: true,
introduction: '',
repository: '',
install_count: 0,
endpoint: { settings: [] },
tags: [],
badges: [],
verification: { authorized_category: 'langgenius' },
from: 'marketplace',
}
setInstallPayload({ uniqueIdentifier: info.latest_package_identifier, manifest })
setShowInstallModal(true)
}
catch {
// silently fail
}
finally {
setIsFetching(false)
}
}, [plugin.plugin_id, plugin.labels, plugin.icon])
const errorMsgKey = {
marketplace: 'task.errorMsg.marketplace',
github: 'task.errorMsg.github',
unknown: 'task.errorMsg.unknown',
}[source] as 'task.errorMsg.marketplace'
const errorMsg = t(errorMsgKey, { ns: 'plugin' })
const renderAction = () => {
if (source === 'marketplace') {
return (
<div className="pt-1">
<Button variant="secondary" size="small" loading={isFetching} onClick={handleInstallFromMarketplace}>
{t('task.installFromMarketplace', { ns: 'plugin' })}
</Button>
</div>
)
}
if (source === 'github') {
return (
<div className="pt-1">
<Button variant="secondary" size="small">
{t('task.installFromGithub', { ns: 'plugin' })}
</Button>
</div>
)
}
return undefined
}
return (
<>
<PluginItem
plugin={plugin}
getIconUrl={getIconUrl}
language={language}
statusIcon={(
<span className="flex h-4 w-4 items-center justify-center rounded-full border border-components-panel-bg bg-components-panel-bg">
<span className="i-ri-error-warning-fill h-4 w-4 text-text-destructive" />
</span>
)}
statusText={(
<span className="whitespace-pre-line">
{plugin.message || errorMsg}
</span>
)}
statusClassName="text-text-destructive"
action={renderAction()}
onClear={onClear}
/>
{showInstallModal && installPayload && (
<InstallFromMarketplace
uniqueIdentifier={installPayload.uniqueIdentifier}
manifest={installPayload.manifest}
onClose={() => setShowInstallModal(false)}
onSuccess={() => setShowInstallModal(false)}
/>
)}
</>
)
}
export default ErrorPluginItem

View File

@ -0,0 +1,59 @@
import type { FC, ReactNode } from 'react'
import type { PluginStatus } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import CardIcon from '@/app/components/plugins/card/base/card-icon'
export type PluginItemProps = {
plugin: PluginStatus
getIconUrl: (icon: string) => string
language: Locale
statusIcon: ReactNode
statusText: ReactNode
statusClassName?: string
action?: ReactNode
onClear?: () => void
}
const PluginItem: FC<PluginItemProps> = ({
plugin,
getIconUrl,
language,
statusIcon,
statusText,
statusClassName,
action,
onClear,
}) => {
return (
<div className="group/item flex gap-1 rounded-lg p-2 hover:bg-state-base-hover">
<div className="relative shrink-0">
<CardIcon
size="small"
src={getIconUrl(plugin.icon)}
/>
<div className="absolute -bottom-0.5 -right-0.5 z-10">
{statusIcon}
</div>
</div>
<div className="flex min-w-0 grow flex-col gap-0.5 px-1">
<div className="truncate text-text-secondary system-sm-medium">
{plugin.labels[language]}
</div>
<div className={`system-xs-regular ${statusClassName || 'text-text-tertiary'}`}>
{statusText}
</div>
{action}
</div>
{onClear && (
<button
className="hidden h-6 w-6 shrink-0 items-center justify-center rounded-md hover:bg-state-base-hover-alt group-hover/item:flex"
onClick={onClear}
>
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</button>
)}
</div>
)
}
export default PluginItem

View File

@ -0,0 +1,67 @@
import type { FC, ReactNode } from 'react'
import type { PluginStatus } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import PluginItem from './plugin-item'
export type PluginSectionProps = {
title: string
count: number
plugins: PluginStatus[]
getIconUrl: (icon: string) => string
language: Locale
statusIcon: ReactNode
defaultStatusText: ReactNode
statusClassName?: string
headerAction?: ReactNode
renderItemAction?: (plugin: PluginStatus) => ReactNode
onClearSingle?: (taskId: string, pluginId: string) => void
}
const PluginSection: FC<PluginSectionProps> = ({
title,
count,
plugins,
getIconUrl,
language,
statusIcon,
defaultStatusText,
statusClassName,
headerAction,
renderItemAction,
onClearSingle,
}) => {
if (plugins.length === 0)
return null
return (
<>
<div className="sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary system-sm-semibold-uppercase">
{title}
{' '}
(
{count}
)
{headerAction}
</div>
<div className="max-h-[300px] 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)}
onClear={onClearSingle
? () => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)
: undefined}
/>
))}
</div>
</>
)
}
export default PluginSection

View File

@ -1,39 +1,10 @@
import type { FC, ReactNode } from 'react'
import type { FC } 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
}
import ErrorPluginItem from './error-plugin-item'
import PluginSection from './plugin-section'
type PluginTaskListProps = {
runningPlugins: PluginStatus[]
@ -45,83 +16,6 @@ type PluginTaskListProps = {
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,
@ -145,9 +39,9 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
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" />
<span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin text-text-accent" />
}
defaultStatusText={t('task.installing', { ns: 'plugin' })}
defaultStatusText={t('task.installingHint', { ns: 'plugin' })}
/>
)}
@ -160,7 +54,7 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
getIconUrl={getIconUrl}
language={language}
statusIcon={
<RiCheckboxCircleFill className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-success" />
<span className="i-ri-checkbox-circle-fill h-3.5 w-3.5 text-text-success" />
}
defaultStatusText={t('task.installed', { ns: 'plugin' })}
statusClassName="text-text-success"
@ -174,23 +68,15 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
{t('task.clearAll', { ns: 'plugin' })}
</Button>
)}
onClearSingle={onClearSingle}
/>
)}
{/* 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={(
<>
<div className="sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary system-sm-semibold-uppercase">
{t('task.installedError', { ns: 'plugin', errorLength: errorPlugins.length })}
<Button
className="shrink-0"
size="small"
@ -199,18 +85,19 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
>
{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>
<div className="max-h-[300px] overflow-y-auto">
{errorPlugins.map(plugin => (
<ErrorPluginItem
key={plugin.plugin_unique_identifier}
plugin={plugin}
getIconUrl={getIconUrl}
language={language}
onClear={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
/>
))}
</div>
</>
)}
</div>
)