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

@ -1,5 +1,5 @@
import type { Model, ModelItem } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import {
ConfigurationMethodEnum,
ModelStatusEnum,
@ -60,8 +60,8 @@ describe('ModelTrigger', () => {
expect(screen.getByText('GPT-4')).toBeInTheDocument()
})
it('should show status tooltip content when model is not active', async () => {
const { container } = render(
it('should show status badge when model is not active', () => {
render(
<ModelTrigger
open={false}
provider={makeModel()}
@ -69,10 +69,7 @@ describe('ModelTrigger', () => {
/>,
)
const tooltipTrigger = container.querySelector('[data-state]') as HTMLElement
fireEvent.mouseEnter(tooltipTrigger)
expect(await screen.findByText('No Configure')).toBeInTheDocument()
expect(screen.getByText(/modelProvider\.selector\.configureRequired/)).toBeInTheDocument()
})
it('should not show status icon when readonly', () => {
@ -86,6 +83,6 @@ describe('ModelTrigger', () => {
)
expect(screen.getByText('GPT-4')).toBeInTheDocument()
expect(screen.queryByText('No Configure')).not.toBeInTheDocument()
expect(screen.queryByText(/modelProvider\.selector\.configureRequired/)).not.toBeInTheDocument()
})
})

View File

@ -2,9 +2,11 @@ import type { DefaultModel, Model, ModelItem } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import {
ConfigurationMethodEnum,
CustomConfigurationStatusEnum,
ModelFeatureEnum,
ModelStatusEnum,
ModelTypeEnum,
PreferredProviderTypeEnum,
} from '../declarations'
import PopupItem from './popup-item'
@ -33,6 +35,14 @@ vi.mock('../model-name', () => ({
default: ({ modelItem }: { modelItem: ModelItem }) => <span>{modelItem.label.en_US}</span>,
}))
vi.mock('./feature-icon', () => ({
default: ({ feature }: { feature: string }) => <span data-testid="feature-icon">{feature}</span>,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
const mockSetShowModelModal = vi.hoisted(() => vi.fn())
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
@ -45,6 +55,11 @@ vi.mock('@/context/provider-context', () => ({
useProviderContext: mockUseProviderContext,
}))
const mockUseAppContext = vi.hoisted(() => vi.fn())
vi.mock('@/context/app-context', () => ({
useAppContext: mockUseAppContext,
}))
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'gpt-4',
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
@ -66,11 +81,24 @@ const makeModel = (overrides: Partial<Model> = {}): Model => ({
...overrides,
})
const makeProvider = (overrides: Record<string, unknown> = {}) => ({
provider: 'openai',
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_name: 'my-api-key',
},
...overrides,
})
describe('PopupItem', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseProviderContext.mockReturnValue({
modelProviders: [{ provider: 'openai' }],
modelProviders: [makeProvider()],
})
mockUseAppContext.mockReturnValue({
currentWorkspace: { trial_credits: 200, trial_credits_used: 0 },
})
})
@ -144,4 +172,66 @@ describe('PopupItem', () => {
expect(screen.getByText('GPT-4')).toBeInTheDocument()
})
it('should toggle collapsed state when clicking provider header', () => {
render(<PopupItem model={makeModel()} onSelect={vi.fn()} />)
expect(screen.getByText('GPT-4')).toBeInTheDocument()
fireEvent.click(screen.getByText('OpenAI'))
expect(screen.queryByText('GPT-4')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('OpenAI'))
expect(screen.getByText('GPT-4')).toBeInTheDocument()
})
it('should show credential name when using custom provider', () => {
render(<PopupItem model={makeModel()} onSelect={vi.fn()} />)
expect(screen.getByText('my-api-key')).toBeInTheDocument()
})
it('should show configure required when no credential name', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [makeProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
current_credential_name: '',
},
})],
})
render(<PopupItem model={makeModel()} onSelect={vi.fn()} />)
expect(screen.getByText(/modelProvider\.selector\.configureRequired/)).toBeInTheDocument()
})
it('should show credits info when using system provider with remaining credits', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [makeProvider({
preferred_provider_type: PreferredProviderTypeEnum.system,
})],
})
render(<PopupItem model={makeModel()} onSelect={vi.fn()} />)
expect(screen.getByText(/modelProvider\.selector\.aiCredits/)).toBeInTheDocument()
})
it('should show credits exhausted when system provider has no credits', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [makeProvider({
preferred_provider_type: PreferredProviderTypeEnum.system,
})],
})
mockUseAppContext.mockReturnValue({
currentWorkspace: { trial_credits: 100, trial_credits_used: 100 },
})
render(<PopupItem model={makeModel()} onSelect={vi.fn()} />)
expect(screen.getByText(/modelProvider\.selector\.creditsExhausted/)).toBeInTheDocument()
})
})

View File

@ -1,5 +1,5 @@
import type { Model, ModelItem } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import {
ConfigurationMethodEnum,
ModelFeatureEnum,
@ -31,17 +31,13 @@ vi.mock('@/app/components/base/tooltip/TooltipManager', () => ({
},
}))
vi.mock('@/app/components/base/icons/src/vender/solid/general', () => ({
XCircle: ({ onClick }: { onClick?: () => void }) => (
<button type="button" aria-label="clear-search" onClick={onClick} />
),
}))
const mockMarketplacePlugins = vi.hoisted(() => ({ current: [] as Array<{ plugin_id: string, latest_package_identifier: string }> }))
vi.mock('../hooks', async () => {
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
return {
...actual,
useLanguage: () => mockLanguage,
useMarketplaceAllPlugins: () => ({ plugins: mockMarketplacePlugins.current }),
}
})
@ -49,6 +45,53 @@ vi.mock('./popup-item', () => ({
default: ({ model }: { model: Model }) => <div>{model.provider}</div>,
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({ modelProviders: [] }),
}))
vi.mock('next-themes', () => ({
useTheme: () => ({ theme: 'light' }),
}))
const mockInstallMutateAsync = vi.hoisted(() => vi.fn())
vi.mock('@/service/use-plugins', () => ({
useInstallPackageFromMarketPlace: () => ({ mutateAsync: mockInstallMutateAsync }),
}))
const mockRefreshPluginList = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({
default: () => ({ refreshPluginList: mockRefreshPluginList }),
}))
const mockCheck = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/plugins/install-plugin/base/check-task-status', () => ({
default: () => ({ check: mockCheck }),
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: vi.fn(() => 'https://marketplace.example.com'),
}))
vi.mock('../utils', async () => {
const actual = await vi.importActual<typeof import('../utils')>('../utils')
return {
...actual,
MODEL_PROVIDER_QUOTA_GET_PAID: ['test-openai', 'test-anthropic'],
providerIconMap: {
'test-openai': ({ className }: { className?: string }) => <span className={className}>OAI</span>,
'test-anthropic': ({ className }: { className?: string }) => <span className={className}>ANT</span>,
},
modelNameMap: {
'test-openai': 'TestOpenAI',
'test-anthropic': 'TestAnthropic',
},
providerKeyToPluginId: {
'test-openai': 'langgenius/openai',
'test-anthropic': 'langgenius/anthropic',
},
}
})
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'gpt-4',
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
@ -74,10 +117,11 @@ describe('Popup', () => {
vi.clearAllMocks()
mockLanguage = 'en_US'
mockSupportFunctionCall.mockReturnValue(true)
mockMarketplacePlugins.current = []
})
it('should filter models by search and allow clearing search', () => {
render(
const { container } = render(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
@ -89,9 +133,11 @@ describe('Popup', () => {
const input = screen.getByPlaceholderText('datasetSettings.form.searchModel')
fireEvent.change(input, { target: { value: 'not-found' } })
expect(screen.getByText('No model found for not-found')).toBeInTheDocument()
expect(screen.getByText('No model found for \u201Cnot-found\u201D')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'clear-search' }))
const clearIcon = container.querySelector('.i-custom-vender-solid-general-x-circle')
expect(clearIcon).toBeInTheDocument()
fireEvent.click(clearIcon!)
expect((input as HTMLInputElement).value).toBe('')
})
@ -100,7 +146,6 @@ describe('Popup', () => {
makeModel({ models: [makeModelItem({ features: [ModelFeatureEnum.toolCall, ModelFeatureEnum.vision] })] }),
]
// When tool-call support is missing, it should be filtered out.
mockSupportFunctionCall.mockReturnValue(false)
const { unmount } = render(
<Popup
@ -110,9 +155,8 @@ describe('Popup', () => {
scopeFeatures={[ModelFeatureEnum.toolCall, ModelFeatureEnum.vision]}
/>,
)
expect(screen.getByText('No model found for “”')).toBeInTheDocument()
expect(screen.getByText('No model found for \u201C\u201D')).toBeInTheDocument()
// When tool-call support exists, the non-toolCall feature check should also pass.
unmount()
mockSupportFunctionCall.mockReturnValue(true)
const { unmount: unmount2 } = render(
@ -136,7 +180,6 @@ describe('Popup', () => {
)
expect(screen.getByText('openai')).toBeInTheDocument()
// When features are missing, non-toolCall feature checks should fail.
unmount3()
render(
<Popup
@ -146,7 +189,7 @@ describe('Popup', () => {
scopeFeatures={[ModelFeatureEnum.vision]}
/>,
)
expect(screen.getByText('No model found for “”')).toBeInTheDocument()
expect(screen.getByText('No model found for \u201C\u201D')).toBeInTheDocument()
})
it('should match labels from other languages when current language key is missing', () => {
@ -182,18 +225,150 @@ describe('Popup', () => {
})
it('should open provider settings when clicking footer link', () => {
const onHide = vi.fn()
render(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={onHide}
/>,
)
fireEvent.click(screen.getByText('common.modelProvider.selector.modelProviderSettings'))
expect(onHide).toHaveBeenCalled()
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'provider',
})
})
it('should show empty state when no providers are configured', () => {
const onHide = vi.fn()
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={onHide}
/>,
)
expect(screen.getByText(/modelProvider\.selector\.noProviderConfigured(?!Desc)/)).toBeInTheDocument()
expect(screen.getByText(/modelProvider\.selector\.noProviderConfiguredDesc/)).toBeInTheDocument()
fireEvent.click(screen.getByText(/modelProvider\.selector\.configure/))
expect(onHide).toHaveBeenCalled()
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'provider',
})
})
it('should render marketplace providers that are not installed', () => {
render(
<Popup
modelList={[makeModel({ provider: 'test-openai' })]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
fireEvent.click(screen.getByText('common.model.settingsLink'))
expect(screen.queryByText('TestOpenAI')).not.toBeInTheDocument()
expect(screen.getByText('TestAnthropic')).toBeInTheDocument()
expect(screen.getByText(/modelProvider\.selector\.fromMarketplace/)).toBeInTheDocument()
expect(screen.getByText(/modelProvider\.selector\.discoverMoreInMarketplace/)).toBeInTheDocument()
})
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'provider',
it('should toggle marketplace section collapse', () => {
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
expect(screen.getByText('TestOpenAI')).toBeInTheDocument()
fireEvent.click(screen.getByText(/modelProvider\.selector\.fromMarketplace/))
expect(screen.queryByText('TestOpenAI')).not.toBeInTheDocument()
fireEvent.click(screen.getByText(/modelProvider\.selector\.fromMarketplace/))
expect(screen.getByText('TestOpenAI')).toBeInTheDocument()
})
it('should install plugin when clicking install button', async () => {
mockMarketplacePlugins.current = [
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
]
mockInstallMutateAsync.mockResolvedValue({ all_installed: true, task_id: 'task-1' })
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
const installButtons = screen.getAllByText(/common\.modelProvider\.selector\.install/)
fireEvent.click(installButtons[0])
await waitFor(() => {
expect(mockInstallMutateAsync).toHaveBeenCalledWith('langgenius/openai:1.0.0')
})
expect(mockRefreshPluginList).toHaveBeenCalled()
})
it('should handle install failure gracefully', async () => {
mockMarketplacePlugins.current = [
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
]
mockInstallMutateAsync.mockRejectedValue(new Error('Install failed'))
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
const installButtons = screen.getAllByText(/common\.modelProvider\.selector\.install/)
fireEvent.click(installButtons[0])
await waitFor(() => {
expect(mockInstallMutateAsync).toHaveBeenCalled()
})
// Should not crash, install buttons should still be available
expect(screen.getAllByText(/common\.modelProvider\.selector\.install/).length).toBeGreaterThan(0)
})
it('should run checkTaskStatus when not all_installed', async () => {
mockMarketplacePlugins.current = [
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
]
mockInstallMutateAsync.mockResolvedValue({ all_installed: false, task_id: 'task-1' })
mockCheck.mockResolvedValue(undefined)
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
const installButtons = screen.getAllByText(/common\.modelProvider\.selector\.install/)
fireEvent.click(installButtons[0])
await waitFor(() => {
expect(mockCheck).toHaveBeenCalledWith({
taskId: 'task-1',
pluginUniqueIdentifier: 'langgenius/openai:1.0.0',
})
})
expect(mockRefreshPluginList).toHaveBeenCalled()
})
})

View File

@ -263,14 +263,14 @@ const Popup: FC<PopupProps> = ({
)}
</div>
<div
className="sticky bottom-0 flex cursor-pointer items-center rounded-b-lg border-t border-divider-subtle bg-components-panel-bg px-4 py-2 text-text-accent-light-mode-only"
className="sticky bottom-0 flex cursor-pointer items-center gap-1 rounded-b-lg border-t border-divider-subtle bg-components-panel-bg px-3 py-2 text-text-tertiary hover:text-text-secondary"
onClick={() => {
onHide()
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
}}
>
<span className="system-xs-medium">{t('model.settingsLink', { ns: 'common' })}</span>
<span className="i-ri-arrow-right-up-line ml-0.5 h-3 w-3" />
<span className="i-ri-equalizer-2-line h-4 w-4 shrink-0" />
<span className="system-xs-medium">{t('modelProvider.selector.modelProviderSettings', { ns: 'common' })}</span>
</div>
</div>
)

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

View File

@ -5733,11 +5733,6 @@
"count": 1
}
},
"app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/plugins/plugin-page/plugin-tasks/components/task-status-indicator.tsx": {
"no-restricted-imports": {
"count": 1

View File

@ -416,6 +416,7 @@
"modelProvider.selector.incompatible": "Incompatible",
"modelProvider.selector.incompatibleTip": "This model is not available in the current version. Please select another available model.",
"modelProvider.selector.install": "Install",
"modelProvider.selector.modelProviderSettings": "Model Provider Settings",
"modelProvider.selector.noProviderConfigured": "No model provider configured",
"modelProvider.selector.noProviderConfiguredDesc": "Browse Marketplace to install one, or configure providers in settings.",
"modelProvider.selector.rerankTip": "Please set up the Rerank model",

View File

@ -231,12 +231,18 @@
"source.local": "Local Package File",
"source.marketplace": "Marketplace",
"task.clearAll": "Clear all",
"task.errorMsg.github": "This plugin couldn't be installed automatically.\nPlease install it from GitHub.",
"task.errorMsg.marketplace": "This plugin couldn't be installed automatically.\nPlease install it from the Marketplace.",
"task.errorMsg.unknown": "This plugin couldn't be installed.\nThe plugin source couldn't be identified.",
"task.errorPlugins": "Failed to Install Plugins",
"task.installError": "{{errorLength}} plugins failed to install, click to view",
"task.installFromGithub": "Install from GitHub",
"task.installFromMarketplace": "Install from Marketplace",
"task.installSuccess": "{{successLength}} plugins installed successfully",
"task.installed": "Installed",
"task.installedError": "{{errorLength}} plugins failed to install",
"task.installing": "Installing plugins",
"task.installingHint": "Installing... This may take a few minutes.",
"task.installingWithError": "Installing {{installingLength}} plugins, {{successLength}} success, {{errorLength}} failed",
"task.installingWithSuccess": "Installing {{installingLength}} plugins, {{successLength}} success.",
"task.runningPlugins": "Installing Plugins",

View File

@ -416,6 +416,7 @@
"modelProvider.selector.incompatible": "不兼容",
"modelProvider.selector.incompatibleTip": "该模型在当前版本中不可用,请选择其他可用模型。",
"modelProvider.selector.install": "安装",
"modelProvider.selector.modelProviderSettings": "模型供应商设置",
"modelProvider.selector.noProviderConfigured": "未配置模型提供商",
"modelProvider.selector.noProviderConfiguredDesc": "前往插件市场安装,或在设置中配置提供商。",
"modelProvider.selector.rerankTip": "请设置 Rerank 模型",

View File

@ -231,12 +231,18 @@
"source.local": "本地插件",
"source.marketplace": "Marketplace",
"task.clearAll": "清除所有",
"task.errorMsg.github": "此插件无法自动安装。\n请从 GitHub 安装。",
"task.errorMsg.marketplace": "此插件无法自动安装。\n请从插件市场安装。",
"task.errorMsg.unknown": "此插件无法安装。\n无法识别插件来源。",
"task.errorPlugins": "安装失败的插件",
"task.installError": "{{errorLength}} 个插件安装失败,点击查看",
"task.installFromGithub": "从 GitHub 安装",
"task.installFromMarketplace": "从插件市场安装",
"task.installSuccess": "{{successLength}} 个插件安装成功",
"task.installed": "已安装",
"task.installedError": "{{errorLength}} 个插件安装失败",
"task.installing": "正在安装插件",
"task.installingHint": "正在安装……可能需要几分钟。",
"task.installingWithError": "{{installingLength}} 个插件安装中,{{successLength}} 安装成功,{{errorLength}} 安装失败",
"task.installingWithSuccess": "{{installingLength}} 个插件安装中,{{successLength}} 安装成功",
"task.runningPlugins": "正在安装的插件",