diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/model-trigger.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/model-trigger.spec.tsx index 8bcf362faf..2681da6daf 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/model-trigger.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/model-trigger.spec.tsx @@ -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( { />, ) - 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() }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.spec.tsx index af398f83ba..b0df942907 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.spec.tsx @@ -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 }) => {modelItem.label.en_US}, })) +vi.mock('./feature-icon', () => ({ + default: ({ feature }: { feature: string }) => {feature}, +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + 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 => ({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' }, @@ -66,11 +81,24 @@ const makeModel = (overrides: Partial = {}): Model => ({ ...overrides, }) +const makeProvider = (overrides: Record = {}) => ({ + 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() + + 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() + + 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() + + 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() + + 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() + + expect(screen.getByText(/modelProvider\.selector\.creditsExhausted/)).toBeInTheDocument() + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.spec.tsx index 4083f4a37c..eb19ef953a 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.spec.tsx @@ -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 }) => ( - + + + ), +})) + +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 => ({ + 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( + , + ) + + expect(screen.getByText('Test Plugin')).toBeInTheDocument() + }) + + it('should render error status icon', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.i-ri-error-warning-fill')).toBeInTheDocument() + }) + + it('should apply destructive text styling', () => { + render( + , + ) + + 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( + , + ) + + expect(screen.getByText(/plugin\.task\.errorMsg\.marketplace/)).toBeInTheDocument() + }) + + it('should show github error message for github plugins', () => { + render( + , + ) + + expect(screen.getByText(/plugin\.task\.errorMsg\.github/)).toBeInTheDocument() + }) + + it('should show unknown error message for unknown source plugins', () => { + render( + , + ) + + expect(screen.getByText(/plugin\.task\.errorMsg\.unknown/)).toBeInTheDocument() + }) + + it('should show plugin.message when available instead of default error', () => { + render( + , + ) + + expect(screen.getByText('Custom error occurred')).toBeInTheDocument() + }) + }) + + describe('Action buttons', () => { + it('should show "Install from Marketplace" button for marketplace plugins', () => { + render( + , + ) + + expect(screen.getByText(/plugin\.task\.installFromMarketplace/)).toBeInTheDocument() + }) + + it('should show "Install from GitHub" button for github plugins', () => { + render( + , + ) + + expect(screen.getByText(/plugin\.task\.installFromGithub/)).toBeInTheDocument() + }) + + it('should not show action button for unknown source plugins', () => { + render( + , + ) + + 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( + , + ) + + // 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + // 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + expect(screen.getByText(/plugin\.task\.installFromGithub/)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-item.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-item.spec.tsx new file mode 100644 index 0000000000..31b533531d --- /dev/null +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-item.spec.tsx @@ -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 }) => ( +
+ ), +})) + +const mockGetIconUrl = vi.fn((icon: string) => `https://example.com/icons/${icon}`) + +const createPlugin = (overrides: Partial = {}): 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( + } + statusText="Installing..." + />, + ) + + expect(screen.getByText('Test Plugin')).toBeInTheDocument() + }) + + it('should render status text', () => { + render( + } + statusText="Installing... please wait" + />, + ) + + expect(screen.getByText('Installing... please wait')).toBeInTheDocument() + }) + + it('should render status icon', () => { + render( + } + statusText="status" + />, + ) + + expect(screen.getByTestId('status-icon')).toBeInTheDocument() + }) + + it('should pass icon url to CardIcon', () => { + render( + } + 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( + } + statusText="done" + statusClassName="text-text-success" + />, + ) + + expect(screen.getByText('done').className).toContain('text-text-success') + }) + + it('should apply default statusClassName when not provided', () => { + render( + } + statusText="done" + />, + ) + + expect(screen.getByText('done').className).toContain('text-text-tertiary') + }) + + it('should render action when provided', () => { + render( + } + statusText="status" + action={} + />, + ) + + expect(screen.getByRole('button', { name: /install/i })).toBeInTheDocument() + }) + + it('should not render action when not provided', () => { + render( + } + statusText="status" + />, + ) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('should render zh-Hans label when language is zh_Hans', () => { + render( + } + statusText="status" + />, + ) + + expect(screen.getByText('测试插件')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should render clear button when onClear is provided', () => { + const handleClear = vi.fn() + render( + } + 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( + } + statusText="status" + />, + ) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-section.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-section.spec.tsx new file mode 100644 index 0000000000..172eb6e191 --- /dev/null +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-section.spec.tsx @@ -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 }) => ( +
+ ), +})) + +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: , + defaultStatusText: 'Default status', +} + +describe('PluginSection', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render title and count', () => { + render() + + expect(screen.getByText(/installing plugins/i)).toBeInTheDocument() + expect(screen.getByText(/installing plugins/i).textContent).toContain('2') + }) + + it('should render all plugin items', () => { + render() + + expect(screen.getByText('PluginA')).toBeInTheDocument() + expect(screen.getByText('PluginB')).toBeInTheDocument() + }) + + it('should render status icons for each plugin', () => { + render() + + expect(screen.getAllByTestId('status-icon')).toHaveLength(2) + }) + }) + + describe('Props', () => { + it('should return null when plugins array is empty', () => { + const { container } = render( + , + ) + + expect(container.innerHTML).toBe('') + }) + + it('should use plugin.message as statusText when available', () => { + const plugins = [createPlugin('p1', 'PluginA', 'Custom message')] + render() + + expect(screen.getByText('Custom message')).toBeInTheDocument() + }) + + it('should use defaultStatusText when plugin has no message', () => { + const plugins = [createPlugin('p1', 'PluginA', '')] + render() + + expect(screen.getByText('Default status')).toBeInTheDocument() + }) + + it('should apply statusClassName to items', () => { + const plugins = [createPlugin('p1', 'PluginA')] + render( + , + ) + + expect(screen.getByText('Default status').className).toContain('text-text-success') + }) + + it('should render headerAction when provided', () => { + render( + Clear all} + />, + ) + + expect(screen.getByRole('button', { name: /clear all/i })).toBeInTheDocument() + }) + + it('should not render headerAction when not provided', () => { + render() + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('should render item actions via renderItemAction', () => { + render( + ( + + )} + />, + ) + + 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( + , + ) + + // 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() + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle single plugin', () => { + const plugins = [createPlugin('p1', 'Solo')] + render() + + expect(screen.getByText('Solo')).toBeInTheDocument() + expect(screen.getByText(/solo/i).closest('.max-h-\\[300px\\]')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-task-list.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-task-list.spec.tsx new file mode 100644 index 0000000000..14fd13855b --- /dev/null +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-task-list.spec.tsx @@ -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 }) => ( +
+ ), +})) + +vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({ + default: () =>
, +})) + +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 => ({ + 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() + const wrapper = container.firstElementChild! + expect(wrapper).toBeInTheDocument() + expect(wrapper.children).toHaveLength(0) + }) + + it('should render running section when running plugins exist', () => { + const { container } = render() + + // 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() + + 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() + + 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( + , + ) + + 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() + + 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( + , + ) + + fireEvent.click(screen.getByText(/plugin\.task\.clearAll/)) + expect(onClearAll).toHaveBeenCalledTimes(1) + }) + + it('should show Clear all button in error section', () => { + render() + + 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( + , + ) + + 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( + , + ) + + // 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() + + // 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() + + // 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( + , + ) + + // 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( + , + ) + + 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() + + expect(screen.getByText('PluginA')).toBeInTheDocument() + expect(screen.getByText('PluginB')).toBeInTheDocument() + expect(screen.getByText('PluginC')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/task-status-indicator.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/task-status-indicator.spec.tsx new file mode 100644 index 0000000000..4c1c8fb49d --- /dev/null +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/task-status-indicator.spec.tsx @@ -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 }) => ( +
+ ), +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => ( +
{children}
+ ), +})) + +vi.mock('@/app/components/header/plugins-nav/downloading-icon', () => ({ + default: () => , +})) + +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() + expect(screen.getByTestId('tooltip')).toBeInTheDocument() + }) + + it('should pass tip to tooltip', () => { + render() + expect(screen.getByTestId('tooltip')).toHaveAttribute('data-tip', 'My tip') + }) + + it('should render install icon by default', () => { + const { container } = render() + // 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() + expect(screen.getByTestId('downloading-icon')).toBeInTheDocument() + }) + + it('should show downloading icon when isInstallingWithError', () => { + render() + expect(screen.getByTestId('downloading-icon')).toBeInTheDocument() + }) + + it('should show progress circle when isInstalling', () => { + render( + , + ) + const progress = screen.getByTestId('progress-circle') + expect(progress).toHaveAttribute('data-percentage', '40') + }) + + it('should show progress circle when isInstallingWithSuccess', () => { + render( + , + ) + const progress = screen.getByTestId('progress-circle') + expect(progress).toHaveAttribute('data-percentage', '75') + }) + + it('should show error progress circle when isInstallingWithError', () => { + render( + , + ) + const progress = screen.getByTestId('progress-circle') + expect(progress).toBeInTheDocument() + }) + + it('should handle zero totalPluginsLength without division error', () => { + render( + , + ) + 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( + , + ) + // 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( + , + ) + const successIcon = container.querySelector('.text-text-success') + expect(successIcon).toBeInTheDocument() + }) + + it('should not show success icon during installing states', () => { + const { container } = render( + , + ) + // 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( + , + ) + const errorIcon = container.querySelector('.text-text-destructive') + expect(errorIcon).toBeInTheDocument() + }) + + it('should apply destructive styling when isFailed', () => { + render( + , + ) + const button = document.getElementById('plugin-task-trigger')! + expect(button.className).toContain('bg-state-destructive-hover') + }) + + it('should apply destructive styling when isInstallingWithError', () => { + render( + , + ) + 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() + + 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( + , + ) + 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() + 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() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/error-plugin-item.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/error-plugin-item.tsx new file mode 100644 index 0000000000..119b219b43 --- /dev/null +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/error-plugin-item.tsx @@ -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 = ({ 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 ( +
+ +
+ ) + } + if (source === 'github') { + return ( +
+ +
+ ) + } + return undefined + } + + return ( + <> + + + + )} + statusText={( + + {plugin.message || errorMsg} + + )} + statusClassName="text-text-destructive" + action={renderAction()} + onClear={onClear} + /> + {showInstallModal && installPayload && ( + setShowInstallModal(false)} + onSuccess={() => setShowInstallModal(false)} + /> + )} + + ) +} + +export default ErrorPluginItem diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-item.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-item.tsx new file mode 100644 index 0000000000..5fa0f36203 --- /dev/null +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-item.tsx @@ -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 = ({ + plugin, + getIconUrl, + language, + statusIcon, + statusText, + statusClassName, + action, + onClear, +}) => { + return ( +
+
+ +
+ {statusIcon} +
+
+
+
+ {plugin.labels[language]} +
+
+ {statusText} +
+ {action} +
+ {onClear && ( + + )} +
+ ) +} + +export default PluginItem diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-section.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-section.tsx new file mode 100644 index 0000000000..5a6dfe0094 --- /dev/null +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-section.tsx @@ -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 = ({ + title, + count, + plugins, + getIconUrl, + language, + statusIcon, + defaultStatusText, + statusClassName, + headerAction, + renderItemAction, + onClearSingle, +}) => { + if (plugins.length === 0) + return null + + return ( + <> +
+ {title} + {' '} + ( + {count} + ) + {headerAction} +
+
+ {plugins.map(plugin => ( + onClearSingle(plugin.taskId, plugin.plugin_unique_identifier) + : undefined} + /> + ))} +
+ + ) +} + +export default PluginSection diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx index 5067a97204..728d3aceab 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx @@ -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 = ({ - plugin, - getIconUrl, - language, - statusIcon, - statusText, - statusClassName, - action, -}) => { - return ( -
-
- {statusIcon} - -
-
-
- {plugin.labels[language]} -
-
- {statusText} -
-
- {action} -
- ) -} - -// Plugin Section Component -const PluginSection: FC = ({ - title, - count, - plugins, - getIconUrl, - language, - statusIcon, - defaultStatusText, - statusClassName, - headerAction, - renderItemAction, -}) => { - if (plugins.length === 0) - return null - - return ( - <> -
- {title} - {' '} - ( - {count} - ) - {headerAction} -
-
- {plugins.map(plugin => ( - - ))} -
- - ) -} - -// Main Plugin Task List Component const PluginTaskList: FC = ({ runningPlugins, successPlugins, @@ -145,9 +39,9 @@ const PluginTaskList: FC = ({ getIconUrl={getIconUrl} language={language} statusIcon={ - + } - defaultStatusText={t('task.installing', { ns: 'plugin' })} + defaultStatusText={t('task.installingHint', { ns: 'plugin' })} /> )} @@ -160,7 +54,7 @@ const PluginTaskList: FC = ({ getIconUrl={getIconUrl} language={language} statusIcon={ - + } defaultStatusText={t('task.installed', { ns: 'plugin' })} statusClassName="text-text-success" @@ -174,23 +68,15 @@ const PluginTaskList: FC = ({ {t('task.clearAll', { ns: 'plugin' })} )} + onClearSingle={onClearSingle} /> )} {/* Error Plugins Section */} {errorPlugins.length > 0 && ( - - } - defaultStatusText={t('task.installError', { ns: 'plugin', errorLength: errorPlugins.length })} - statusClassName="text-text-destructive break-all" - headerAction={( + <> +
+ {t('task.installedError', { ns: 'plugin', errorLength: errorPlugins.length })} - )} - renderItemAction={plugin => ( - - )} - /> +
+
+ {errorPlugins.map(plugin => ( + onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)} + /> + ))} +
+ )}
) diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 9622586143..18f0f82aa9 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -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 diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index f9a07b7de9..0bce920339 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -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", diff --git a/web/i18n/en-US/plugin.json b/web/i18n/en-US/plugin.json index c7f091a442..980a291578 100644 --- a/web/i18n/en-US/plugin.json +++ b/web/i18n/en-US/plugin.json @@ -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", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index 4a9044f3dd..7d672d5977 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -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 模型", diff --git a/web/i18n/zh-Hans/plugin.json b/web/i18n/zh-Hans/plugin.json index 703bd4e6ea..6e984ddf67 100644 --- a/web/i18n/zh-Hans/plugin.json +++ b/web/i18n/zh-Hans/plugin.json @@ -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": "正在安装的插件",