diff --git a/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx b/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx
index e5c1541214..def33d4957 100644
--- a/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx
+++ b/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx
@@ -225,6 +225,7 @@ const AddOAuthButton = ({
>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ gcTime: 0,
+ },
+ },
+ })
+
+const createWrapper = () => {
+ const testQueryClient = createTestQueryClient()
+ return ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ )
+}
+
+// Mock API hooks - these make network requests so must be mocked
+const mockGetPluginOAuthUrl = vi.fn()
+const mockGetPluginOAuthClientSchema = vi.fn()
+const mockSetPluginOAuthCustomClient = vi.fn()
+const mockDeletePluginOAuthCustomClient = vi.fn()
+const mockInvalidPluginOAuthClientSchema = vi.fn()
+const mockAddPluginCredential = vi.fn()
+const mockUpdatePluginCredential = vi.fn()
+const mockGetPluginCredentialSchema = vi.fn()
+
+vi.mock('../hooks/use-credential', () => ({
+ useGetPluginOAuthUrlHook: () => ({
+ mutateAsync: mockGetPluginOAuthUrl,
+ }),
+ useGetPluginOAuthClientSchemaHook: () => ({
+ data: mockGetPluginOAuthClientSchema(),
+ isLoading: false,
+ }),
+ useSetPluginOAuthCustomClientHook: () => ({
+ mutateAsync: mockSetPluginOAuthCustomClient,
+ }),
+ useDeletePluginOAuthCustomClientHook: () => ({
+ mutateAsync: mockDeletePluginOAuthCustomClient,
+ }),
+ useInvalidPluginOAuthClientSchemaHook: () => mockInvalidPluginOAuthClientSchema,
+ useAddPluginCredentialHook: () => ({
+ mutateAsync: mockAddPluginCredential,
+ }),
+ useUpdatePluginCredentialHook: () => ({
+ mutateAsync: mockUpdatePluginCredential,
+ }),
+ useGetPluginCredentialSchemaHook: () => ({
+ data: mockGetPluginCredentialSchema(),
+ isLoading: false,
+ }),
+}))
+
+// Mock openOAuthPopup - requires window operations
+const mockOpenOAuthPopup = vi.fn()
+vi.mock('@/hooks/use-oauth', () => ({
+ openOAuthPopup: (...args: unknown[]) => mockOpenOAuthPopup(...args),
+}))
+
+// Mock service/use-triggers - API service
+vi.mock('@/service/use-triggers', () => ({
+ useTriggerPluginDynamicOptions: () => ({
+ data: { options: [] },
+ isLoading: false,
+ }),
+ useTriggerPluginDynamicOptionsInfo: () => ({
+ data: null,
+ isLoading: false,
+ }),
+ useInvalidTriggerDynamicOptions: () => vi.fn(),
+}))
+
+// Mock AuthForm to control form validation in tests
+const mockGetFormValues = vi.fn()
+vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({
+ default: vi.fn().mockImplementation(({ ref }: { ref: { current: unknown } }) => {
+ if (ref)
+ ref.current = { getFormValues: mockGetFormValues }
+
+ return
Auth Form
+ }),
+}))
+
+// Mock useToastContext
+const mockNotify = vi.fn()
+vi.mock('@/app/components/base/toast', () => ({
+ useToastContext: () => ({ notify: mockNotify }),
+}))
+
+// Factory function for creating test PluginPayload
+const createPluginPayload = (overrides: Partial
= {}): PluginPayload => ({
+ category: AuthCategory.tool,
+ provider: 'test-provider',
+ ...overrides,
+})
+
+// Factory for form schemas
+const createFormSchema = (overrides: Partial = {}): FormSchema => ({
+ type: 'text-input' as FormSchema['type'],
+ name: 'test-field',
+ label: 'Test Field',
+ required: false,
+ ...overrides,
+})
+
+// ==================== AddApiKeyButton Tests ====================
+describe('AddApiKeyButton', () => {
+ let AddApiKeyButton: typeof import('./add-api-key-button').default
+
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ mockGetPluginCredentialSchema.mockReturnValue([])
+ const importedAddApiKeyButton = await import('./add-api-key-button')
+ AddApiKeyButton = importedAddApiKeyButton.default
+ })
+
+ describe('Rendering', () => {
+ it('should render button with default text', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(, { wrapper: createWrapper() })
+
+ expect(screen.getByRole('button')).toHaveTextContent('Use Api Key')
+ })
+
+ it('should render button with custom text', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toHaveTextContent('Custom API Key')
+ })
+
+ it('should apply button variant', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button').className).toContain('btn-primary')
+ })
+
+ it('should use secondary-accent variant by default', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(, { wrapper: createWrapper() })
+
+ // Verify the default button has secondary-accent variant class
+ expect(screen.getByRole('button').className).toContain('btn-secondary-accent')
+ })
+ })
+
+ describe('Props Testing', () => {
+ it('should disable button when disabled prop is true', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+
+ it('should not disable button when disabled prop is false', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).not.toBeDisabled()
+ })
+
+ it('should accept formSchemas prop', () => {
+ const pluginPayload = createPluginPayload()
+ const formSchemas = [createFormSchema({ name: 'api_key', label: 'API Key' })]
+
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ }).not.toThrow()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should open modal when button is clicked', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key' }),
+ ])
+
+ render(, { wrapper: createWrapper() })
+
+ fireEvent.click(screen.getByRole('button'))
+
+ await waitFor(() => {
+ expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument()
+ })
+ })
+
+ it('should not open modal when button is disabled', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ // Modal should not appear
+ expect(screen.queryByText('plugin.auth.useApiAuth')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty pluginPayload properties', () => {
+ const pluginPayload = createPluginPayload({
+ provider: '',
+ providerType: undefined,
+ })
+
+ expect(() => {
+ render(, { wrapper: createWrapper() })
+ }).not.toThrow()
+ })
+
+ it('should handle all auth categories', () => {
+ const categories = [AuthCategory.tool, AuthCategory.datasource, AuthCategory.model, AuthCategory.trigger]
+
+ categories.forEach((category) => {
+ const pluginPayload = createPluginPayload({ category })
+ const { unmount } = render(, { wrapper: createWrapper() })
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ unmount()
+ })
+ })
+ })
+
+ describe('Modal Behavior', () => {
+ it('should close modal when onClose is called from ApiKeyModal', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key' }),
+ ])
+
+ render(, { wrapper: createWrapper() })
+
+ // Open modal
+ fireEvent.click(screen.getByRole('button'))
+
+ await waitFor(() => {
+ expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument()
+ })
+
+ // Close modal via cancel button
+ fireEvent.click(screen.getByText('common.operation.cancel'))
+
+ await waitFor(() => {
+ expect(screen.queryByText('plugin.auth.useApiAuth')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should call onUpdate when provided and modal triggers update', async () => {
+ const pluginPayload = createPluginPayload()
+ const onUpdate = vi.fn()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key' }),
+ ])
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Open modal
+ fireEvent.click(screen.getByRole('button'))
+
+ await waitFor(() => {
+ expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Memoization', () => {
+ it('should be a memoized component', async () => {
+ const AddApiKeyButtonDefault = (await import('./add-api-key-button')).default
+ expect(typeof AddApiKeyButtonDefault).toBe('object')
+ })
+ })
+})
+
+// ==================== AddOAuthButton Tests ====================
+describe('AddOAuthButton', () => {
+ let AddOAuthButton: typeof import('./add-oauth-button').default
+
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ client_params: {},
+ redirect_uri: 'https://example.com/callback',
+ })
+ mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: 'https://oauth.example.com/auth' })
+ const importedAddOAuthButton = await import('./add-oauth-button')
+ AddOAuthButton = importedAddOAuthButton.default
+ })
+
+ describe('Rendering - Not Configured State', () => {
+ it('should render setup OAuth button when not configured', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ expect(screen.getByText('plugin.auth.setupOAuth')).toBeInTheDocument()
+ })
+
+ it('should apply button variant to setup button', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button').className).toContain('btn-secondary')
+ })
+ })
+
+ describe('Rendering - Configured State', () => {
+ it('should render OAuth button when system OAuth params exist', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: true,
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('Connect OAuth')).toBeInTheDocument()
+ })
+
+ it('should render OAuth button when custom client is enabled', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: true,
+ is_system_oauth_params_exists: false,
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('OAuth')).toBeInTheDocument()
+ })
+
+ it('should show custom badge when custom client is enabled', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: true,
+ is_system_oauth_params_exists: false,
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ expect(screen.getByText('plugin.auth.custom')).toBeInTheDocument()
+ })
+ })
+
+ describe('Props Testing', () => {
+ it('should disable button when disabled prop is true', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+
+ it('should apply custom className', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: true,
+ is_system_oauth_params_exists: false,
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button').className).toContain('custom-class')
+ })
+
+ it('should use oAuthData prop when provided', () => {
+ const pluginPayload = createPluginPayload()
+ const oAuthData = {
+ schema: [],
+ is_oauth_custom_client_enabled: true,
+ is_system_oauth_params_exists: true,
+ client_params: {},
+ redirect_uri: 'https://custom.example.com/callback',
+ }
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Should render configured button since oAuthData has is_system_oauth_params_exists=true
+ expect(screen.queryByText('plugin.auth.setupOAuth')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should trigger OAuth flow when configured button is clicked', async () => {
+ const pluginPayload = createPluginPayload()
+ const onUpdate = vi.fn()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: true,
+ is_system_oauth_params_exists: false,
+ })
+ mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: 'https://oauth.example.com/auth' })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click the main button area (left side)
+ const buttonText = screen.getByText('use oauth')
+ fireEvent.click(buttonText)
+
+ await waitFor(() => {
+ expect(mockGetPluginOAuthUrl).toHaveBeenCalled()
+ })
+ })
+
+ it('should open settings when setup button is clicked', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ redirect_uri: 'https://example.com/callback',
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ fireEvent.click(screen.getByText('plugin.auth.setupOAuth'))
+
+ await waitFor(() => {
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+ })
+
+ it('should not trigger OAuth when no authorization_url is returned', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: true,
+ is_system_oauth_params_exists: false,
+ })
+ mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: '' })
+
+ render(, { wrapper: createWrapper() })
+
+ const buttonText = screen.getByText('use oauth')
+ fireEvent.click(buttonText)
+
+ await waitFor(() => {
+ expect(mockGetPluginOAuthUrl).toHaveBeenCalled()
+ })
+
+ expect(mockOpenOAuthPopup).not.toHaveBeenCalled()
+ })
+
+ it('should call onUpdate callback after successful OAuth', async () => {
+ const pluginPayload = createPluginPayload()
+ const onUpdate = vi.fn()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: true,
+ is_system_oauth_params_exists: false,
+ })
+ mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: 'https://oauth.example.com/auth' })
+ // Simulate openOAuthPopup calling the success callback
+ mockOpenOAuthPopup.mockImplementation((url, callback) => {
+ callback?.()
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const buttonText = screen.getByText('use oauth')
+ fireEvent.click(buttonText)
+
+ await waitFor(() => {
+ expect(mockOpenOAuthPopup).toHaveBeenCalledWith(
+ 'https://oauth.example.com/auth',
+ expect.any(Function),
+ )
+ })
+
+ // Verify onUpdate was called through the callback
+ expect(onUpdate).toHaveBeenCalled()
+ })
+
+ it('should open OAuth settings when settings icon is clicked', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })],
+ is_oauth_custom_client_enabled: true,
+ is_system_oauth_params_exists: false,
+ redirect_uri: 'https://example.com/callback',
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ // Click the settings icon using data-testid for reliable selection
+ const settingsButton = screen.getByTestId('oauth-settings-button')
+ fireEvent.click(settingsButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+ })
+
+ it('should close OAuth settings modal when onClose is called', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ redirect_uri: 'https://example.com/callback',
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ // Open settings
+ fireEvent.click(screen.getByText('plugin.auth.setupOAuth'))
+
+ await waitFor(() => {
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+
+ // Close settings via cancel button
+ fireEvent.click(screen.getByText('common.operation.cancel'))
+
+ await waitFor(() => {
+ expect(screen.queryByText('plugin.auth.oauthClientSettings')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Schema Processing', () => {
+ it('should handle is_system_oauth_params_exists state', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: true,
+ redirect_uri: 'https://example.com/callback',
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ // Should show the configured button, not setup button
+ expect(screen.queryByText('plugin.auth.setupOAuth')).not.toBeInTheDocument()
+ })
+
+ it('should open OAuth settings modal with correct data', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [createFormSchema({ name: 'client_id', label: 'Client ID', required: true })],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ redirect_uri: 'https://example.com/callback',
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ fireEvent.click(screen.getByText('plugin.auth.setupOAuth'))
+
+ await waitFor(() => {
+ // OAuthClientSettings modal should open
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+ })
+
+ it('should handle client_params defaults in schema', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [
+ createFormSchema({ name: 'client_id', label: 'Client ID' }),
+ createFormSchema({ name: 'client_secret', label: 'Client Secret' }),
+ ],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: true,
+ client_params: {
+ client_id: 'preset-client-id',
+ client_secret: 'preset-secret',
+ },
+ redirect_uri: 'https://example.com/callback',
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ // Open settings by clicking the gear icon
+ const button = screen.getByRole('button')
+ const gearIconContainer = button.querySelector('[class*="shrink-0"][class*="w-8"]')
+ if (gearIconContainer)
+ fireEvent.click(gearIconContainer)
+
+ await waitFor(() => {
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+ })
+
+ it('should handle __auth_client__ logic when configured with system OAuth and no custom client', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: true,
+ client_params: {},
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ // Should render configured button (not setup button)
+ expect(screen.queryByText('plugin.auth.setupOAuth')).not.toBeInTheDocument()
+ })
+
+ it('should open OAuth settings when system OAuth params exist', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [createFormSchema({ name: 'client_id', label: 'Client ID', required: true })],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: true,
+ redirect_uri: 'https://example.com/callback',
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ // Click the settings icon
+ const button = screen.getByRole('button')
+ const gearIconContainer = button.querySelector('[class*="shrink-0"][class*="w-8"]')
+ if (gearIconContainer)
+ fireEvent.click(gearIconContainer)
+
+ await waitFor(() => {
+ // OAuthClientSettings modal should open
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Clipboard Operations', () => {
+ it('should have clipboard API available for copy operations', async () => {
+ const pluginPayload = createPluginPayload()
+ const mockWriteText = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText: mockWriteText },
+ configurable: true,
+ })
+
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [createFormSchema({ name: 'client_id', label: 'Client ID', required: true })],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ redirect_uri: 'https://example.com/callback',
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ fireEvent.click(screen.getByText('plugin.auth.setupOAuth'))
+
+ await waitFor(() => {
+ // OAuthClientSettings modal opens
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+
+ // Verify clipboard API is available
+ expect(navigator.clipboard.writeText).toBeDefined()
+ })
+ })
+
+ describe('__auth_client__ Logic', () => {
+ it('should return default when not configured and system OAuth params exist', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: true,
+ client_params: {},
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ // When isConfigured is true (is_system_oauth_params_exists=true), it should show the configured button
+ expect(screen.queryByText('plugin.auth.setupOAuth')).not.toBeInTheDocument()
+ })
+
+ it('should return custom when not configured and no system OAuth params', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ client_params: {},
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ // When not configured, it should show the setup button
+ expect(screen.getByText('plugin.auth.setupOAuth')).toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty schema', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ })
+
+ expect(() => {
+ render(, { wrapper: createWrapper() })
+ }).not.toThrow()
+ })
+
+ it('should handle undefined oAuthData fields', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue(undefined)
+
+ expect(() => {
+ render(, { wrapper: createWrapper() })
+ }).not.toThrow()
+ })
+
+ it('should handle null client_params', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [createFormSchema({ name: 'test' })],
+ is_oauth_custom_client_enabled: true,
+ is_system_oauth_params_exists: true,
+ client_params: null,
+ })
+
+ expect(() => {
+ render(, { wrapper: createWrapper() })
+ }).not.toThrow()
+ })
+ })
+})
+
+// ==================== ApiKeyModal Tests ====================
+describe('ApiKeyModal', () => {
+ let ApiKeyModal: typeof import('./api-key-modal').default
+
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key', required: true }),
+ ])
+ mockAddPluginCredential.mockResolvedValue({})
+ mockUpdatePluginCredential.mockResolvedValue({})
+ // Reset form values mock to return validation failed by default
+ mockGetFormValues.mockReturnValue({
+ isCheckValidated: false,
+ values: {},
+ })
+ const importedApiKeyModal = await import('./api-key-modal')
+ ApiKeyModal = importedApiKeyModal.default
+ })
+
+ describe('Rendering', () => {
+ it('should render modal with title', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(, { wrapper: createWrapper() })
+
+ expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument()
+ })
+
+ it('should render modal with subtitle', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(, { wrapper: createWrapper() })
+
+ expect(screen.getByText('plugin.auth.useApiAuthDesc')).toBeInTheDocument()
+ })
+
+ it('should render form when data is loaded', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(, { wrapper: createWrapper() })
+
+ // AuthForm is mocked, so check for the mock element
+ expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument()
+ })
+ })
+
+ describe('Props Testing', () => {
+ it('should call onClose when modal is closed', () => {
+ const pluginPayload = createPluginPayload()
+ const onClose = vi.fn()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Find and click cancel button
+ const cancelButton = screen.getByText('common.operation.cancel')
+ fireEvent.click(cancelButton)
+
+ expect(onClose).toHaveBeenCalled()
+ })
+
+ it('should disable confirm button when disabled prop is true', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const confirmButton = screen.getByText('common.operation.save')
+ expect(confirmButton.closest('button')).toBeDisabled()
+ })
+
+ it('should show modal when editValues is provided', () => {
+ const pluginPayload = createPluginPayload()
+ const editValues = {
+ __name__: 'Test Name',
+ __credential_id__: 'test-id',
+ api_key: 'test-key',
+ }
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument()
+ })
+
+ it('should use formSchemas from props when provided', () => {
+ const pluginPayload = createPluginPayload()
+ const customSchemas = [
+ createFormSchema({ name: 'custom_field', label: 'Custom Field' }),
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // AuthForm is mocked, verify modal renders
+ expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument()
+ })
+ })
+
+ describe('Form Behavior', () => {
+ it('should render AuthForm component', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(, { wrapper: createWrapper() })
+
+ // AuthForm is mocked, verify it's rendered
+ expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument()
+ })
+
+ it('should render modal with editValues', () => {
+ const pluginPayload = createPluginPayload()
+ const editValues = {
+ __name__: 'Existing Name',
+ api_key: 'existing-key',
+ }
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument()
+ })
+ })
+
+ describe('Form Submission - handleConfirm', () => {
+ beforeEach(() => {
+ // Default: form validation passes with empty values
+ mockGetFormValues.mockReturnValue({
+ isCheckValidated: true,
+ values: {
+ __name__: 'Test Name',
+ api_key: 'test-api-key',
+ },
+ })
+ })
+
+ it('should call addPluginCredential when creating new credential', async () => {
+ const pluginPayload = createPluginPayload()
+ const onClose = vi.fn()
+ const onUpdate = vi.fn()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key' }),
+ ])
+ mockAddPluginCredential.mockResolvedValue({})
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click confirm button
+ const confirmButton = screen.getByText('common.operation.save')
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockAddPluginCredential).toHaveBeenCalled()
+ })
+ })
+
+ it('should call updatePluginCredential when editing existing credential', async () => {
+ const pluginPayload = createPluginPayload()
+ const onClose = vi.fn()
+ const onUpdate = vi.fn()
+ const editValues = {
+ __name__: 'Test Credential',
+ __credential_id__: 'test-credential-id',
+ api_key: 'existing-key',
+ }
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key' }),
+ ])
+ mockUpdatePluginCredential.mockResolvedValue({})
+ mockGetFormValues.mockReturnValue({
+ isCheckValidated: true,
+ values: {
+ __name__: 'Test Credential',
+ __credential_id__: 'test-credential-id',
+ api_key: 'updated-key',
+ },
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click confirm button
+ const confirmButton = screen.getByText('common.operation.save')
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockUpdatePluginCredential).toHaveBeenCalled()
+ })
+ })
+
+ it('should call onClose and onUpdate after successful submission', async () => {
+ const pluginPayload = createPluginPayload()
+ const onClose = vi.fn()
+ const onUpdate = vi.fn()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key' }),
+ ])
+ mockAddPluginCredential.mockResolvedValue({})
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click confirm button
+ const confirmButton = screen.getByText('common.operation.save')
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled()
+ expect(onUpdate).toHaveBeenCalled()
+ })
+ })
+
+ it('should not call API when form validation fails', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key', required: true }),
+ ])
+ mockGetFormValues.mockReturnValue({
+ isCheckValidated: false,
+ values: {},
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click confirm button
+ const confirmButton = screen.getByText('common.operation.save')
+ fireEvent.click(confirmButton)
+
+ // Verify API was not called since validation failed synchronously
+ expect(mockAddPluginCredential).not.toHaveBeenCalled()
+ })
+
+ it('should handle doingAction state to prevent double submission', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key' }),
+ ])
+ // Make the API call slow
+ mockAddPluginCredential.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)))
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click confirm button twice quickly
+ const confirmButton = screen.getByText('common.operation.save')
+ fireEvent.click(confirmButton)
+ fireEvent.click(confirmButton)
+
+ // Should only be called once due to doingAction guard
+ await waitFor(() => {
+ expect(mockAddPluginCredential).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ it('should return early if doingActionRef is true during concurrent clicks', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key' }),
+ ])
+
+ // Create a promise that we can control
+ let resolveFirstCall: (value?: unknown) => void = () => {}
+ let apiCallCount = 0
+
+ mockAddPluginCredential.mockImplementation(() => {
+ apiCallCount++
+ if (apiCallCount === 1) {
+ // First call: return a pending promise
+ return new Promise((resolve) => {
+ resolveFirstCall = resolve
+ })
+ }
+ // Subsequent calls should not happen but return resolved promise
+ return Promise.resolve({})
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const confirmButton = screen.getByText('common.operation.save')
+
+ // First click starts the request
+ fireEvent.click(confirmButton)
+
+ // Wait for the first API call to be made
+ await waitFor(() => {
+ expect(apiCallCount).toBe(1)
+ })
+
+ // Second click while first request is still pending should be ignored
+ fireEvent.click(confirmButton)
+
+ // Verify only one API call was made (no additional calls)
+ expect(apiCallCount).toBe(1)
+
+ // Clean up by resolving the promise
+ resolveFirstCall()
+ })
+
+ it('should call onRemove when extra button is clicked in edit mode', async () => {
+ const pluginPayload = createPluginPayload()
+ const onRemove = vi.fn()
+ const editValues = {
+ __name__: 'Test Credential',
+ __credential_id__: 'test-credential-id',
+ }
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key' }),
+ ])
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Find and click the remove button
+ const removeButton = screen.getByText('common.operation.remove')
+ fireEvent.click(removeButton)
+
+ expect(onRemove).toHaveBeenCalled()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty credentials schema', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginCredentialSchema.mockReturnValue([])
+
+ render(, { wrapper: createWrapper() })
+
+ // Should still render the modal with authorization name field
+ expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument()
+ })
+
+ it('should handle undefined detail in pluginPayload', () => {
+ const pluginPayload = createPluginPayload({ detail: undefined })
+
+ expect(() => {
+ render(, { wrapper: createWrapper() })
+ }).not.toThrow()
+ })
+
+ it('should handle form schema with default values', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key', default: 'default-key' }),
+ ])
+
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ }).not.toThrow()
+
+ expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument()
+ })
+ })
+})
+
+// ==================== OAuthClientSettings Tests ====================
+describe('OAuthClientSettings', () => {
+ let OAuthClientSettings: typeof import('./oauth-client-settings').default
+
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ mockSetPluginOAuthCustomClient.mockResolvedValue({})
+ mockDeletePluginOAuthCustomClient.mockResolvedValue({})
+ const importedOAuthClientSettings = await import('./oauth-client-settings')
+ OAuthClientSettings = importedOAuthClientSettings.default
+ })
+
+ const defaultSchemas: FormSchema[] = [
+ createFormSchema({ name: 'client_id', label: 'Client ID', required: true }),
+ createFormSchema({ name: 'client_secret', label: 'Client Secret', required: true }),
+ ]
+
+ describe('Rendering', () => {
+ it('should render modal with correct title', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+
+ it('should render Save and Auth button', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.saveAndAuth')).toBeInTheDocument()
+ })
+
+ it('should render Save Only button', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.saveOnly')).toBeInTheDocument()
+ })
+
+ it('should render Cancel button', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
+ })
+
+ it('should render form from schemas', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // AuthForm is mocked
+ expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument()
+ })
+ })
+
+ describe('Props Testing', () => {
+ it('should call onClose when cancel button is clicked', () => {
+ const pluginPayload = createPluginPayload()
+ const onClose = vi.fn()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ fireEvent.click(screen.getByText('common.operation.cancel'))
+ expect(onClose).toHaveBeenCalled()
+ })
+
+ it('should disable buttons when disabled prop is true', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const confirmButton = screen.getByText('plugin.auth.saveAndAuth')
+ expect(confirmButton.closest('button')).toBeDisabled()
+ })
+
+ it('should render with editValues', () => {
+ const pluginPayload = createPluginPayload()
+ const editValues = {
+ client_id: 'existing-client-id',
+ client_secret: 'existing-secret',
+ __oauth_client__: 'custom',
+ }
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+ })
+
+ describe('Remove Button', () => {
+ it('should show remove button when custom client and hasOriginalClientParams', () => {
+ const pluginPayload = createPluginPayload()
+ const schemasWithOAuthClient: FormSchema[] = [
+ {
+ name: '__oauth_client__',
+ label: 'OAuth Client',
+ type: 'radio' as FormSchema['type'],
+ options: [
+ { label: 'Default', value: 'default' },
+ { label: 'Custom', value: 'custom' },
+ ],
+ default: 'custom',
+ required: false,
+ },
+ ...defaultSchemas,
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('common.operation.remove')).toBeInTheDocument()
+ })
+
+ it('should not show remove button when using default client', () => {
+ const pluginPayload = createPluginPayload()
+ const schemasWithOAuthClient: FormSchema[] = [
+ {
+ name: '__oauth_client__',
+ label: 'OAuth Client',
+ type: 'radio' as FormSchema['type'],
+ options: [
+ { label: 'Default', value: 'default' },
+ { label: 'Custom', value: 'custom' },
+ ],
+ default: 'default',
+ required: false,
+ },
+ ...defaultSchemas,
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.queryByText('common.operation.remove')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Form Submission', () => {
+ beforeEach(() => {
+ // Default: form validation passes
+ mockGetFormValues.mockReturnValue({
+ isCheckValidated: true,
+ values: {
+ __oauth_client__: 'custom',
+ client_id: 'test-client-id',
+ client_secret: 'test-secret',
+ },
+ })
+ })
+
+ it('should render Save and Auth button that is clickable', async () => {
+ const pluginPayload = createPluginPayload()
+ const onAuth = vi.fn().mockResolvedValue(undefined)
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const saveAndAuthButton = screen.getByText('plugin.auth.saveAndAuth')
+ expect(saveAndAuthButton).toBeInTheDocument()
+ expect(saveAndAuthButton.closest('button')).not.toBeDisabled()
+ })
+
+ it('should call setPluginOAuthCustomClient when Save Only is clicked', async () => {
+ const pluginPayload = createPluginPayload()
+ const onClose = vi.fn()
+ const onUpdate = vi.fn()
+ mockSetPluginOAuthCustomClient.mockResolvedValue({})
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click Save Only button
+ fireEvent.click(screen.getByText('plugin.auth.saveOnly'))
+
+ await waitFor(() => {
+ expect(mockSetPluginOAuthCustomClient).toHaveBeenCalled()
+ })
+ })
+
+ it('should call onClose and onUpdate after successful submission', async () => {
+ const pluginPayload = createPluginPayload()
+ const onClose = vi.fn()
+ const onUpdate = vi.fn()
+ mockSetPluginOAuthCustomClient.mockResolvedValue({})
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ fireEvent.click(screen.getByText('plugin.auth.saveOnly'))
+
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled()
+ expect(onUpdate).toHaveBeenCalled()
+ })
+ })
+
+ it('should call onAuth after handleConfirmAndAuthorize', async () => {
+ const pluginPayload = createPluginPayload()
+ const onAuth = vi.fn().mockResolvedValue(undefined)
+ const onClose = vi.fn()
+ mockSetPluginOAuthCustomClient.mockResolvedValue({})
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click Save and Auth button
+ fireEvent.click(screen.getByText('plugin.auth.saveAndAuth'))
+
+ await waitFor(() => {
+ expect(mockSetPluginOAuthCustomClient).toHaveBeenCalled()
+ expect(onAuth).toHaveBeenCalled()
+ })
+ })
+
+ it('should handle form with empty values', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Modal should render with save buttons
+ expect(screen.getByText('plugin.auth.saveOnly')).toBeInTheDocument()
+ expect(screen.getByText('plugin.auth.saveAndAuth')).toBeInTheDocument()
+ })
+
+ it('should call deletePluginOAuthCustomClient when Remove is clicked', async () => {
+ const pluginPayload = createPluginPayload()
+ const onClose = vi.fn()
+ const onUpdate = vi.fn()
+ mockDeletePluginOAuthCustomClient.mockResolvedValue({})
+
+ const schemasWithOAuthClient: FormSchema[] = [
+ {
+ name: '__oauth_client__',
+ label: 'OAuth Client',
+ type: 'radio' as FormSchema['type'],
+ options: [
+ { label: 'Default', value: 'default' },
+ { label: 'Custom', value: 'custom' },
+ ],
+ default: 'custom',
+ required: false,
+ },
+ ...defaultSchemas,
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click Remove button
+ fireEvent.click(screen.getByText('common.operation.remove'))
+
+ await waitFor(() => {
+ expect(mockDeletePluginOAuthCustomClient).toHaveBeenCalled()
+ })
+ })
+
+ it('should call onClose and onUpdate after successful removal', async () => {
+ const pluginPayload = createPluginPayload()
+ const onClose = vi.fn()
+ const onUpdate = vi.fn()
+ mockDeletePluginOAuthCustomClient.mockResolvedValue({})
+
+ const schemasWithOAuthClient: FormSchema[] = [
+ {
+ name: '__oauth_client__',
+ label: 'OAuth Client',
+ type: 'radio' as FormSchema['type'],
+ options: [
+ { label: 'Default', value: 'default' },
+ { label: 'Custom', value: 'custom' },
+ ],
+ default: 'custom',
+ required: false,
+ },
+ ...defaultSchemas,
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ fireEvent.click(screen.getByText('common.operation.remove'))
+
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled()
+ expect(onUpdate).toHaveBeenCalled()
+ })
+ })
+
+ it('should prevent double submission when doingAction is true', async () => {
+ const pluginPayload = createPluginPayload()
+ // Make the API call slow
+ mockSetPluginOAuthCustomClient.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)))
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click Save Only button twice quickly
+ const saveButton = screen.getByText('plugin.auth.saveOnly')
+ fireEvent.click(saveButton)
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ it('should return early from handleConfirm if doingActionRef is true', async () => {
+ const pluginPayload = createPluginPayload()
+ let resolveFirstCall: (value?: unknown) => void = () => {}
+ let apiCallCount = 0
+
+ mockSetPluginOAuthCustomClient.mockImplementation(() => {
+ apiCallCount++
+ if (apiCallCount === 1) {
+ return new Promise((resolve) => {
+ resolveFirstCall = resolve
+ })
+ }
+ return Promise.resolve({})
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const saveButton = screen.getByText('plugin.auth.saveOnly')
+
+ // First click starts the request
+ fireEvent.click(saveButton)
+
+ // Wait for the first API call to be made
+ await waitFor(() => {
+ expect(apiCallCount).toBe(1)
+ })
+
+ // Second click while first request is pending should be ignored
+ fireEvent.click(saveButton)
+
+ // Verify only one API call was made (no additional calls)
+ expect(apiCallCount).toBe(1)
+
+ // Clean up
+ resolveFirstCall()
+ })
+
+ it('should return early from handleRemove if doingActionRef is true', async () => {
+ const pluginPayload = createPluginPayload()
+ let resolveFirstCall: (value?: unknown) => void = () => {}
+ let deleteCallCount = 0
+
+ mockDeletePluginOAuthCustomClient.mockImplementation(() => {
+ deleteCallCount++
+ if (deleteCallCount === 1) {
+ return new Promise((resolve) => {
+ resolveFirstCall = resolve
+ })
+ }
+ return Promise.resolve({})
+ })
+
+ const schemasWithOAuthClient: FormSchema[] = [
+ {
+ name: '__oauth_client__',
+ label: 'OAuth Client',
+ type: 'radio' as FormSchema['type'],
+ options: [
+ { label: 'Default', value: 'default' },
+ { label: 'Custom', value: 'custom' },
+ ],
+ default: 'custom',
+ required: false,
+ },
+ ...defaultSchemas,
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const removeButton = screen.getByText('common.operation.remove')
+
+ // First click starts the delete request
+ fireEvent.click(removeButton)
+
+ // Wait for the first delete call to be made
+ await waitFor(() => {
+ expect(deleteCallCount).toBe(1)
+ })
+
+ // Second click while first request is pending should be ignored
+ fireEvent.click(removeButton)
+
+ // Verify only one delete call was made (no additional calls)
+ expect(deleteCallCount).toBe(1)
+
+ // Clean up
+ resolveFirstCall()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty schemas', () => {
+ const pluginPayload = createPluginPayload()
+
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ }).not.toThrow()
+ })
+
+ it('should handle schemas without default values', () => {
+ const pluginPayload = createPluginPayload()
+ const schemasWithoutDefaults: FormSchema[] = [
+ createFormSchema({ name: 'field1', label: 'Field 1', default: undefined }),
+ ]
+
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ }).not.toThrow()
+ })
+
+ it('should handle undefined editValues', () => {
+ const pluginPayload = createPluginPayload()
+
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ }).not.toThrow()
+ })
+ })
+
+ describe('Branch Coverage - defaultValues computation', () => {
+ it('should compute defaultValues from schemas with default values', () => {
+ const pluginPayload = createPluginPayload()
+ const schemasWithDefaults: FormSchema[] = [
+ createFormSchema({ name: 'client_id', label: 'Client ID', default: 'default-id' }),
+ createFormSchema({ name: 'client_secret', label: 'Client Secret', default: 'default-secret' }),
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+
+ it('should skip schemas without default values in defaultValues computation', () => {
+ const pluginPayload = createPluginPayload()
+ const mixedSchemas: FormSchema[] = [
+ createFormSchema({ name: 'field_with_default', label: 'With Default', default: 'value' }),
+ createFormSchema({ name: 'field_without_default', label: 'Without Default', default: undefined }),
+ createFormSchema({ name: 'field_with_empty', label: 'Empty Default', default: '' }),
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+ })
+
+ describe('Branch Coverage - __oauth_client__ value', () => {
+ beforeEach(() => {
+ mockGetFormValues.mockReturnValue({
+ isCheckValidated: true,
+ values: {
+ __oauth_client__: 'default',
+ client_id: 'test-id',
+ },
+ })
+ })
+
+ it('should send enable_oauth_custom_client=false when __oauth_client__ is default', async () => {
+ const pluginPayload = createPluginPayload()
+ mockSetPluginOAuthCustomClient.mockResolvedValue({})
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ fireEvent.click(screen.getByText('plugin.auth.saveOnly'))
+
+ await waitFor(() => {
+ expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledWith(
+ expect.objectContaining({
+ enable_oauth_custom_client: false,
+ }),
+ )
+ })
+ })
+
+ it('should send enable_oauth_custom_client=true when __oauth_client__ is custom', async () => {
+ const pluginPayload = createPluginPayload()
+ mockSetPluginOAuthCustomClient.mockResolvedValue({})
+ mockGetFormValues.mockReturnValue({
+ isCheckValidated: true,
+ values: {
+ __oauth_client__: 'custom',
+ client_id: 'test-id',
+ },
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ fireEvent.click(screen.getByText('plugin.auth.saveOnly'))
+
+ await waitFor(() => {
+ expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledWith(
+ expect.objectContaining({
+ enable_oauth_custom_client: true,
+ }),
+ )
+ })
+ })
+ })
+
+ describe('Branch Coverage - onAuth callback', () => {
+ beforeEach(() => {
+ mockGetFormValues.mockReturnValue({
+ isCheckValidated: true,
+ values: { __oauth_client__: 'custom' },
+ })
+ })
+
+ it('should call onAuth when provided and Save and Auth is clicked', async () => {
+ const pluginPayload = createPluginPayload()
+ const onAuth = vi.fn().mockResolvedValue(undefined)
+ mockSetPluginOAuthCustomClient.mockResolvedValue({})
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ fireEvent.click(screen.getByText('plugin.auth.saveAndAuth'))
+
+ await waitFor(() => {
+ expect(onAuth).toHaveBeenCalled()
+ })
+ })
+
+ it('should not call onAuth when not provided', async () => {
+ const pluginPayload = createPluginPayload()
+ mockSetPluginOAuthCustomClient.mockResolvedValue({})
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ fireEvent.click(screen.getByText('plugin.auth.saveAndAuth'))
+
+ await waitFor(() => {
+ expect(mockSetPluginOAuthCustomClient).toHaveBeenCalled()
+ })
+ // No onAuth to call, but should not throw
+ })
+ })
+
+ describe('Branch Coverage - disabled states', () => {
+ it('should disable buttons when disabled prop is true', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.saveAndAuth').closest('button')).toBeDisabled()
+ expect(screen.getByText('plugin.auth.saveOnly').closest('button')).toBeDisabled()
+ })
+
+ it('should disable Remove button when editValues is undefined', () => {
+ const pluginPayload = createPluginPayload()
+ const schemasWithOAuthClient: FormSchema[] = [
+ {
+ name: '__oauth_client__',
+ label: 'OAuth Client',
+ type: 'radio' as FormSchema['type'],
+ options: [
+ { label: 'Default', value: 'default' },
+ { label: 'Custom', value: 'custom' },
+ ],
+ default: 'custom',
+ required: false,
+ },
+ ...defaultSchemas,
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Remove button should exist but be disabled
+ const removeButton = screen.queryByText('common.operation.remove')
+ if (removeButton) {
+ expect(removeButton.closest('button')).toBeDisabled()
+ }
+ })
+
+ it('should disable Remove button when disabled prop is true', () => {
+ const pluginPayload = createPluginPayload()
+ const schemasWithOAuthClient: FormSchema[] = [
+ {
+ name: '__oauth_client__',
+ label: 'OAuth Client',
+ type: 'radio' as FormSchema['type'],
+ options: [
+ { label: 'Default', value: 'default' },
+ { label: 'Custom', value: 'custom' },
+ ],
+ default: 'custom',
+ required: false,
+ },
+ ...defaultSchemas,
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const removeButton = screen.getByText('common.operation.remove')
+ expect(removeButton.closest('button')).toBeDisabled()
+ })
+ })
+
+ describe('Branch Coverage - pluginPayload.detail', () => {
+ it('should render ReadmeEntrance when pluginPayload has detail', () => {
+ const pluginPayload = createPluginPayload({
+ detail: {
+ name: 'test-plugin',
+ label: { en_US: 'Test Plugin' },
+ } as unknown as PluginPayload['detail'],
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // ReadmeEntrance should be rendered (it's mocked in vitest.setup)
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+
+ it('should not render ReadmeEntrance when pluginPayload has no detail', () => {
+ const pluginPayload = createPluginPayload({ detail: undefined })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+ })
+
+ describe('Branch Coverage - footerSlot conditions', () => {
+ it('should show Remove button only when __oauth_client__=custom AND hasOriginalClientParams=true', () => {
+ const pluginPayload = createPluginPayload()
+ const schemasWithCustomOAuth: FormSchema[] = [
+ {
+ name: '__oauth_client__',
+ label: 'OAuth Client',
+ type: 'radio' as FormSchema['type'],
+ options: [
+ { label: 'Default', value: 'default' },
+ { label: 'Custom', value: 'custom' },
+ ],
+ default: 'custom',
+ required: false,
+ },
+ ...defaultSchemas,
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('common.operation.remove')).toBeInTheDocument()
+ })
+
+ it('should not show Remove button when hasOriginalClientParams=false', () => {
+ const pluginPayload = createPluginPayload()
+ const schemasWithCustomOAuth: FormSchema[] = [
+ {
+ name: '__oauth_client__',
+ label: 'OAuth Client',
+ type: 'radio' as FormSchema['type'],
+ options: [
+ { label: 'Default', value: 'default' },
+ { label: 'Custom', value: 'custom' },
+ ],
+ default: 'custom',
+ required: false,
+ },
+ ...defaultSchemas,
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.queryByText('common.operation.remove')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Memoization', () => {
+ it('should be a memoized component', async () => {
+ const OAuthClientSettingsDefault = (await import('./oauth-client-settings')).default
+ expect(typeof OAuthClientSettingsDefault).toBe('object')
+ })
+ })
+})
+
+// ==================== Integration Tests ====================
+describe('Authorize Components Integration', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key' }),
+ ])
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ redirect_uri: 'https://example.com/callback',
+ })
+ })
+
+ describe('AddApiKeyButton -> ApiKeyModal Flow', () => {
+ it('should open ApiKeyModal when AddApiKeyButton is clicked', async () => {
+ const AddApiKeyButton = (await import('./add-api-key-button')).default
+ const pluginPayload = createPluginPayload()
+
+ render(, { wrapper: createWrapper() })
+
+ fireEvent.click(screen.getByRole('button'))
+
+ await waitFor(() => {
+ expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('AddOAuthButton -> OAuthClientSettings Flow', () => {
+ it('should open OAuthClientSettings when setup button is clicked', async () => {
+ const AddOAuthButton = (await import('./add-oauth-button')).default
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ redirect_uri: 'https://example.com/callback',
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ fireEvent.click(screen.getByText('plugin.auth.setupOAuth'))
+
+ await waitFor(() => {
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-auth/authorize/index.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/index.spec.tsx
new file mode 100644
index 0000000000..354ef8eeea
--- /dev/null
+++ b/web/app/components/plugins/plugin-auth/authorize/index.spec.tsx
@@ -0,0 +1,786 @@
+import type { ReactNode } from 'react'
+import type { PluginPayload } from '../types'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { AuthCategory } from '../types'
+import Authorize from './index'
+
+// Create a wrapper with QueryClientProvider for real component testing
+const createTestQueryClient = () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ gcTime: 0,
+ },
+ },
+ })
+
+const createWrapper = () => {
+ const testQueryClient = createTestQueryClient()
+ return ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ )
+}
+
+// Mock API hooks - only mock network-related hooks
+const mockGetPluginOAuthClientSchema = vi.fn()
+
+vi.mock('../hooks/use-credential', () => ({
+ useGetPluginOAuthUrlHook: () => ({
+ mutateAsync: vi.fn().mockResolvedValue({ authorization_url: '' }),
+ }),
+ useGetPluginOAuthClientSchemaHook: () => ({
+ data: mockGetPluginOAuthClientSchema(),
+ isLoading: false,
+ }),
+ useSetPluginOAuthCustomClientHook: () => ({
+ mutateAsync: vi.fn().mockResolvedValue({}),
+ }),
+ useDeletePluginOAuthCustomClientHook: () => ({
+ mutateAsync: vi.fn().mockResolvedValue({}),
+ }),
+ useInvalidPluginOAuthClientSchemaHook: () => vi.fn(),
+ useAddPluginCredentialHook: () => ({
+ mutateAsync: vi.fn().mockResolvedValue({}),
+ }),
+ useUpdatePluginCredentialHook: () => ({
+ mutateAsync: vi.fn().mockResolvedValue({}),
+ }),
+ useGetPluginCredentialSchemaHook: () => ({
+ data: [],
+ isLoading: false,
+ }),
+}))
+
+// Mock openOAuthPopup - window operations
+vi.mock('@/hooks/use-oauth', () => ({
+ openOAuthPopup: vi.fn(),
+}))
+
+// Mock service/use-triggers - API service
+vi.mock('@/service/use-triggers', () => ({
+ useTriggerPluginDynamicOptions: () => ({
+ data: { options: [] },
+ isLoading: false,
+ }),
+ useTriggerPluginDynamicOptionsInfo: () => ({
+ data: null,
+ isLoading: false,
+ }),
+ useInvalidTriggerDynamicOptions: () => vi.fn(),
+}))
+
+// Factory function for creating test PluginPayload
+const createPluginPayload = (overrides: Partial = {}): PluginPayload => ({
+ category: AuthCategory.tool,
+ provider: 'test-provider',
+ ...overrides,
+})
+
+describe('Authorize', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ })
+ })
+
+ // ==================== Rendering Tests ====================
+ describe('Rendering', () => {
+ it('should render nothing when canOAuth and canApiKey are both false/undefined', () => {
+ const pluginPayload = createPluginPayload()
+
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // No buttons should be rendered
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ // Container should only have wrapper element
+ expect(container.querySelector('.flex')).toBeInTheDocument()
+ })
+
+ it('should render only OAuth button when canOAuth is true and canApiKey is false', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // OAuth button should exist (either configured or setup button)
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should render only API Key button when canApiKey is true and canOAuth is false', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should render both OAuth and API Key buttons when both are true', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const buttons = screen.getAllByRole('button')
+ expect(buttons.length).toBe(2)
+ })
+
+ it('should render divider when showDivider is true and both buttons are shown', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('or')).toBeInTheDocument()
+ })
+
+ it('should not render divider when showDivider is false', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.queryByText('or')).not.toBeInTheDocument()
+ })
+
+ it('should not render divider when only one button type is shown', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.queryByText('or')).not.toBeInTheDocument()
+ })
+
+ it('should render divider by default (showDivider defaults to true)', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('or')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Props Testing ====================
+ describe('Props Testing', () => {
+ describe('theme prop', () => {
+ it('should render buttons with secondary theme variant when theme is secondary', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const buttons = screen.getAllByRole('button')
+ buttons.forEach((button) => {
+ expect(button.className).toContain('btn-secondary')
+ })
+ })
+ })
+
+ describe('disabled prop', () => {
+ it('should disable OAuth button when disabled is true', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+
+ it('should disable API Key button when disabled is true', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+
+ it('should not disable buttons when disabled is false', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const buttons = screen.getAllByRole('button')
+ buttons.forEach((button) => {
+ expect(button).not.toBeDisabled()
+ })
+ })
+ })
+
+ describe('notAllowCustomCredential prop', () => {
+ it('should disable OAuth button when notAllowCustomCredential is true', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+
+ it('should disable API Key button when notAllowCustomCredential is true', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+
+ it('should add opacity class when notAllowCustomCredential is true', () => {
+ const pluginPayload = createPluginPayload()
+
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const wrappers = container.querySelectorAll('.opacity-50')
+ expect(wrappers.length).toBe(2) // Both OAuth and API Key wrappers
+ })
+ })
+ })
+
+ // ==================== Button Text Variations ====================
+ describe('Button Text Variations', () => {
+ it('should show correct OAuth text based on canApiKey', () => {
+ const pluginPayload = createPluginPayload()
+
+ // When canApiKey is false, should show "useOAuthAuth"
+ const { rerender } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toHaveTextContent('plugin.auth')
+
+ // When canApiKey is true, button text changes
+ rerender(
+ ,
+ )
+
+ const buttons = screen.getAllByRole('button')
+ expect(buttons.length).toBe(2)
+ })
+ })
+
+ // ==================== Memoization Dependencies ====================
+ describe('Memoization and Re-rendering', () => {
+ it('should maintain stable props across re-renders with same dependencies', () => {
+ const pluginPayload = createPluginPayload()
+ const onUpdate = vi.fn()
+
+ const { rerender } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const initialButtonCount = screen.getAllByRole('button').length
+
+ rerender(
+ ,
+ )
+
+ expect(screen.getAllByRole('button').length).toBe(initialButtonCount)
+ })
+
+ it('should update when canApiKey changes', () => {
+ const pluginPayload = createPluginPayload()
+
+ const { rerender } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getAllByRole('button').length).toBe(1)
+
+ rerender(
+ ,
+ )
+
+ expect(screen.getAllByRole('button').length).toBe(2)
+ })
+
+ it('should update when canOAuth changes', () => {
+ const pluginPayload = createPluginPayload()
+
+ const { rerender } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getAllByRole('button').length).toBe(1)
+
+ rerender(
+ ,
+ )
+
+ expect(screen.getAllByRole('button').length).toBe(2)
+ })
+
+ it('should update button variant when theme changes', () => {
+ const pluginPayload = createPluginPayload()
+
+ const { rerender } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const buttonPrimary = screen.getByRole('button')
+ // Primary theme with canOAuth=false should have primary variant
+ expect(buttonPrimary.className).toContain('btn-primary')
+
+ rerender(
+ ,
+ )
+
+ expect(screen.getByRole('button').className).toContain('btn-secondary')
+ })
+ })
+
+ // ==================== Edge Cases ====================
+ describe('Edge Cases', () => {
+ it('should handle undefined pluginPayload properties gracefully', () => {
+ const pluginPayload: PluginPayload = {
+ category: AuthCategory.tool,
+ provider: 'test-provider',
+ providerType: undefined,
+ detail: undefined,
+ }
+
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ }).not.toThrow()
+ })
+
+ it('should handle all auth categories', () => {
+ const categories = [AuthCategory.tool, AuthCategory.datasource, AuthCategory.model, AuthCategory.trigger]
+
+ categories.forEach((category) => {
+ const pluginPayload = createPluginPayload({ category })
+
+ const { unmount } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getAllByRole('button').length).toBe(2)
+
+ unmount()
+ })
+ })
+
+ it('should handle empty string provider', () => {
+ const pluginPayload = createPluginPayload({ provider: '' })
+
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ }).not.toThrow()
+ })
+
+ it('should handle both disabled and notAllowCustomCredential together', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const buttons = screen.getAllByRole('button')
+ buttons.forEach((button) => {
+ expect(button).toBeDisabled()
+ })
+ })
+ })
+
+ // ==================== Component Memoization ====================
+ describe('Component Memoization', () => {
+ it('should be a memoized component (exported with memo)', async () => {
+ const AuthorizeDefault = (await import('./index')).default
+ expect(AuthorizeDefault).toBeDefined()
+ // memo wrapped components are React elements with $$typeof
+ expect(typeof AuthorizeDefault).toBe('object')
+ })
+
+ it('should not re-render wrapper when notAllowCustomCredential stays the same', () => {
+ const pluginPayload = createPluginPayload()
+ const onUpdate = vi.fn()
+
+ const { rerender, container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const initialOpacityElements = container.querySelectorAll('.opacity-50').length
+
+ rerender(
+ ,
+ )
+
+ expect(container.querySelectorAll('.opacity-50').length).toBe(initialOpacityElements)
+ })
+
+ it('should update wrapper when notAllowCustomCredential changes', () => {
+ const pluginPayload = createPluginPayload()
+
+ const { rerender, container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(container.querySelectorAll('.opacity-50').length).toBe(0)
+
+ rerender(
+ ,
+ )
+
+ expect(container.querySelectorAll('.opacity-50').length).toBe(1)
+ })
+ })
+
+ // ==================== Integration with pluginPayload ====================
+ describe('pluginPayload Integration', () => {
+ it('should pass pluginPayload to OAuth button', () => {
+ const pluginPayload = createPluginPayload({
+ provider: 'special-provider',
+ category: AuthCategory.model,
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should pass pluginPayload to API Key button', () => {
+ const pluginPayload = createPluginPayload({
+ provider: 'another-provider',
+ category: AuthCategory.datasource,
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should handle pluginPayload with detail property', () => {
+ const pluginPayload = createPluginPayload({
+ detail: {
+ plugin_id: 'test-plugin',
+ name: 'Test Plugin',
+ } as PluginPayload['detail'],
+ })
+
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ }).not.toThrow()
+ })
+ })
+
+ // ==================== Conditional Rendering Scenarios ====================
+ describe('Conditional Rendering Scenarios', () => {
+ it('should handle rapid prop changes', () => {
+ const pluginPayload = createPluginPayload()
+
+ const { rerender } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getAllByRole('button').length).toBe(2)
+
+ rerender()
+ expect(screen.getAllByRole('button').length).toBe(1)
+
+ rerender()
+ expect(screen.getAllByRole('button').length).toBe(1)
+
+ rerender()
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ })
+
+ it('should correctly toggle divider visibility based on button combinations', () => {
+ const pluginPayload = createPluginPayload()
+
+ const { rerender } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('or')).toBeInTheDocument()
+
+ rerender(
+ ,
+ )
+
+ expect(screen.queryByText('or')).not.toBeInTheDocument()
+
+ rerender(
+ ,
+ )
+
+ expect(screen.queryByText('or')).not.toBeInTheDocument()
+ })
+ })
+
+ // ==================== Accessibility ====================
+ describe('Accessibility', () => {
+ it('should have accessible button elements', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const buttons = screen.getAllByRole('button')
+ expect(buttons.length).toBe(2)
+ })
+
+ it('should indicate disabled state for accessibility', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const buttons = screen.getAllByRole('button')
+ buttons.forEach((button) => {
+ expect(button).toBeDisabled()
+ })
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-auth/index.spec.tsx b/web/app/components/plugins/plugin-auth/index.spec.tsx
new file mode 100644
index 0000000000..328de71e8d
--- /dev/null
+++ b/web/app/components/plugins/plugin-auth/index.spec.tsx
@@ -0,0 +1,2035 @@
+import type { ReactNode } from 'react'
+import type { Credential, PluginPayload } from './types'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { act, fireEvent, render, renderHook, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { AuthCategory, CredentialTypeEnum } from './types'
+
+// ==================== Mock Setup ====================
+
+// Mock API hooks for credential operations
+const mockGetPluginCredentialInfo = vi.fn()
+const mockDeletePluginCredential = vi.fn()
+const mockSetPluginDefaultCredential = vi.fn()
+const mockUpdatePluginCredential = vi.fn()
+const mockInvalidPluginCredentialInfo = vi.fn()
+const mockGetPluginOAuthUrl = vi.fn()
+const mockGetPluginOAuthClientSchema = vi.fn()
+const mockSetPluginOAuthCustomClient = vi.fn()
+const mockDeletePluginOAuthCustomClient = vi.fn()
+const mockInvalidPluginOAuthClientSchema = vi.fn()
+const mockAddPluginCredential = vi.fn()
+const mockGetPluginCredentialSchema = vi.fn()
+const mockInvalidToolsByType = vi.fn()
+
+vi.mock('@/service/use-plugins-auth', () => ({
+ useGetPluginCredentialInfo: (url: string) => ({
+ data: url ? mockGetPluginCredentialInfo() : undefined,
+ isLoading: false,
+ }),
+ useDeletePluginCredential: () => ({
+ mutateAsync: mockDeletePluginCredential,
+ }),
+ useSetPluginDefaultCredential: () => ({
+ mutateAsync: mockSetPluginDefaultCredential,
+ }),
+ useUpdatePluginCredential: () => ({
+ mutateAsync: mockUpdatePluginCredential,
+ }),
+ useInvalidPluginCredentialInfo: () => mockInvalidPluginCredentialInfo,
+ useGetPluginOAuthUrl: () => ({
+ mutateAsync: mockGetPluginOAuthUrl,
+ }),
+ useGetPluginOAuthClientSchema: () => ({
+ data: mockGetPluginOAuthClientSchema(),
+ isLoading: false,
+ }),
+ useSetPluginOAuthCustomClient: () => ({
+ mutateAsync: mockSetPluginOAuthCustomClient,
+ }),
+ useDeletePluginOAuthCustomClient: () => ({
+ mutateAsync: mockDeletePluginOAuthCustomClient,
+ }),
+ useInvalidPluginOAuthClientSchema: () => mockInvalidPluginOAuthClientSchema,
+ useAddPluginCredential: () => ({
+ mutateAsync: mockAddPluginCredential,
+ }),
+ useGetPluginCredentialSchema: () => ({
+ data: mockGetPluginCredentialSchema(),
+ isLoading: false,
+ }),
+}))
+
+vi.mock('@/service/use-tools', () => ({
+ useInvalidToolsByType: () => mockInvalidToolsByType,
+}))
+
+// Mock AppContext
+const mockIsCurrentWorkspaceManager = vi.fn()
+vi.mock('@/context/app-context', () => ({
+ useAppContext: () => ({
+ isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(),
+ }),
+}))
+
+// Mock toast context
+const mockNotify = vi.fn()
+vi.mock('@/app/components/base/toast', () => ({
+ useToastContext: () => ({
+ notify: mockNotify,
+ }),
+}))
+
+// Mock openOAuthPopup
+vi.mock('@/hooks/use-oauth', () => ({
+ openOAuthPopup: vi.fn(),
+}))
+
+// Mock service/use-triggers
+vi.mock('@/service/use-triggers', () => ({
+ useTriggerPluginDynamicOptions: () => ({
+ data: { options: [] },
+ isLoading: false,
+ }),
+ useTriggerPluginDynamicOptionsInfo: () => ({
+ data: null,
+ isLoading: false,
+ }),
+ useInvalidTriggerDynamicOptions: () => vi.fn(),
+}))
+
+// ==================== Test Utilities ====================
+
+const createTestQueryClient = () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ gcTime: 0,
+ },
+ },
+ })
+
+const createWrapper = () => {
+ const testQueryClient = createTestQueryClient()
+ return ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ )
+}
+
+// Factory functions for test data
+const createPluginPayload = (overrides: Partial = {}): PluginPayload => ({
+ category: AuthCategory.tool,
+ provider: 'test-provider',
+ ...overrides,
+})
+
+const createCredential = (overrides: Partial = {}): Credential => ({
+ id: 'test-credential-id',
+ name: 'Test Credential',
+ provider: 'test-provider',
+ credential_type: CredentialTypeEnum.API_KEY,
+ is_default: false,
+ credentials: { api_key: 'test-key' },
+ ...overrides,
+})
+
+const createCredentialList = (count: number, overrides: Partial[] = []): Credential[] => {
+ return Array.from({ length: count }, (_, i) => createCredential({
+ id: `credential-${i}`,
+ name: `Credential ${i}`,
+ is_default: i === 0,
+ ...overrides[i],
+ }))
+}
+
+// ==================== Index Exports Tests ====================
+describe('Index Exports', () => {
+ it('should export all required components and hooks', async () => {
+ const exports = await import('./index')
+
+ expect(exports.AddApiKeyButton).toBeDefined()
+ expect(exports.AddOAuthButton).toBeDefined()
+ expect(exports.ApiKeyModal).toBeDefined()
+ expect(exports.Authorized).toBeDefined()
+ expect(exports.AuthorizedInDataSourceNode).toBeDefined()
+ expect(exports.AuthorizedInNode).toBeDefined()
+ expect(exports.usePluginAuth).toBeDefined()
+ expect(exports.PluginAuth).toBeDefined()
+ expect(exports.PluginAuthInAgent).toBeDefined()
+ expect(exports.PluginAuthInDataSourceNode).toBeDefined()
+ })
+
+ it('should export AuthCategory enum', async () => {
+ const exports = await import('./index')
+
+ expect(exports.AuthCategory).toBeDefined()
+ expect(exports.AuthCategory.tool).toBe('tool')
+ expect(exports.AuthCategory.datasource).toBe('datasource')
+ expect(exports.AuthCategory.model).toBe('model')
+ expect(exports.AuthCategory.trigger).toBe('trigger')
+ })
+
+ it('should export CredentialTypeEnum', async () => {
+ const exports = await import('./index')
+
+ expect(exports.CredentialTypeEnum).toBeDefined()
+ expect(exports.CredentialTypeEnum.OAUTH2).toBe('oauth2')
+ expect(exports.CredentialTypeEnum.API_KEY).toBe('api-key')
+ })
+})
+
+// ==================== Types Tests ====================
+describe('Types', () => {
+ describe('AuthCategory enum', () => {
+ it('should have correct values', () => {
+ expect(AuthCategory.tool).toBe('tool')
+ expect(AuthCategory.datasource).toBe('datasource')
+ expect(AuthCategory.model).toBe('model')
+ expect(AuthCategory.trigger).toBe('trigger')
+ })
+
+ it('should have exactly 4 categories', () => {
+ const values = Object.values(AuthCategory)
+ expect(values).toHaveLength(4)
+ })
+ })
+
+ describe('CredentialTypeEnum', () => {
+ it('should have correct values', () => {
+ expect(CredentialTypeEnum.OAUTH2).toBe('oauth2')
+ expect(CredentialTypeEnum.API_KEY).toBe('api-key')
+ })
+
+ it('should have exactly 2 types', () => {
+ const values = Object.values(CredentialTypeEnum)
+ expect(values).toHaveLength(2)
+ })
+ })
+
+ describe('Credential type', () => {
+ it('should allow creating valid credentials', () => {
+ const credential: Credential = {
+ id: 'test-id',
+ name: 'Test',
+ provider: 'test-provider',
+ is_default: true,
+ }
+ expect(credential.id).toBe('test-id')
+ expect(credential.is_default).toBe(true)
+ })
+
+ it('should allow optional fields', () => {
+ const credential: Credential = {
+ id: 'test-id',
+ name: 'Test',
+ provider: 'test-provider',
+ is_default: false,
+ credential_type: CredentialTypeEnum.API_KEY,
+ credentials: { key: 'value' },
+ isWorkspaceDefault: true,
+ from_enterprise: false,
+ not_allowed_to_use: false,
+ }
+ expect(credential.credential_type).toBe(CredentialTypeEnum.API_KEY)
+ expect(credential.isWorkspaceDefault).toBe(true)
+ })
+ })
+
+ describe('PluginPayload type', () => {
+ it('should allow creating valid plugin payload', () => {
+ const payload: PluginPayload = {
+ category: AuthCategory.tool,
+ provider: 'test-provider',
+ }
+ expect(payload.category).toBe(AuthCategory.tool)
+ })
+
+ it('should allow optional fields', () => {
+ const payload: PluginPayload = {
+ category: AuthCategory.datasource,
+ provider: 'test-provider',
+ providerType: 'builtin',
+ detail: undefined,
+ }
+ expect(payload.providerType).toBe('builtin')
+ })
+ })
+})
+
+// ==================== Utils Tests ====================
+describe('Utils', () => {
+ describe('transformFormSchemasSecretInput', () => {
+ it('should transform secret input values to hidden format', async () => {
+ const { transformFormSchemasSecretInput } = await import('./utils')
+
+ const secretNames = ['api_key', 'secret_token']
+ const values = {
+ api_key: 'actual-key',
+ secret_token: 'actual-token',
+ public_key: 'public-value',
+ }
+
+ const result = transformFormSchemasSecretInput(secretNames, values)
+
+ expect(result.api_key).toBe('[__HIDDEN__]')
+ expect(result.secret_token).toBe('[__HIDDEN__]')
+ expect(result.public_key).toBe('public-value')
+ })
+
+ it('should not transform empty secret values', async () => {
+ const { transformFormSchemasSecretInput } = await import('./utils')
+
+ const secretNames = ['api_key']
+ const values = {
+ api_key: '',
+ public_key: 'public-value',
+ }
+
+ const result = transformFormSchemasSecretInput(secretNames, values)
+
+ expect(result.api_key).toBe('')
+ expect(result.public_key).toBe('public-value')
+ })
+
+ it('should not transform undefined secret values', async () => {
+ const { transformFormSchemasSecretInput } = await import('./utils')
+
+ const secretNames = ['api_key']
+ const values = {
+ public_key: 'public-value',
+ }
+
+ const result = transformFormSchemasSecretInput(secretNames, values)
+
+ expect(result.api_key).toBeUndefined()
+ expect(result.public_key).toBe('public-value')
+ })
+
+ it('should handle empty secret names array', async () => {
+ const { transformFormSchemasSecretInput } = await import('./utils')
+
+ const secretNames: string[] = []
+ const values = {
+ api_key: 'actual-key',
+ public_key: 'public-value',
+ }
+
+ const result = transformFormSchemasSecretInput(secretNames, values)
+
+ expect(result.api_key).toBe('actual-key')
+ expect(result.public_key).toBe('public-value')
+ })
+
+ it('should handle empty values object', async () => {
+ const { transformFormSchemasSecretInput } = await import('./utils')
+
+ const secretNames = ['api_key']
+ const values = {}
+
+ const result = transformFormSchemasSecretInput(secretNames, values)
+
+ expect(Object.keys(result)).toHaveLength(0)
+ })
+
+ it('should preserve original values object immutably', async () => {
+ const { transformFormSchemasSecretInput } = await import('./utils')
+
+ const secretNames = ['api_key']
+ const values = {
+ api_key: 'actual-key',
+ public_key: 'public-value',
+ }
+
+ transformFormSchemasSecretInput(secretNames, values)
+
+ expect(values.api_key).toBe('actual-key')
+ })
+
+ it('should handle null-ish values correctly', async () => {
+ const { transformFormSchemasSecretInput } = await import('./utils')
+
+ const secretNames = ['api_key', 'null_key']
+ const values = {
+ api_key: null,
+ null_key: 0,
+ }
+
+ const result = transformFormSchemasSecretInput(secretNames, values as Record)
+
+ // null is preserved as-is to represent an explicitly unset secret, not masked as [__HIDDEN__]
+ expect(result.api_key).toBe(null)
+ // numeric values like 0 are also preserved; only non-empty string secrets are transformed
+ expect(result.null_key).toBe(0)
+ })
+ })
+})
+
+// ==================== useGetApi Hook Tests ====================
+describe('useGetApi Hook', () => {
+ describe('tool category', () => {
+ it('should return correct API endpoints for tool category', async () => {
+ const { useGetApi } = await import('./hooks/use-get-api')
+
+ const pluginPayload = createPluginPayload({
+ category: AuthCategory.tool,
+ provider: 'test-tool',
+ })
+
+ const apiMap = useGetApi(pluginPayload)
+
+ expect(apiMap.getCredentialInfo).toBe('/workspaces/current/tool-provider/builtin/test-tool/credential/info')
+ expect(apiMap.setDefaultCredential).toBe('/workspaces/current/tool-provider/builtin/test-tool/default-credential')
+ expect(apiMap.getCredentials).toBe('/workspaces/current/tool-provider/builtin/test-tool/credentials')
+ expect(apiMap.addCredential).toBe('/workspaces/current/tool-provider/builtin/test-tool/add')
+ expect(apiMap.updateCredential).toBe('/workspaces/current/tool-provider/builtin/test-tool/update')
+ expect(apiMap.deleteCredential).toBe('/workspaces/current/tool-provider/builtin/test-tool/delete')
+ expect(apiMap.getOauthUrl).toBe('/oauth/plugin/test-tool/tool/authorization-url')
+ expect(apiMap.getOauthClientSchema).toBe('/workspaces/current/tool-provider/builtin/test-tool/oauth/client-schema')
+ expect(apiMap.setCustomOauthClient).toBe('/workspaces/current/tool-provider/builtin/test-tool/oauth/custom-client')
+ expect(apiMap.deleteCustomOAuthClient).toBe('/workspaces/current/tool-provider/builtin/test-tool/oauth/custom-client')
+ })
+
+ it('should return getCredentialSchema function for tool category', async () => {
+ const { useGetApi } = await import('./hooks/use-get-api')
+
+ const pluginPayload = createPluginPayload({
+ category: AuthCategory.tool,
+ provider: 'test-tool',
+ })
+
+ const apiMap = useGetApi(pluginPayload)
+
+ expect(apiMap.getCredentialSchema(CredentialTypeEnum.API_KEY)).toBe(
+ '/workspaces/current/tool-provider/builtin/test-tool/credential/schema/api-key',
+ )
+ expect(apiMap.getCredentialSchema(CredentialTypeEnum.OAUTH2)).toBe(
+ '/workspaces/current/tool-provider/builtin/test-tool/credential/schema/oauth2',
+ )
+ })
+ })
+
+ describe('datasource category', () => {
+ it('should return correct API endpoints for datasource category', async () => {
+ const { useGetApi } = await import('./hooks/use-get-api')
+
+ const pluginPayload = createPluginPayload({
+ category: AuthCategory.datasource,
+ provider: 'test-datasource',
+ })
+
+ const apiMap = useGetApi(pluginPayload)
+
+ expect(apiMap.getCredentialInfo).toBe('')
+ expect(apiMap.setDefaultCredential).toBe('/auth/plugin/datasource/test-datasource/default')
+ expect(apiMap.getCredentials).toBe('/auth/plugin/datasource/test-datasource')
+ expect(apiMap.addCredential).toBe('/auth/plugin/datasource/test-datasource')
+ expect(apiMap.updateCredential).toBe('/auth/plugin/datasource/test-datasource/update')
+ expect(apiMap.deleteCredential).toBe('/auth/plugin/datasource/test-datasource/delete')
+ expect(apiMap.getOauthUrl).toBe('/oauth/plugin/test-datasource/datasource/get-authorization-url')
+ expect(apiMap.getOauthClientSchema).toBe('')
+ expect(apiMap.setCustomOauthClient).toBe('/auth/plugin/datasource/test-datasource/custom-client')
+ expect(apiMap.deleteCustomOAuthClient).toBe('/auth/plugin/datasource/test-datasource/custom-client')
+ })
+
+ it('should return empty string for getCredentialSchema in datasource', async () => {
+ const { useGetApi } = await import('./hooks/use-get-api')
+
+ const pluginPayload = createPluginPayload({
+ category: AuthCategory.datasource,
+ provider: 'test-datasource',
+ })
+
+ const apiMap = useGetApi(pluginPayload)
+
+ expect(apiMap.getCredentialSchema(CredentialTypeEnum.API_KEY)).toBe('')
+ })
+ })
+
+ describe('other categories', () => {
+ it('should return empty strings for model category', async () => {
+ const { useGetApi } = await import('./hooks/use-get-api')
+
+ const pluginPayload = createPluginPayload({
+ category: AuthCategory.model,
+ provider: 'test-model',
+ })
+
+ const apiMap = useGetApi(pluginPayload)
+
+ expect(apiMap.getCredentialInfo).toBe('')
+ expect(apiMap.setDefaultCredential).toBe('')
+ expect(apiMap.getCredentials).toBe('')
+ expect(apiMap.addCredential).toBe('')
+ expect(apiMap.updateCredential).toBe('')
+ expect(apiMap.deleteCredential).toBe('')
+ expect(apiMap.getCredentialSchema(CredentialTypeEnum.API_KEY)).toBe('')
+ })
+
+ it('should return empty strings for trigger category', async () => {
+ const { useGetApi } = await import('./hooks/use-get-api')
+
+ const pluginPayload = createPluginPayload({
+ category: AuthCategory.trigger,
+ provider: 'test-trigger',
+ })
+
+ const apiMap = useGetApi(pluginPayload)
+
+ expect(apiMap.getCredentialInfo).toBe('')
+ expect(apiMap.setDefaultCredential).toBe('')
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should handle empty provider', async () => {
+ const { useGetApi } = await import('./hooks/use-get-api')
+
+ const pluginPayload = createPluginPayload({
+ category: AuthCategory.tool,
+ provider: '',
+ })
+
+ const apiMap = useGetApi(pluginPayload)
+
+ expect(apiMap.getCredentialInfo).toBe('/workspaces/current/tool-provider/builtin//credential/info')
+ })
+
+ it('should handle special characters in provider name', async () => {
+ const { useGetApi } = await import('./hooks/use-get-api')
+
+ const pluginPayload = createPluginPayload({
+ category: AuthCategory.tool,
+ provider: 'test-provider_v2',
+ })
+
+ const apiMap = useGetApi(pluginPayload)
+
+ expect(apiMap.getCredentialInfo).toContain('test-provider_v2')
+ })
+ })
+})
+
+// ==================== usePluginAuth Hook Tests ====================
+describe('usePluginAuth Hook', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsCurrentWorkspaceManager.mockReturnValue(true)
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [],
+ supported_credential_types: [],
+ allow_custom_token: true,
+ })
+ })
+
+ it('should return isAuthorized false when no credentials', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.isAuthorized).toBe(false)
+ expect(result.current.credentials).toHaveLength(0)
+ })
+
+ it('should return isAuthorized true when credentials exist', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [createCredential()],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.isAuthorized).toBe(true)
+ expect(result.current.credentials).toHaveLength(1)
+ })
+
+ it('should return canOAuth true when oauth2 is supported', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [],
+ supported_credential_types: [CredentialTypeEnum.OAUTH2],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.canOAuth).toBe(true)
+ expect(result.current.canApiKey).toBe(false)
+ })
+
+ it('should return canApiKey true when api-key is supported', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.canOAuth).toBe(false)
+ expect(result.current.canApiKey).toBe(true)
+ })
+
+ it('should return both canOAuth and canApiKey when both supported', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [],
+ supported_credential_types: [CredentialTypeEnum.OAUTH2, CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.canOAuth).toBe(true)
+ expect(result.current.canApiKey).toBe(true)
+ })
+
+ it('should return disabled true when user is not workspace manager', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ mockIsCurrentWorkspaceManager.mockReturnValue(false)
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.disabled).toBe(true)
+ })
+
+ it('should return disabled false when user is workspace manager', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ mockIsCurrentWorkspaceManager.mockReturnValue(true)
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.disabled).toBe(false)
+ })
+
+ it('should return notAllowCustomCredential based on allow_custom_token', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [],
+ supported_credential_types: [],
+ allow_custom_token: false,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.notAllowCustomCredential).toBe(true)
+ })
+
+ it('should return invalidPluginCredentialInfo function', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(typeof result.current.invalidPluginCredentialInfo).toBe('function')
+ })
+
+ it('should not fetch when enable is false', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, false), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.isAuthorized).toBe(false)
+ expect(result.current.credentials).toHaveLength(0)
+ })
+})
+
+// ==================== usePluginAuthAction Hook Tests ====================
+describe('usePluginAuthAction Hook', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockDeletePluginCredential.mockResolvedValue({})
+ mockSetPluginDefaultCredential.mockResolvedValue({})
+ mockUpdatePluginCredential.mockResolvedValue({})
+ })
+
+ it('should return all action handlers', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.doingAction).toBe(false)
+ expect(typeof result.current.handleSetDoingAction).toBe('function')
+ expect(typeof result.current.openConfirm).toBe('function')
+ expect(typeof result.current.closeConfirm).toBe('function')
+ expect(result.current.deleteCredentialId).toBe(null)
+ expect(typeof result.current.setDeleteCredentialId).toBe('function')
+ expect(typeof result.current.handleConfirm).toBe('function')
+ expect(result.current.editValues).toBe(null)
+ expect(typeof result.current.setEditValues).toBe('function')
+ expect(typeof result.current.handleEdit).toBe('function')
+ expect(typeof result.current.handleRemove).toBe('function')
+ expect(typeof result.current.handleSetDefault).toBe('function')
+ expect(typeof result.current.handleRename).toBe('function')
+ })
+
+ it('should open and close confirm dialog', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ act(() => {
+ result.current.openConfirm('test-credential-id')
+ })
+
+ expect(result.current.deleteCredentialId).toBe('test-credential-id')
+
+ act(() => {
+ result.current.closeConfirm()
+ })
+
+ expect(result.current.deleteCredentialId).toBe(null)
+ })
+
+ it('should handle edit with values', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ const editValues = { key: 'value' }
+
+ act(() => {
+ result.current.handleEdit('test-id', editValues)
+ })
+
+ expect(result.current.editValues).toEqual(editValues)
+ })
+
+ it('should handle confirm delete', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ const onUpdate = vi.fn()
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload, onUpdate), {
+ wrapper: createWrapper(),
+ })
+
+ act(() => {
+ result.current.openConfirm('test-credential-id')
+ })
+
+ await act(async () => {
+ await result.current.handleConfirm()
+ })
+
+ expect(mockDeletePluginCredential).toHaveBeenCalledWith({ credential_id: 'test-credential-id' })
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'common.api.actionSuccess',
+ })
+ expect(onUpdate).toHaveBeenCalled()
+ expect(result.current.deleteCredentialId).toBe(null)
+ })
+
+ it('should not confirm delete when no credential id', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ await act(async () => {
+ await result.current.handleConfirm()
+ })
+
+ expect(mockDeletePluginCredential).not.toHaveBeenCalled()
+ })
+
+ it('should handle set default', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ const onUpdate = vi.fn()
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload, onUpdate), {
+ wrapper: createWrapper(),
+ })
+
+ await act(async () => {
+ await result.current.handleSetDefault('test-credential-id')
+ })
+
+ expect(mockSetPluginDefaultCredential).toHaveBeenCalledWith('test-credential-id')
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'common.api.actionSuccess',
+ })
+ expect(onUpdate).toHaveBeenCalled()
+ })
+
+ it('should handle rename', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ const onUpdate = vi.fn()
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload, onUpdate), {
+ wrapper: createWrapper(),
+ })
+
+ await act(async () => {
+ await result.current.handleRename({
+ credential_id: 'test-credential-id',
+ name: 'New Name',
+ })
+ })
+
+ expect(mockUpdatePluginCredential).toHaveBeenCalledWith({
+ credential_id: 'test-credential-id',
+ name: 'New Name',
+ })
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'common.api.actionSuccess',
+ })
+ expect(onUpdate).toHaveBeenCalled()
+ })
+
+ it('should prevent concurrent actions', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ act(() => {
+ result.current.handleSetDoingAction(true)
+ })
+
+ act(() => {
+ result.current.openConfirm('test-credential-id')
+ })
+
+ await act(async () => {
+ await result.current.handleConfirm()
+ })
+
+ // Should not call delete when already doing action
+ expect(mockDeletePluginCredential).not.toHaveBeenCalled()
+ })
+
+ it('should handle remove after edit', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ act(() => {
+ result.current.handleEdit('test-credential-id', { key: 'value' })
+ })
+
+ act(() => {
+ result.current.handleRemove()
+ })
+
+ expect(result.current.deleteCredentialId).toBe('test-credential-id')
+ })
+})
+
+// ==================== PluginAuth Component Tests ====================
+describe('PluginAuth Component', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsCurrentWorkspaceManager.mockReturnValue(true)
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ })
+ })
+
+ it('should render Authorize when not authorized', async () => {
+ const PluginAuth = (await import('./plugin-auth')).default
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Should render authorize button
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should render Authorized when authorized and no children', async () => {
+ const PluginAuth = (await import('./plugin-auth')).default
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [createCredential()],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Should render authorized content
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should render children when authorized and children provided', async () => {
+ const PluginAuth = (await import('./plugin-auth')).default
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [createCredential()],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+
+ Custom Content
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByTestId('custom-children')).toBeInTheDocument()
+ expect(screen.getByText('Custom Content')).toBeInTheDocument()
+ })
+
+ it('should apply className when not authorized', async () => {
+ const PluginAuth = (await import('./plugin-auth')).default
+
+ const pluginPayload = createPluginPayload()
+
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(container.firstChild).toHaveClass('custom-class')
+ })
+
+ it('should not apply className when authorized', async () => {
+ const PluginAuth = (await import('./plugin-auth')).default
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [createCredential()],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(container.firstChild).not.toHaveClass('custom-class')
+ })
+
+ it('should be memoized', async () => {
+ const PluginAuthModule = await import('./plugin-auth')
+ expect(typeof PluginAuthModule.default).toBe('object')
+ })
+})
+
+// ==================== PluginAuthInAgent Component Tests ====================
+describe('PluginAuthInAgent Component', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsCurrentWorkspaceManager.mockReturnValue(true)
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [createCredential()],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ })
+ })
+
+ it('should render Authorize when not authorized', async () => {
+ const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should render Authorized with workspace default when authorized', async () => {
+ const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ expect(screen.getByText('plugin.auth.workspaceDefault')).toBeInTheDocument()
+ })
+
+ it('should show credential name when credentialId is provided', async () => {
+ const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
+
+ const credential = createCredential({ id: 'selected-id', name: 'Selected Credential' })
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [credential],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('Selected Credential')).toBeInTheDocument()
+ })
+
+ it('should show auth removed when credential not found', async () => {
+ const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [createCredential()],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.authRemoved')).toBeInTheDocument()
+ })
+
+ it('should show unavailable when credential is not allowed to use', async () => {
+ const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
+
+ const credential = createCredential({
+ id: 'unavailable-id',
+ name: 'Unavailable Credential',
+ not_allowed_to_use: true,
+ from_enterprise: false,
+ })
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [credential],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Check that button text contains unavailable
+ const button = screen.getByRole('button')
+ expect(button.textContent).toContain('plugin.auth.unavailable')
+ })
+
+ it('should call onAuthorizationItemClick when item is clicked', async () => {
+ const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
+
+ const onAuthorizationItemClick = vi.fn()
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click to open popup
+ const buttons = screen.getAllByRole('button')
+ fireEvent.click(buttons[0])
+
+ // Verify popup is opened (there will be multiple buttons after opening)
+ expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
+ })
+
+ it('should trigger handleAuthorizationItemClick and close popup when authorization item is clicked', async () => {
+ const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
+
+ const onAuthorizationItemClick = vi.fn()
+ const credential = createCredential({ id: 'test-cred-id', name: 'Test Credential' })
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [credential],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click trigger button to open popup
+ const triggerButton = screen.getByRole('button')
+ fireEvent.click(triggerButton)
+
+ // Find and click the workspace default item in the dropdown
+ // There will be multiple elements with this text, we need the one in the popup (not the trigger)
+ const workspaceDefaultItems = screen.getAllByText('plugin.auth.workspaceDefault')
+ // The second one is in the popup list (first one is the trigger button)
+ const popupItem = workspaceDefaultItems.length > 1 ? workspaceDefaultItems[1] : workspaceDefaultItems[0]
+ fireEvent.click(popupItem)
+
+ // Verify onAuthorizationItemClick was called with empty string for workspace default
+ expect(onAuthorizationItemClick).toHaveBeenCalledWith('')
+ })
+
+ it('should call onAuthorizationItemClick with credential id when specific credential is clicked', async () => {
+ const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
+
+ const onAuthorizationItemClick = vi.fn()
+ const credential = createCredential({
+ id: 'specific-cred-id',
+ name: 'Specific Credential',
+ credential_type: CredentialTypeEnum.API_KEY,
+ })
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [credential],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click trigger button to open popup
+ const triggerButton = screen.getByRole('button')
+ fireEvent.click(triggerButton)
+
+ // Find and click the specific credential item - there might be multiple "Specific Credential" texts
+ const credentialItems = screen.getAllByText('Specific Credential')
+ // Click the one in the popup (usually the last one if trigger shows different text)
+ const popupItem = credentialItems[credentialItems.length - 1]
+ fireEvent.click(popupItem)
+
+ // Verify onAuthorizationItemClick was called with the credential id
+ expect(onAuthorizationItemClick).toHaveBeenCalledWith('specific-cred-id')
+ })
+
+ it('should be memoized', async () => {
+ const PluginAuthInAgentModule = await import('./plugin-auth-in-agent')
+ expect(typeof PluginAuthInAgentModule.default).toBe('object')
+ })
+})
+
+// ==================== PluginAuthInDataSourceNode Component Tests ====================
+describe('PluginAuthInDataSourceNode Component', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render connect button when not authorized', async () => {
+ const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default
+
+ const onJumpToDataSourcePage = vi.fn()
+
+ render(
+ ,
+ )
+
+ const button = screen.getByRole('button')
+ expect(button).toBeInTheDocument()
+ expect(screen.getByText('common.integrations.connect')).toBeInTheDocument()
+ })
+
+ it('should call onJumpToDataSourcePage when connect button is clicked', async () => {
+ const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default
+
+ const onJumpToDataSourcePage = vi.fn()
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button'))
+ expect(onJumpToDataSourcePage).toHaveBeenCalledTimes(1)
+ })
+
+ it('should render children when authorized', async () => {
+ const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default
+
+ const onJumpToDataSourcePage = vi.fn()
+
+ render(
+
+ Authorized Content
+ ,
+ )
+
+ expect(screen.getByTestId('children-content')).toBeInTheDocument()
+ expect(screen.getByText('Authorized Content')).toBeInTheDocument()
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ })
+
+ it('should not render connect button when authorized', async () => {
+ const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default
+
+ const onJumpToDataSourcePage = vi.fn()
+
+ render(
+ ,
+ )
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ })
+
+ it('should not render children when not authorized', async () => {
+ const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default
+
+ const onJumpToDataSourcePage = vi.fn()
+
+ render(
+
+ Authorized Content
+ ,
+ )
+
+ expect(screen.queryByTestId('children-content')).not.toBeInTheDocument()
+ })
+
+ it('should handle undefined isAuthorized (falsy)', async () => {
+ const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default
+
+ const onJumpToDataSourcePage = vi.fn()
+
+ render(
+
+ Content
+ ,
+ )
+
+ // isAuthorized is undefined, which is falsy, so connect button should be shown
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ expect(screen.queryByTestId('children-content')).not.toBeInTheDocument()
+ })
+
+ it('should be memoized', async () => {
+ const PluginAuthInDataSourceNodeModule = await import('./plugin-auth-in-datasource-node')
+ expect(typeof PluginAuthInDataSourceNodeModule.default).toBe('object')
+ })
+})
+
+// ==================== AuthorizedInDataSourceNode Component Tests ====================
+describe('AuthorizedInDataSourceNode Component', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render with singular authorization text when authorizationsNum is 1', async () => {
+ const AuthorizedInDataSourceNode = (await import('./authorized-in-data-source-node')).default
+
+ const onJumpToDataSourcePage = vi.fn()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ expect(screen.getByText('plugin.auth.authorization')).toBeInTheDocument()
+ })
+
+ it('should render with plural authorizations text when authorizationsNum > 1', async () => {
+ const AuthorizedInDataSourceNode = (await import('./authorized-in-data-source-node')).default
+
+ const onJumpToDataSourcePage = vi.fn()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('plugin.auth.authorizations')).toBeInTheDocument()
+ })
+
+ it('should call onJumpToDataSourcePage when button is clicked', async () => {
+ const AuthorizedInDataSourceNode = (await import('./authorized-in-data-source-node')).default
+
+ const onJumpToDataSourcePage = vi.fn()
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button'))
+ expect(onJumpToDataSourcePage).toHaveBeenCalledTimes(1)
+ })
+
+ it('should render with green indicator', async () => {
+ const AuthorizedInDataSourceNode = (await import('./authorized-in-data-source-node')).default
+
+ const { container } = render(
+ ,
+ )
+
+ // Check that indicator component is rendered
+ expect(container.querySelector('.mr-1\\.5')).toBeInTheDocument()
+ })
+
+ it('should handle authorizationsNum of 0', async () => {
+ const AuthorizedInDataSourceNode = (await import('./authorized-in-data-source-node')).default
+
+ render(
+ ,
+ )
+
+ // 0 is not > 1, so should show singular
+ expect(screen.getByText('plugin.auth.authorization')).toBeInTheDocument()
+ })
+
+ it('should be memoized', async () => {
+ const AuthorizedInDataSourceNodeModule = await import('./authorized-in-data-source-node')
+ expect(typeof AuthorizedInDataSourceNodeModule.default).toBe('object')
+ })
+})
+
+// ==================== AuthorizedInNode Component Tests ====================
+describe('AuthorizedInNode Component', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsCurrentWorkspaceManager.mockReturnValue(true)
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [createCredential({ is_default: true })],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ })
+ })
+
+ it('should render with workspace default when no credentialId', async () => {
+ const AuthorizedInNode = (await import('./authorized-in-node')).default
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.workspaceDefault')).toBeInTheDocument()
+ })
+
+ it('should render credential name when credentialId matches', async () => {
+ const AuthorizedInNode = (await import('./authorized-in-node')).default
+
+ const credential = createCredential({ id: 'selected-id', name: 'My Credential' })
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [credential],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('My Credential')).toBeInTheDocument()
+ })
+
+ it('should show auth removed when credentialId not found', async () => {
+ const AuthorizedInNode = (await import('./authorized-in-node')).default
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [createCredential()],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.authRemoved')).toBeInTheDocument()
+ })
+
+ it('should show unavailable when credential is not allowed', async () => {
+ const AuthorizedInNode = (await import('./authorized-in-node')).default
+
+ const credential = createCredential({
+ id: 'unavailable-id',
+ not_allowed_to_use: true,
+ from_enterprise: false,
+ })
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [credential],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Check that button text contains unavailable
+ const button = screen.getByRole('button')
+ expect(button.textContent).toContain('plugin.auth.unavailable')
+ })
+
+ it('should show unavailable when default credential is not allowed', async () => {
+ const AuthorizedInNode = (await import('./authorized-in-node')).default
+
+ const credential = createCredential({
+ is_default: true,
+ not_allowed_to_use: true,
+ })
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [credential],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Check that button text contains unavailable
+ const button = screen.getByRole('button')
+ expect(button.textContent).toContain('plugin.auth.unavailable')
+ })
+
+ it('should call onAuthorizationItemClick when clicking', async () => {
+ const AuthorizedInNode = (await import('./authorized-in-node')).default
+
+ const onAuthorizationItemClick = vi.fn()
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click to open the popup
+ const buttons = screen.getAllByRole('button')
+ fireEvent.click(buttons[0])
+
+ // The popup should be open now - there will be multiple buttons after opening
+ expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
+ })
+
+ it('should be memoized', async () => {
+ const AuthorizedInNodeModule = await import('./authorized-in-node')
+ expect(typeof AuthorizedInNodeModule.default).toBe('object')
+ })
+})
+
+// ==================== useCredential Hooks Tests ====================
+describe('useCredential Hooks', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [],
+ supported_credential_types: [],
+ allow_custom_token: true,
+ })
+ })
+
+ describe('useGetPluginCredentialInfoHook', () => {
+ it('should return credential info when enabled', async () => {
+ const { useGetPluginCredentialInfoHook } = await import('./hooks/use-credential')
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [createCredential()],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => useGetPluginCredentialInfoHook(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.data).toBeDefined()
+ expect(result.current.data?.credentials).toHaveLength(1)
+ })
+
+ it('should not fetch when disabled', async () => {
+ const { useGetPluginCredentialInfoHook } = await import('./hooks/use-credential')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => useGetPluginCredentialInfoHook(pluginPayload, false), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.data).toBeUndefined()
+ })
+ })
+
+ describe('useDeletePluginCredentialHook', () => {
+ it('should return mutateAsync function', async () => {
+ const { useDeletePluginCredentialHook } = await import('./hooks/use-credential')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => useDeletePluginCredentialHook(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ expect(typeof result.current.mutateAsync).toBe('function')
+ })
+ })
+
+ describe('useInvalidPluginCredentialInfoHook', () => {
+ it('should return invalidation function that calls both invalidators', async () => {
+ const { useInvalidPluginCredentialInfoHook } = await import('./hooks/use-credential')
+
+ const pluginPayload = createPluginPayload({ providerType: 'builtin' })
+
+ const { result } = renderHook(() => useInvalidPluginCredentialInfoHook(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ expect(typeof result.current).toBe('function')
+
+ result.current()
+
+ expect(mockInvalidPluginCredentialInfo).toHaveBeenCalled()
+ expect(mockInvalidToolsByType).toHaveBeenCalled()
+ })
+ })
+
+ describe('useSetPluginDefaultCredentialHook', () => {
+ it('should return mutateAsync function', async () => {
+ const { useSetPluginDefaultCredentialHook } = await import('./hooks/use-credential')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => useSetPluginDefaultCredentialHook(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ expect(typeof result.current.mutateAsync).toBe('function')
+ })
+ })
+
+ describe('useGetPluginCredentialSchemaHook', () => {
+ it('should return schema data', async () => {
+ const { useGetPluginCredentialSchemaHook } = await import('./hooks/use-credential')
+
+ mockGetPluginCredentialSchema.mockReturnValue([{ name: 'api_key', type: 'string' }])
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(
+ () => useGetPluginCredentialSchemaHook(pluginPayload, CredentialTypeEnum.API_KEY),
+ { wrapper: createWrapper() },
+ )
+
+ expect(result.current.data).toBeDefined()
+ })
+ })
+
+ describe('useAddPluginCredentialHook', () => {
+ it('should return mutateAsync function', async () => {
+ const { useAddPluginCredentialHook } = await import('./hooks/use-credential')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => useAddPluginCredentialHook(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ expect(typeof result.current.mutateAsync).toBe('function')
+ })
+ })
+
+ describe('useUpdatePluginCredentialHook', () => {
+ it('should return mutateAsync function', async () => {
+ const { useUpdatePluginCredentialHook } = await import('./hooks/use-credential')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => useUpdatePluginCredentialHook(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ expect(typeof result.current.mutateAsync).toBe('function')
+ })
+ })
+
+ describe('useGetPluginOAuthUrlHook', () => {
+ it('should return mutateAsync function', async () => {
+ const { useGetPluginOAuthUrlHook } = await import('./hooks/use-credential')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => useGetPluginOAuthUrlHook(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ expect(typeof result.current.mutateAsync).toBe('function')
+ })
+ })
+
+ describe('useGetPluginOAuthClientSchemaHook', () => {
+ it('should return schema data', async () => {
+ const { useGetPluginOAuthClientSchemaHook } = await import('./hooks/use-credential')
+
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => useGetPluginOAuthClientSchemaHook(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.data).toBeDefined()
+ })
+ })
+
+ describe('useSetPluginOAuthCustomClientHook', () => {
+ it('should return mutateAsync function', async () => {
+ const { useSetPluginOAuthCustomClientHook } = await import('./hooks/use-credential')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => useSetPluginOAuthCustomClientHook(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ expect(typeof result.current.mutateAsync).toBe('function')
+ })
+ })
+
+ describe('useDeletePluginOAuthCustomClientHook', () => {
+ it('should return mutateAsync function', async () => {
+ const { useDeletePluginOAuthCustomClientHook } = await import('./hooks/use-credential')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => useDeletePluginOAuthCustomClientHook(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ expect(typeof result.current.mutateAsync).toBe('function')
+ })
+ })
+})
+
+// ==================== Edge Cases and Error Handling ====================
+describe('Edge Cases and Error Handling', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsCurrentWorkspaceManager.mockReturnValue(true)
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ })
+ })
+
+ describe('PluginAuth edge cases', () => {
+ it('should handle empty provider gracefully', async () => {
+ const PluginAuth = (await import('./plugin-auth')).default
+
+ const pluginPayload = createPluginPayload({ provider: '' })
+
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ }).not.toThrow()
+ })
+
+ it('should handle tool and datasource auth categories with button', async () => {
+ const PluginAuth = (await import('./plugin-auth')).default
+
+ // Tool and datasource categories should render with API support
+ const categoriesWithApi = [AuthCategory.tool]
+
+ for (const category of categoriesWithApi) {
+ const pluginPayload = createPluginPayload({ category })
+
+ const { unmount } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+
+ unmount()
+ }
+ })
+
+ it('should handle model and trigger categories without throwing', async () => {
+ const PluginAuth = (await import('./plugin-auth')).default
+
+ // Model and trigger categories have empty API endpoints, so they render without buttons
+ const categoriesWithoutApi = [AuthCategory.model, AuthCategory.trigger]
+
+ for (const category of categoriesWithoutApi) {
+ const pluginPayload = createPluginPayload({ category })
+
+ expect(() => {
+ const { unmount } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ unmount()
+ }).not.toThrow()
+ }
+ })
+
+ it('should handle undefined detail', async () => {
+ const PluginAuth = (await import('./plugin-auth')).default
+
+ const pluginPayload = createPluginPayload({ detail: undefined })
+
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ }).not.toThrow()
+ })
+ })
+
+ describe('usePluginAuthAction error handling', () => {
+ it('should handle delete error gracefully', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ mockDeletePluginCredential.mockRejectedValue(new Error('Delete failed'))
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ act(() => {
+ result.current.openConfirm('test-id')
+ })
+
+ // Should not throw, error is caught
+ await expect(
+ act(async () => {
+ await result.current.handleConfirm()
+ }),
+ ).rejects.toThrow('Delete failed')
+
+ // Action state should be reset
+ expect(result.current.doingAction).toBe(false)
+ })
+
+ it('should handle set default error gracefully', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ mockSetPluginDefaultCredential.mockRejectedValue(new Error('Set default failed'))
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ await expect(
+ act(async () => {
+ await result.current.handleSetDefault('test-id')
+ }),
+ ).rejects.toThrow('Set default failed')
+
+ expect(result.current.doingAction).toBe(false)
+ })
+
+ it('should handle rename error gracefully', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ mockUpdatePluginCredential.mockRejectedValue(new Error('Rename failed'))
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ await expect(
+ act(async () => {
+ await result.current.handleRename({ credential_id: 'test-id', name: 'New Name' })
+ }),
+ ).rejects.toThrow('Rename failed')
+
+ expect(result.current.doingAction).toBe(false)
+ })
+ })
+
+ describe('Credential list edge cases', () => {
+ it('should handle large credential lists', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ const largeCredentialList = createCredentialList(100)
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: largeCredentialList,
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.isAuthorized).toBe(true)
+ expect(result.current.credentials).toHaveLength(100)
+ })
+
+ it('should handle mixed credential types', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ const mixedCredentials = [
+ createCredential({ id: '1', credential_type: CredentialTypeEnum.API_KEY }),
+ createCredential({ id: '2', credential_type: CredentialTypeEnum.OAUTH2 }),
+ createCredential({ id: '3', credential_type: undefined }),
+ ]
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: mixedCredentials,
+ supported_credential_types: [CredentialTypeEnum.API_KEY, CredentialTypeEnum.OAUTH2],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.credentials).toHaveLength(3)
+ expect(result.current.canOAuth).toBe(true)
+ expect(result.current.canApiKey).toBe(true)
+ })
+ })
+
+ describe('Boundary conditions', () => {
+ it('should handle special characters in provider name', async () => {
+ const { useGetApi } = await import('./hooks/use-get-api')
+
+ const pluginPayload = createPluginPayload({
+ provider: 'test-provider_v2.0',
+ })
+
+ const apiMap = useGetApi(pluginPayload)
+
+ expect(apiMap.getCredentialInfo).toContain('test-provider_v2.0')
+ })
+
+ it('should handle very long provider names', async () => {
+ const { useGetApi } = await import('./hooks/use-get-api')
+
+ const longProvider = 'a'.repeat(200)
+ const pluginPayload = createPluginPayload({
+ provider: longProvider,
+ })
+
+ const apiMap = useGetApi(pluginPayload)
+
+ expect(apiMap.getCredentialInfo).toContain(longProvider)
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-item/action.spec.tsx b/web/app/components/plugins/plugin-item/action.spec.tsx
new file mode 100644
index 0000000000..9969357bb6
--- /dev/null
+++ b/web/app/components/plugins/plugin-item/action.spec.tsx
@@ -0,0 +1,937 @@
+import type { MetaData, PluginCategoryEnum } from '../types'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import Toast from '@/app/components/base/toast'
+
+// ==================== Imports (after mocks) ====================
+
+import { PluginSource } from '../types'
+import Action from './action'
+
+// ==================== Mock Setup ====================
+
+// Use vi.hoisted to define mock functions that can be referenced in vi.mock
+const {
+ mockUninstallPlugin,
+ mockFetchReleases,
+ mockCheckForUpdates,
+ mockSetShowUpdatePluginModal,
+ mockInvalidateInstalledPluginList,
+} = vi.hoisted(() => ({
+ mockUninstallPlugin: vi.fn(),
+ mockFetchReleases: vi.fn(),
+ mockCheckForUpdates: vi.fn(),
+ mockSetShowUpdatePluginModal: vi.fn(),
+ mockInvalidateInstalledPluginList: vi.fn(),
+}))
+
+// Mock uninstall plugin service
+vi.mock('@/service/plugins', () => ({
+ uninstallPlugin: (id: string) => mockUninstallPlugin(id),
+}))
+
+// Mock GitHub releases hook
+vi.mock('../install-plugin/hooks', () => ({
+ useGitHubReleases: () => ({
+ fetchReleases: mockFetchReleases,
+ checkForUpdates: mockCheckForUpdates,
+ }),
+}))
+
+// Mock modal context
+vi.mock('@/context/modal-context', () => ({
+ useModalContext: () => ({
+ setShowUpdatePluginModal: mockSetShowUpdatePluginModal,
+ }),
+}))
+
+// Mock invalidate installed plugin list
+vi.mock('@/service/use-plugins', () => ({
+ useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList,
+}))
+
+// Mock PluginInfo component - has complex dependencies (Modal, KeyValueItem)
+vi.mock('../plugin-page/plugin-info', () => ({
+ default: ({ repository, release, packageName, onHide }: {
+ repository: string
+ release: string
+ packageName: string
+ onHide: () => void
+ }) => (
+
+
+
+ ),
+}))
+
+// Mock Tooltip - uses PortalToFollowElem which requires complex floating UI setup
+// Simplified mock that just renders children with tooltip content accessible
+vi.mock('../../base/tooltip', () => ({
+ default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
+
+ {children}
+
+ ),
+}))
+
+// Mock Confirm - uses createPortal which has issues in test environment
+vi.mock('../../base/confirm', () => ({
+ default: ({ isShow, title, content, onCancel, onConfirm, isLoading, isDisabled }: {
+ isShow: boolean
+ title: string
+ content: React.ReactNode
+ onCancel: () => void
+ onConfirm: () => void
+ isLoading: boolean
+ isDisabled: boolean
+ }) => {
+ if (!isShow)
+ return null
+ return (
+
+
{title}
+
{content}
+
+
+
+ )
+ },
+}))
+
+// ==================== Test Utilities ====================
+
+type ActionProps = {
+ author: string
+ installationId: string
+ pluginUniqueIdentifier: string
+ pluginName: string
+ category: PluginCategoryEnum
+ usedInApps: number
+ isShowFetchNewVersion: boolean
+ isShowInfo: boolean
+ isShowDelete: boolean
+ onDelete: () => void
+ meta?: MetaData
+}
+
+const createActionProps = (overrides: Partial = {}): ActionProps => ({
+ author: 'test-author',
+ installationId: 'install-123',
+ pluginUniqueIdentifier: 'test-author/test-plugin@1.0.0',
+ pluginName: 'test-plugin',
+ category: 'tool' as PluginCategoryEnum,
+ usedInApps: 5,
+ isShowFetchNewVersion: false,
+ isShowInfo: false,
+ isShowDelete: true,
+ onDelete: vi.fn(),
+ meta: {
+ repo: 'test-author/test-plugin',
+ version: '1.0.0',
+ package: 'test-plugin.difypkg',
+ },
+ ...overrides,
+})
+
+// ==================== Tests ====================
+
+// Helper to find action buttons (real ActionButton component uses type="button")
+const getActionButtons = () => screen.getAllByRole('button')
+const queryActionButtons = () => screen.queryAllByRole('button')
+
+describe('Action Component', () => {
+ // Spy on Toast.notify - real component but we track calls
+ let toastNotifySpy: ReturnType
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ // Spy on Toast.notify and mock implementation to avoid DOM side effects
+ toastNotifySpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
+ mockUninstallPlugin.mockResolvedValue({ success: true })
+ mockFetchReleases.mockResolvedValue([])
+ mockCheckForUpdates.mockReturnValue({
+ needUpdate: false,
+ toastProps: { type: 'info', message: 'Up to date' },
+ })
+ })
+
+ afterEach(() => {
+ toastNotifySpy.mockRestore()
+ })
+
+ // ==================== Rendering Tests ====================
+ describe('Rendering', () => {
+ it('should render delete button when isShowDelete is true', () => {
+ // Arrange
+ const props = createActionProps({
+ isShowDelete: true,
+ isShowInfo: false,
+ isShowFetchNewVersion: false,
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(getActionButtons()).toHaveLength(1)
+ })
+
+ it('should render fetch new version button when isShowFetchNewVersion is true', () => {
+ // Arrange
+ const props = createActionProps({
+ isShowFetchNewVersion: true,
+ isShowInfo: false,
+ isShowDelete: false,
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(getActionButtons()).toHaveLength(1)
+ })
+
+ it('should render info button when isShowInfo is true', () => {
+ // Arrange
+ const props = createActionProps({
+ isShowFetchNewVersion: false,
+ isShowInfo: true,
+ isShowDelete: false,
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(getActionButtons()).toHaveLength(1)
+ })
+
+ it('should render all buttons when all flags are true', () => {
+ // Arrange
+ const props = createActionProps({
+ isShowFetchNewVersion: true,
+ isShowInfo: true,
+ isShowDelete: true,
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(getActionButtons()).toHaveLength(3)
+ })
+
+ it('should render no buttons when all flags are false', () => {
+ // Arrange
+ const props = createActionProps({
+ isShowFetchNewVersion: false,
+ isShowInfo: false,
+ isShowDelete: false,
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(queryActionButtons()).toHaveLength(0)
+ })
+
+ it('should render tooltips for each button', () => {
+ // Arrange
+ const props = createActionProps({
+ isShowFetchNewVersion: true,
+ isShowInfo: true,
+ isShowDelete: true,
+ })
+
+ // Act
+ render()
+
+ // Assert
+ const tooltips = screen.getAllByTestId('tooltip')
+ expect(tooltips).toHaveLength(3)
+ })
+ })
+
+ // ==================== Delete Functionality Tests ====================
+ describe('Delete Functionality', () => {
+ it('should show delete confirm modal when delete button is clicked', () => {
+ // Arrange
+ const props = createActionProps({
+ isShowDelete: true,
+ isShowInfo: false,
+ isShowFetchNewVersion: false,
+ })
+
+ // Act
+ render()
+ fireEvent.click(getActionButtons()[0])
+
+ // Assert
+ expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
+ expect(screen.getByTestId('confirm-title')).toHaveTextContent('plugin.action.delete')
+ })
+
+ it('should display plugin name in delete confirm content', () => {
+ // Arrange
+ const props = createActionProps({
+ isShowDelete: true,
+ isShowInfo: false,
+ isShowFetchNewVersion: false,
+ pluginName: 'my-awesome-plugin',
+ })
+
+ // Act
+ render()
+ fireEvent.click(getActionButtons()[0])
+
+ // Assert
+ expect(screen.getByText('my-awesome-plugin')).toBeInTheDocument()
+ })
+
+ it('should hide confirm modal when cancel is clicked', () => {
+ // Arrange
+ const props = createActionProps({
+ isShowDelete: true,
+ isShowInfo: false,
+ isShowFetchNewVersion: false,
+ })
+
+ // Act
+ render()
+ fireEvent.click(getActionButtons()[0])
+ expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('confirm-cancel'))
+
+ // Assert
+ expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
+ })
+
+ it('should call uninstallPlugin when confirm is clicked', async () => {
+ // Arrange
+ const props = createActionProps({
+ isShowDelete: true,
+ isShowInfo: false,
+ isShowFetchNewVersion: false,
+ installationId: 'install-456',
+ })
+
+ // Act
+ render()
+ fireEvent.click(getActionButtons()[0])
+ fireEvent.click(screen.getByTestId('confirm-ok'))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockUninstallPlugin).toHaveBeenCalledWith('install-456')
+ })
+ })
+
+ it('should call onDelete callback after successful uninstall', async () => {
+ // Arrange
+ mockUninstallPlugin.mockResolvedValue({ success: true })
+ const onDelete = vi.fn()
+ const props = createActionProps({
+ isShowDelete: true,
+ isShowInfo: false,
+ isShowFetchNewVersion: false,
+ onDelete,
+ })
+
+ // Act
+ render()
+ fireEvent.click(getActionButtons()[0])
+ fireEvent.click(screen.getByTestId('confirm-ok'))
+
+ // Assert
+ await waitFor(() => {
+ expect(onDelete).toHaveBeenCalled()
+ })
+ })
+
+ it('should not call onDelete if uninstall fails', async () => {
+ // Arrange
+ mockUninstallPlugin.mockResolvedValue({ success: false })
+ const onDelete = vi.fn()
+ const props = createActionProps({
+ isShowDelete: true,
+ isShowInfo: false,
+ isShowFetchNewVersion: false,
+ onDelete,
+ })
+
+ // Act
+ render()
+ fireEvent.click(getActionButtons()[0])
+ fireEvent.click(screen.getByTestId('confirm-ok'))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockUninstallPlugin).toHaveBeenCalled()
+ })
+ expect(onDelete).not.toHaveBeenCalled()
+ })
+
+ it('should handle uninstall error gracefully', async () => {
+ // Arrange
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
+ mockUninstallPlugin.mockRejectedValue(new Error('Network error'))
+ const props = createActionProps({
+ isShowDelete: true,
+ isShowInfo: false,
+ isShowFetchNewVersion: false,
+ })
+
+ // Act
+ render()
+ fireEvent.click(getActionButtons()[0])
+ fireEvent.click(screen.getByTestId('confirm-ok'))
+
+ // Assert
+ await waitFor(() => {
+ expect(consoleError).toHaveBeenCalledWith('uninstallPlugin error', expect.any(Error))
+ })
+
+ consoleError.mockRestore()
+ })
+
+ it('should show loading state during deletion', async () => {
+ // Arrange
+ let resolveUninstall: (value: { success: boolean }) => void
+ mockUninstallPlugin.mockReturnValue(
+ new Promise((resolve) => {
+ resolveUninstall = resolve
+ }),
+ )
+ const props = createActionProps({
+ isShowDelete: true,
+ isShowInfo: false,
+ isShowFetchNewVersion: false,
+ })
+
+ // Act
+ render()
+ fireEvent.click(getActionButtons()[0])
+ fireEvent.click(screen.getByTestId('confirm-ok'))
+
+ // Assert - Loading state
+ await waitFor(() => {
+ expect(screen.getByTestId('confirm-modal')).toHaveAttribute('data-loading', 'true')
+ })
+
+ // Resolve and check modal closes
+ resolveUninstall!({ success: true })
+ await waitFor(() => {
+ expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ // ==================== Plugin Info Tests ====================
+ describe('Plugin Info', () => {
+ it('should show plugin info modal when info button is clicked', () => {
+ // Arrange
+ const props = createActionProps({
+ isShowInfo: true,
+ isShowDelete: false,
+ isShowFetchNewVersion: false,
+ meta: {
+ repo: 'owner/repo-name',
+ version: '2.0.0',
+ package: 'my-package.difypkg',
+ },
+ })
+
+ // Act
+ render()
+ fireEvent.click(getActionButtons()[0])
+
+ // Assert
+ expect(screen.getByTestId('plugin-info-modal')).toBeInTheDocument()
+ expect(screen.getByTestId('plugin-info-modal')).toHaveAttribute('data-repo', 'owner/repo-name')
+ expect(screen.getByTestId('plugin-info-modal')).toHaveAttribute('data-release', '2.0.0')
+ expect(screen.getByTestId('plugin-info-modal')).toHaveAttribute('data-package', 'my-package.difypkg')
+ })
+
+ it('should hide plugin info modal when close is clicked', () => {
+ // Arrange
+ const props = createActionProps({
+ isShowInfo: true,
+ isShowDelete: false,
+ isShowFetchNewVersion: false,
+ })
+
+ // Act
+ render()
+ fireEvent.click(getActionButtons()[0])
+ expect(screen.getByTestId('plugin-info-modal')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('close-plugin-info'))
+
+ // Assert
+ expect(screen.queryByTestId('plugin-info-modal')).not.toBeInTheDocument()
+ })
+ })
+
+ // ==================== Check for Updates Tests ====================
+ describe('Check for Updates', () => {
+ it('should fetch releases when check for updates button is clicked', async () => {
+ // Arrange
+ mockFetchReleases.mockResolvedValue([{ version: '1.0.0' }])
+ const props = createActionProps({
+ isShowFetchNewVersion: true,
+ isShowDelete: false,
+ isShowInfo: false,
+ meta: {
+ repo: 'owner/repo',
+ version: '1.0.0',
+ package: 'pkg.difypkg',
+ },
+ })
+
+ // Act
+ render()
+ fireEvent.click(getActionButtons()[0])
+
+ // Assert
+ await waitFor(() => {
+ expect(mockFetchReleases).toHaveBeenCalledWith('owner', 'repo')
+ })
+ })
+
+ it('should use author and pluginName as fallback for empty repo parts', async () => {
+ // Arrange
+ mockFetchReleases.mockResolvedValue([{ version: '1.0.0' }])
+ const props = createActionProps({
+ isShowFetchNewVersion: true,
+ isShowDelete: false,
+ isShowInfo: false,
+ author: 'fallback-author',
+ pluginName: 'fallback-plugin',
+ meta: {
+ repo: '/', // Results in empty parts after split
+ version: '1.0.0',
+ package: 'pkg.difypkg',
+ },
+ })
+
+ // Act
+ render()
+ fireEvent.click(getActionButtons()[0])
+
+ // Assert
+ await waitFor(() => {
+ expect(mockFetchReleases).toHaveBeenCalledWith('fallback-author', 'fallback-plugin')
+ })
+ })
+
+ it('should not proceed if no releases are fetched', async () => {
+ // Arrange
+ mockFetchReleases.mockResolvedValue([])
+ const props = createActionProps({
+ isShowFetchNewVersion: true,
+ isShowDelete: false,
+ isShowInfo: false,
+ })
+
+ // Act
+ render()
+ fireEvent.click(getActionButtons()[0])
+
+ // Assert
+ await waitFor(() => {
+ expect(mockFetchReleases).toHaveBeenCalled()
+ })
+ expect(mockCheckForUpdates).not.toHaveBeenCalled()
+ })
+
+ it('should show toast notification after checking for updates', async () => {
+ // Arrange
+ mockFetchReleases.mockResolvedValue([{ version: '2.0.0' }])
+ mockCheckForUpdates.mockReturnValue({
+ needUpdate: false,
+ toastProps: { type: 'success', message: 'Already up to date' },
+ })
+ const props = createActionProps({
+ isShowFetchNewVersion: true,
+ isShowDelete: false,
+ isShowInfo: false,
+ })
+
+ // Act
+ render()
+ fireEvent.click(getActionButtons()[0])
+
+ // Assert - Toast.notify is called with the toast props
+ await waitFor(() => {
+ expect(toastNotifySpy).toHaveBeenCalledWith({ type: 'success', message: 'Already up to date' })
+ })
+ })
+
+ it('should show update modal when update is available', async () => {
+ // Arrange
+ const releases = [{ version: '2.0.0' }]
+ mockFetchReleases.mockResolvedValue(releases)
+ mockCheckForUpdates.mockReturnValue({
+ needUpdate: true,
+ toastProps: { type: 'info', message: 'Update available' },
+ })
+ const props = createActionProps({
+ isShowFetchNewVersion: true,
+ isShowDelete: false,
+ isShowInfo: false,
+ pluginUniqueIdentifier: 'test-id',
+ category: 'model' as PluginCategoryEnum,
+ meta: {
+ repo: 'owner/repo',
+ version: '1.0.0',
+ package: 'pkg.difypkg',
+ },
+ })
+
+ // Act
+ render()
+ fireEvent.click(getActionButtons()[0])
+
+ // Assert
+ await waitFor(() => {
+ expect(mockSetShowUpdatePluginModal).toHaveBeenCalledWith(
+ expect.objectContaining({
+ payload: expect.objectContaining({
+ type: PluginSource.github,
+ category: 'model',
+ github: expect.objectContaining({
+ originalPackageInfo: expect.objectContaining({
+ id: 'test-id',
+ repo: 'owner/repo',
+ version: '1.0.0',
+ package: 'pkg.difypkg',
+ releases,
+ }),
+ }),
+ }),
+ }),
+ )
+ })
+ })
+
+ it('should call invalidateInstalledPluginList on save callback', async () => {
+ // Arrange
+ const releases = [{ version: '2.0.0' }]
+ mockFetchReleases.mockResolvedValue(releases)
+ mockCheckForUpdates.mockReturnValue({
+ needUpdate: true,
+ toastProps: { type: 'info', message: 'Update available' },
+ })
+ const props = createActionProps({
+ isShowFetchNewVersion: true,
+ isShowDelete: false,
+ isShowInfo: false,
+ })
+
+ // Act
+ render()
+ fireEvent.click(getActionButtons()[0])
+
+ // Wait for modal to be called
+ await waitFor(() => {
+ expect(mockSetShowUpdatePluginModal).toHaveBeenCalled()
+ })
+
+ // Invoke the callback
+ const call = mockSetShowUpdatePluginModal.mock.calls[0][0]
+ call.onSaveCallback()
+
+ // Assert
+ expect(mockInvalidateInstalledPluginList).toHaveBeenCalled()
+ })
+
+ it('should check updates with current version', async () => {
+ // Arrange
+ const releases = [{ version: '2.0.0' }, { version: '1.5.0' }]
+ mockFetchReleases.mockResolvedValue(releases)
+ const props = createActionProps({
+ isShowFetchNewVersion: true,
+ isShowDelete: false,
+ isShowInfo: false,
+ meta: {
+ repo: 'owner/repo',
+ version: '1.0.0',
+ package: 'pkg.difypkg',
+ },
+ })
+
+ // Act
+ render()
+ fireEvent.click(getActionButtons()[0])
+
+ // Assert
+ await waitFor(() => {
+ expect(mockCheckForUpdates).toHaveBeenCalledWith(releases, '1.0.0')
+ })
+ })
+ })
+
+ // ==================== Callback Stability Tests ====================
+ describe('Callback Stability (useCallback)', () => {
+ it('should have stable handleDelete callback with same dependencies', async () => {
+ // Arrange
+ mockUninstallPlugin.mockResolvedValue({ success: true })
+ const onDelete = vi.fn()
+ const props = createActionProps({
+ isShowDelete: true,
+ isShowInfo: false,
+ isShowFetchNewVersion: false,
+ onDelete,
+ installationId: 'stable-install-id',
+ })
+
+ // Act - First render and delete
+ const { rerender } = render()
+ fireEvent.click(getActionButtons()[0])
+ fireEvent.click(screen.getByTestId('confirm-ok'))
+
+ await waitFor(() => {
+ expect(mockUninstallPlugin).toHaveBeenCalledWith('stable-install-id')
+ })
+
+ // Re-render with same props
+ mockUninstallPlugin.mockClear()
+ rerender()
+ fireEvent.click(getActionButtons()[0])
+ fireEvent.click(screen.getByTestId('confirm-ok'))
+
+ await waitFor(() => {
+ expect(mockUninstallPlugin).toHaveBeenCalledWith('stable-install-id')
+ })
+ })
+
+ it('should update handleDelete when installationId changes', async () => {
+ // Arrange
+ mockUninstallPlugin.mockResolvedValue({ success: true })
+ const props1 = createActionProps({
+ isShowDelete: true,
+ isShowInfo: false,
+ isShowFetchNewVersion: false,
+ installationId: 'install-1',
+ })
+ const props2 = createActionProps({
+ isShowDelete: true,
+ isShowInfo: false,
+ isShowFetchNewVersion: false,
+ installationId: 'install-2',
+ })
+
+ // Act
+ const { rerender } = render()
+ fireEvent.click(getActionButtons()[0])
+ fireEvent.click(screen.getByTestId('confirm-ok'))
+
+ await waitFor(() => {
+ expect(mockUninstallPlugin).toHaveBeenCalledWith('install-1')
+ })
+
+ mockUninstallPlugin.mockClear()
+ rerender()
+ fireEvent.click(getActionButtons()[0])
+ fireEvent.click(screen.getByTestId('confirm-ok'))
+
+ await waitFor(() => {
+ expect(mockUninstallPlugin).toHaveBeenCalledWith('install-2')
+ })
+ })
+
+ it('should update handleDelete when onDelete changes', async () => {
+ // Arrange
+ mockUninstallPlugin.mockResolvedValue({ success: true })
+ const onDelete1 = vi.fn()
+ const onDelete2 = vi.fn()
+ const props1 = createActionProps({
+ isShowDelete: true,
+ isShowInfo: false,
+ isShowFetchNewVersion: false,
+ onDelete: onDelete1,
+ })
+ const props2 = createActionProps({
+ isShowDelete: true,
+ isShowInfo: false,
+ isShowFetchNewVersion: false,
+ onDelete: onDelete2,
+ })
+
+ // Act
+ const { rerender } = render()
+ fireEvent.click(getActionButtons()[0])
+ fireEvent.click(screen.getByTestId('confirm-ok'))
+
+ await waitFor(() => {
+ expect(onDelete1).toHaveBeenCalled()
+ })
+ expect(onDelete2).not.toHaveBeenCalled()
+
+ rerender()
+ fireEvent.click(getActionButtons()[0])
+ fireEvent.click(screen.getByTestId('confirm-ok'))
+
+ await waitFor(() => {
+ expect(onDelete2).toHaveBeenCalled()
+ })
+ })
+ })
+
+ // ==================== Edge Cases ====================
+ describe('Edge Cases', () => {
+ it('should handle undefined meta for info display', () => {
+ // Arrange - meta is required for info, but test defensive behavior
+ const props = createActionProps({
+ isShowInfo: false,
+ isShowDelete: true,
+ isShowFetchNewVersion: false,
+ meta: undefined,
+ })
+
+ // Act & Assert - Should not crash
+ expect(() => render()).not.toThrow()
+ })
+
+ it('should handle empty repo string', async () => {
+ // Arrange
+ mockFetchReleases.mockResolvedValue([{ version: '1.0.0' }])
+ const props = createActionProps({
+ isShowFetchNewVersion: true,
+ isShowDelete: false,
+ isShowInfo: false,
+ author: 'fallback-owner',
+ pluginName: 'fallback-repo',
+ meta: {
+ repo: '',
+ version: '1.0.0',
+ package: 'pkg.difypkg',
+ },
+ })
+
+ // Act
+ render()
+ fireEvent.click(getActionButtons()[0])
+
+ // Assert - Should use author and pluginName as fallback
+ await waitFor(() => {
+ expect(mockFetchReleases).toHaveBeenCalledWith('fallback-owner', 'fallback-repo')
+ })
+ })
+
+ it('should handle concurrent delete requests gracefully', async () => {
+ // Arrange
+ let resolveFirst: (value: { success: boolean }) => void
+ const firstPromise = new Promise<{ success: boolean }>((resolve) => {
+ resolveFirst = resolve
+ })
+ mockUninstallPlugin.mockReturnValueOnce(firstPromise)
+
+ const props = createActionProps({
+ isShowDelete: true,
+ isShowInfo: false,
+ isShowFetchNewVersion: false,
+ })
+
+ // Act
+ render()
+ fireEvent.click(getActionButtons()[0])
+ fireEvent.click(screen.getByTestId('confirm-ok'))
+
+ // The confirm button should be disabled during deletion
+ expect(screen.getByTestId('confirm-modal')).toHaveAttribute('data-loading', 'true')
+ expect(screen.getByTestId('confirm-modal')).toHaveAttribute('data-disabled', 'true')
+
+ // Resolve the deletion
+ resolveFirst!({ success: true })
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should handle special characters in plugin name', () => {
+ // Arrange
+ const props = createActionProps({
+ isShowDelete: true,
+ isShowInfo: false,
+ isShowFetchNewVersion: false,
+ pluginName: 'plugin-with-special@chars#123',
+ })
+
+ // Act
+ render()
+ fireEvent.click(getActionButtons()[0])
+
+ // Assert
+ expect(screen.getByText('plugin-with-special@chars#123')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== React.memo Tests ====================
+ describe('React.memo Behavior', () => {
+ it('should be wrapped with React.memo', () => {
+ // Assert
+ expect(Action).toBeDefined()
+ expect((Action as any).$$typeof?.toString()).toContain('Symbol')
+ })
+ })
+
+ // ==================== Prop Variations ====================
+ describe('Prop Variations', () => {
+ it('should handle all category types', () => {
+ // Arrange
+ const categories = ['tool', 'model', 'extension', 'agent-strategy', 'datasource'] as PluginCategoryEnum[]
+
+ categories.forEach((category) => {
+ const props = createActionProps({
+ category,
+ isShowDelete: true,
+ isShowInfo: false,
+ isShowFetchNewVersion: false,
+ })
+ expect(() => render()).not.toThrow()
+ })
+ })
+
+ it('should handle different usedInApps values', () => {
+ // Arrange
+ const values = [0, 1, 5, 100]
+
+ values.forEach((usedInApps) => {
+ const props = createActionProps({
+ usedInApps,
+ isShowDelete: true,
+ isShowInfo: false,
+ isShowFetchNewVersion: false,
+ })
+ expect(() => render()).not.toThrow()
+ })
+ })
+
+ it('should handle combination of multiple action buttons', () => {
+ // Arrange - Test various combinations
+ const combinations = [
+ { isShowFetchNewVersion: true, isShowInfo: false, isShowDelete: false },
+ { isShowFetchNewVersion: false, isShowInfo: true, isShowDelete: false },
+ { isShowFetchNewVersion: false, isShowInfo: false, isShowDelete: true },
+ { isShowFetchNewVersion: true, isShowInfo: true, isShowDelete: false },
+ { isShowFetchNewVersion: true, isShowInfo: false, isShowDelete: true },
+ { isShowFetchNewVersion: false, isShowInfo: true, isShowDelete: true },
+ { isShowFetchNewVersion: true, isShowInfo: true, isShowDelete: true },
+ ]
+
+ combinations.forEach((flags) => {
+ const props = createActionProps(flags)
+ const expectedCount = [flags.isShowFetchNewVersion, flags.isShowInfo, flags.isShowDelete].filter(Boolean).length
+
+ const { unmount } = render()
+ const buttons = queryActionButtons()
+ expect(buttons).toHaveLength(expectedCount)
+ unmount()
+ })
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-item/index.spec.tsx b/web/app/components/plugins/plugin-item/index.spec.tsx
new file mode 100644
index 0000000000..ae76e64c46
--- /dev/null
+++ b/web/app/components/plugins/plugin-item/index.spec.tsx
@@ -0,0 +1,1016 @@
+import type { PluginDeclaration, PluginDetail } from '../types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { PluginCategoryEnum, PluginSource } from '../types'
+
+// ==================== Imports (after mocks) ====================
+
+import PluginItem from './index'
+
+// ==================== Mock Setup ====================
+
+// Mock theme hook
+const mockTheme = vi.fn(() => 'light')
+vi.mock('@/hooks/use-theme', () => ({
+ default: () => ({ theme: mockTheme() }),
+}))
+
+// Mock i18n render hook
+const mockGetValueFromI18nObject = vi.fn((obj: Record) => obj?.en_US || '')
+vi.mock('@/hooks/use-i18n', () => ({
+ useRenderI18nObject: () => mockGetValueFromI18nObject,
+}))
+
+// Mock categories hook
+const mockCategoriesMap: Record = {
+ 'tool': { name: 'tool', label: 'Tools' },
+ 'model': { name: 'model', label: 'Models' },
+ 'extension': { name: 'extension', label: 'Extensions' },
+ 'agent-strategy': { name: 'agent-strategy', label: 'Agents' },
+ 'datasource': { name: 'datasource', label: 'Data Sources' },
+}
+vi.mock('../hooks', () => ({
+ useCategories: () => ({
+ categories: Object.values(mockCategoriesMap),
+ categoriesMap: mockCategoriesMap,
+ }),
+}))
+
+// Mock plugin page context
+const mockCurrentPluginID = vi.fn((): string | undefined => undefined)
+const mockSetCurrentPluginID = vi.fn()
+vi.mock('../plugin-page/context', () => ({
+ usePluginPageContext: (selector: (v: any) => any) => {
+ const context = {
+ currentPluginID: mockCurrentPluginID(),
+ setCurrentPluginID: mockSetCurrentPluginID,
+ }
+ return selector(context)
+ },
+}))
+
+// Mock refresh plugin list hook
+const mockRefreshPluginList = vi.fn()
+vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({
+ default: () => ({ refreshPluginList: mockRefreshPluginList }),
+}))
+
+// Mock app context
+const mockLangGeniusVersionInfo = vi.fn(() => ({
+ current_version: '1.0.0',
+}))
+vi.mock('@/context/app-context', () => ({
+ useAppContext: () => ({
+ langGeniusVersionInfo: mockLangGeniusVersionInfo(),
+ }),
+}))
+
+// Mock global public store
+const mockEnableMarketplace = vi.fn(() => true)
+vi.mock('@/context/global-public-context', () => ({
+ useGlobalPublicStore: (selector: (s: any) => any) =>
+ selector({ systemFeatures: { enable_marketplace: mockEnableMarketplace() } }),
+}))
+
+// Mock Action component
+vi.mock('./action', () => ({
+ default: ({ onDelete, pluginName }: { onDelete: () => void, pluginName: string }) => (
+
+
+
+ ),
+}))
+
+// Mock child components
+vi.mock('../card/base/corner-mark', () => ({
+ default: ({ text }: { text: string }) => {text}
,
+}))
+
+vi.mock('../card/base/title', () => ({
+ default: ({ title }: { title: string }) => {title}
,
+}))
+
+vi.mock('../card/base/description', () => ({
+ default: ({ text }: { text: string }) => {text}
,
+}))
+
+vi.mock('../card/base/org-info', () => ({
+ default: ({ orgName, packageName }: { orgName: string, packageName: string }) => (
+
+ {orgName}
+ /
+ {packageName}
+
+ ),
+}))
+
+vi.mock('../base/badges/verified', () => ({
+ default: ({ text }: { text: string }) => {text}
,
+}))
+
+vi.mock('../../base/badge', () => ({
+ default: ({ text, hasRedCornerMark }: { text: string, hasRedCornerMark?: boolean }) => (
+ {text}
+ ),
+}))
+
+// ==================== Test Utilities ====================
+
+const createPluginDeclaration = (overrides: Partial = {}): PluginDeclaration => ({
+ plugin_unique_identifier: 'test-plugin-id',
+ version: '1.0.0',
+ author: 'test-author',
+ icon: 'test-icon.png',
+ icon_dark: 'test-icon-dark.png',
+ name: 'test-plugin',
+ category: PluginCategoryEnum.tool,
+ label: { en_US: 'Test Plugin' } as any,
+ description: { en_US: 'Test plugin description' } as any,
+ created_at: '2024-01-01',
+ resource: null,
+ plugins: null,
+ verified: false,
+ endpoint: {} as any,
+ model: null,
+ tags: [],
+ agent_strategy: null,
+ meta: {
+ version: '1.0.0',
+ minimum_dify_version: '0.5.0',
+ },
+ trigger: {} as any,
+ ...overrides,
+})
+
+const createPluginDetail = (overrides: Partial = {}): PluginDetail => ({
+ id: 'plugin-1',
+ created_at: '2024-01-01',
+ updated_at: '2024-01-01',
+ name: 'test-plugin',
+ plugin_id: 'plugin-1',
+ plugin_unique_identifier: 'test-author/test-plugin@1.0.0',
+ declaration: createPluginDeclaration(),
+ installation_id: 'install-1',
+ tenant_id: 'tenant-1',
+ endpoints_setups: 0,
+ endpoints_active: 0,
+ version: '1.0.0',
+ latest_version: '1.0.0',
+ latest_unique_identifier: 'test-author/test-plugin@1.0.0',
+ source: PluginSource.marketplace,
+ meta: {
+ repo: 'test-author/test-plugin',
+ version: '1.0.0',
+ package: 'test-plugin.difypkg',
+ },
+ status: 'active',
+ deprecated_reason: '',
+ alternative_plugin_id: '',
+ ...overrides,
+})
+
+// ==================== Tests ====================
+
+describe('PluginItem', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTheme.mockReturnValue('light')
+ mockCurrentPluginID.mockReturnValue(undefined)
+ mockEnableMarketplace.mockReturnValue(true)
+ mockLangGeniusVersionInfo.mockReturnValue({ current_version: '1.0.0' })
+ mockGetValueFromI18nObject.mockImplementation((obj: Record) => obj?.en_US || '')
+ })
+
+ // ==================== Rendering Tests ====================
+ describe('Rendering', () => {
+ it('should render plugin item with basic info', () => {
+ // Arrange
+ const plugin = createPluginDetail()
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('plugin-title')).toBeInTheDocument()
+ expect(screen.getByTestId('plugin-description')).toBeInTheDocument()
+ expect(screen.getByTestId('corner-mark')).toBeInTheDocument()
+ expect(screen.getByTestId('version-badge')).toBeInTheDocument()
+ })
+
+ it('should render plugin icon', () => {
+ // Arrange
+ const plugin = createPluginDetail()
+
+ // Act
+ render()
+
+ // Assert
+ const img = screen.getByRole('img')
+ expect(img).toHaveAttribute('alt', `plugin-${plugin.plugin_unique_identifier}-logo`)
+ })
+
+ it('should render category label in corner mark', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ declaration: createPluginDeclaration({ category: PluginCategoryEnum.model }),
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('corner-mark')).toHaveTextContent('Models')
+ })
+
+ it('should apply custom className', () => {
+ // Arrange
+ const plugin = createPluginDetail()
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ const innerDiv = container.querySelector('.custom-class')
+ expect(innerDiv).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Plugin Sources Tests ====================
+ describe('Plugin Sources', () => {
+ it('should render GitHub source with repo link', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ source: PluginSource.github,
+ meta: { repo: 'owner/repo', version: '1.0.0', package: 'pkg.difypkg' },
+ })
+
+ // Act
+ render()
+
+ // Assert
+ const githubLink = screen.getByRole('link')
+ expect(githubLink).toHaveAttribute('href', 'https://github.com/owner/repo')
+ expect(screen.getByText('GitHub')).toBeInTheDocument()
+ })
+
+ it('should render marketplace source with link when enabled', () => {
+ // Arrange
+ mockEnableMarketplace.mockReturnValue(true)
+ const plugin = createPluginDetail({
+ source: PluginSource.marketplace,
+ declaration: createPluginDeclaration({ author: 'test-author', name: 'test-plugin' }),
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('marketplace')).toBeInTheDocument()
+ })
+
+ it('should render local source indicator', () => {
+ // Arrange
+ const plugin = createPluginDetail({ source: PluginSource.local })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Local Plugin')).toBeInTheDocument()
+ })
+
+ it('should render debugging source indicator', () => {
+ // Arrange
+ const plugin = createPluginDetail({ source: PluginSource.debugging })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Debugging Plugin')).toBeInTheDocument()
+ })
+
+ it('should show org info for GitHub source', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ source: PluginSource.github,
+ declaration: createPluginDeclaration({ author: 'github-author' }),
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('org-info')).toHaveAttribute('data-org', 'github-author')
+ })
+
+ it('should show org info for marketplace source', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ source: PluginSource.marketplace,
+ declaration: createPluginDeclaration({ author: 'marketplace-author' }),
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('org-info')).toHaveAttribute('data-org', 'marketplace-author')
+ })
+
+ it('should not show org info for local source', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ source: PluginSource.local,
+ declaration: createPluginDeclaration({ author: 'local-author' }),
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('org-info')).toHaveAttribute('data-org', '')
+ })
+ })
+
+ // ==================== Extension Category Tests ====================
+ describe('Extension Category', () => {
+ it('should show endpoints info for extension category', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ declaration: createPluginDeclaration({ category: PluginCategoryEnum.extension }),
+ endpoints_active: 3,
+ })
+
+ // Act
+ render()
+
+ // Assert - The translation includes interpolation
+ expect(screen.getByText(/plugin\.endpointsEnabled/)).toBeInTheDocument()
+ })
+
+ it('should not show endpoints info for non-extension category', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }),
+ endpoints_active: 3,
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.queryByText(/plugin\.endpointsEnabled/)).not.toBeInTheDocument()
+ })
+ })
+
+ // ==================== Version Compatibility Tests ====================
+ describe('Version Compatibility', () => {
+ it('should show warning icon when Dify version is not compatible', () => {
+ // Arrange
+ mockLangGeniusVersionInfo.mockReturnValue({ current_version: '0.3.0' })
+ const plugin = createPluginDetail({
+ declaration: createPluginDeclaration({
+ meta: { version: '1.0.0', minimum_dify_version: '0.5.0' },
+ }),
+ })
+
+ // Act
+ const { container } = render()
+
+ // Assert - Warning icon should be rendered
+ const warningIcon = container.querySelector('.text-text-accent')
+ expect(warningIcon).toBeInTheDocument()
+ })
+
+ it('should not show warning when Dify version is compatible', () => {
+ // Arrange
+ mockLangGeniusVersionInfo.mockReturnValue({ current_version: '1.0.0' })
+ const plugin = createPluginDetail({
+ declaration: createPluginDeclaration({
+ meta: { version: '1.0.0', minimum_dify_version: '0.5.0' },
+ }),
+ })
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ const warningIcon = container.querySelector('.text-text-accent')
+ expect(warningIcon).not.toBeInTheDocument()
+ })
+
+ it('should handle missing current_version gracefully', () => {
+ // Arrange
+ mockLangGeniusVersionInfo.mockReturnValue({ current_version: '' })
+ const plugin = createPluginDetail()
+
+ // Act
+ const { container } = render()
+
+ // Assert - Should not crash and not show warning
+ const warningIcon = container.querySelector('.text-text-accent')
+ expect(warningIcon).not.toBeInTheDocument()
+ })
+
+ it('should handle missing minimum_dify_version gracefully', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ declaration: createPluginDeclaration({
+ meta: { version: '1.0.0' },
+ }),
+ })
+
+ // Act
+ const { container } = render()
+
+ // Assert - Should not crash and not show warning
+ const warningIcon = container.querySelector('.text-text-accent')
+ expect(warningIcon).not.toBeInTheDocument()
+ })
+ })
+
+ // ==================== Deprecated Plugin Tests ====================
+ describe('Deprecated Plugin', () => {
+ it('should show deprecated indicator for deprecated marketplace plugin', () => {
+ // Arrange
+ mockEnableMarketplace.mockReturnValue(true)
+ const plugin = createPluginDetail({
+ source: PluginSource.marketplace,
+ status: 'deleted',
+ deprecated_reason: 'Plugin is no longer maintained',
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('plugin.deprecated')).toBeInTheDocument()
+ })
+
+ it('should show background effect for deprecated plugin', () => {
+ // Arrange
+ mockEnableMarketplace.mockReturnValue(true)
+ const plugin = createPluginDetail({
+ source: PluginSource.marketplace,
+ status: 'deleted',
+ deprecated_reason: 'Plugin is deprecated',
+ })
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ const bgEffect = container.querySelector('.blur-\\[120px\\]')
+ expect(bgEffect).toBeInTheDocument()
+ })
+
+ it('should not show deprecated indicator for active plugin', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ source: PluginSource.marketplace,
+ status: 'active',
+ deprecated_reason: '',
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.queryByText('plugin.deprecated')).not.toBeInTheDocument()
+ })
+
+ it('should not show deprecated indicator for non-marketplace source', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ source: PluginSource.github,
+ status: 'deleted',
+ deprecated_reason: 'Some reason',
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.queryByText('plugin.deprecated')).not.toBeInTheDocument()
+ })
+
+ it('should not show deprecated when marketplace is disabled', () => {
+ // Arrange
+ mockEnableMarketplace.mockReturnValue(false)
+ const plugin = createPluginDetail({
+ source: PluginSource.marketplace,
+ status: 'deleted',
+ deprecated_reason: 'Some reason',
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.queryByText('plugin.deprecated')).not.toBeInTheDocument()
+ })
+ })
+
+ // ==================== Verified Badge Tests ====================
+ describe('Verified Badge', () => {
+ it('should show verified badge for verified plugin', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ declaration: createPluginDeclaration({ verified: true }),
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('verified-badge')).toBeInTheDocument()
+ })
+
+ it('should not show verified badge for unverified plugin', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ declaration: createPluginDeclaration({ verified: false }),
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.queryByTestId('verified-badge')).not.toBeInTheDocument()
+ })
+ })
+
+ // ==================== Version Badge Tests ====================
+ describe('Version Badge', () => {
+ it('should show version from meta for GitHub source', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ source: PluginSource.github,
+ version: '2.0.0',
+ meta: { repo: 'owner/repo', version: '1.5.0', package: 'pkg' },
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('version-badge')).toHaveTextContent('1.5.0')
+ })
+
+ it('should show version from plugin for marketplace source', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ source: PluginSource.marketplace,
+ version: '2.0.0',
+ meta: { repo: 'owner/repo', version: '1.5.0', package: 'pkg' },
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('version-badge')).toHaveTextContent('2.0.0')
+ })
+
+ it('should show update indicator when new version available', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ source: PluginSource.marketplace,
+ version: '1.0.0',
+ latest_version: '2.0.0',
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('version-badge')).toHaveAttribute('data-has-update', 'true')
+ })
+
+ it('should not show update indicator when version is latest', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ source: PluginSource.marketplace,
+ version: '1.0.0',
+ latest_version: '1.0.0',
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('version-badge')).toHaveAttribute('data-has-update', 'false')
+ })
+
+ it('should not show update indicator for non-marketplace source', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ source: PluginSource.github,
+ version: '1.0.0',
+ latest_version: '2.0.0',
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('version-badge')).toHaveAttribute('data-has-update', 'false')
+ })
+ })
+
+ // ==================== User Interactions Tests ====================
+ describe('User Interactions', () => {
+ it('should call setCurrentPluginID when plugin is clicked', () => {
+ // Arrange
+ const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' })
+
+ // Act
+ const { container } = render()
+ const pluginContainer = container.firstChild as HTMLElement
+ fireEvent.click(pluginContainer)
+
+ // Assert
+ expect(mockSetCurrentPluginID).toHaveBeenCalledWith('test-plugin-id')
+ })
+
+ it('should highlight selected plugin', () => {
+ // Arrange
+ mockCurrentPluginID.mockReturnValue('test-plugin-id')
+ const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' })
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ const pluginContainer = container.firstChild as HTMLElement
+ expect(pluginContainer).toHaveClass('border-components-option-card-option-selected-border')
+ })
+
+ it('should not highlight unselected plugin', () => {
+ // Arrange
+ mockCurrentPluginID.mockReturnValue('other-plugin-id')
+ const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' })
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ const pluginContainer = container.firstChild as HTMLElement
+ expect(pluginContainer).not.toHaveClass('border-components-option-card-option-selected-border')
+ })
+
+ it('should stop propagation when action area is clicked', () => {
+ // Arrange
+ const plugin = createPluginDetail()
+
+ // Act
+ render()
+ const actionArea = screen.getByTestId('plugin-action').parentElement
+ fireEvent.click(actionArea!)
+
+ // Assert - setCurrentPluginID should not be called
+ expect(mockSetCurrentPluginID).not.toHaveBeenCalled()
+ })
+ })
+
+ // ==================== Delete Callback Tests ====================
+ describe('Delete Callback', () => {
+ it('should call refreshPluginList when delete is triggered', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }),
+ })
+
+ // Act
+ render()
+ fireEvent.click(screen.getByTestId('delete-button'))
+
+ // Assert
+ expect(mockRefreshPluginList).toHaveBeenCalledWith({ category: PluginCategoryEnum.tool })
+ })
+
+ it('should pass correct category to refreshPluginList', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ declaration: createPluginDeclaration({ category: PluginCategoryEnum.model }),
+ })
+
+ // Act
+ render()
+ fireEvent.click(screen.getByTestId('delete-button'))
+
+ // Assert
+ expect(mockRefreshPluginList).toHaveBeenCalledWith({ category: PluginCategoryEnum.model })
+ })
+ })
+
+ // ==================== Theme Tests ====================
+ describe('Theme Support', () => {
+ it('should use dark icon when theme is dark and dark icon exists', () => {
+ // Arrange
+ mockTheme.mockReturnValue('dark')
+ const plugin = createPluginDetail({
+ declaration: createPluginDeclaration({
+ icon: 'light-icon.png',
+ icon_dark: 'dark-icon.png',
+ }),
+ })
+
+ // Act
+ render()
+
+ // Assert
+ const img = screen.getByRole('img')
+ expect(img.getAttribute('src')).toContain('dark-icon.png')
+ })
+
+ it('should use light icon when theme is light', () => {
+ // Arrange
+ mockTheme.mockReturnValue('light')
+ const plugin = createPluginDetail({
+ declaration: createPluginDeclaration({
+ icon: 'light-icon.png',
+ icon_dark: 'dark-icon.png',
+ }),
+ })
+
+ // Act
+ render()
+
+ // Assert
+ const img = screen.getByRole('img')
+ expect(img.getAttribute('src')).toContain('light-icon.png')
+ })
+
+ it('should use light icon when dark icon is not available', () => {
+ // Arrange
+ mockTheme.mockReturnValue('dark')
+ const plugin = createPluginDetail({
+ declaration: createPluginDeclaration({
+ icon: 'light-icon.png',
+ icon_dark: undefined,
+ }),
+ })
+
+ // Act
+ render()
+
+ // Assert
+ const img = screen.getByRole('img')
+ expect(img.getAttribute('src')).toContain('light-icon.png')
+ })
+
+ it('should use external URL directly for icon', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ declaration: createPluginDeclaration({
+ icon: 'https://example.com/icon.png',
+ }),
+ })
+
+ // Act
+ render()
+
+ // Assert
+ const img = screen.getByRole('img')
+ expect(img).toHaveAttribute('src', 'https://example.com/icon.png')
+ })
+ })
+
+ // ==================== Memoization Tests ====================
+ describe('Memoization', () => {
+ it('should memoize orgName based on source and author', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ source: PluginSource.github,
+ declaration: createPluginDeclaration({ author: 'test-author' }),
+ })
+
+ // Act
+ const { rerender } = render()
+
+ // First render should show author
+ expect(screen.getByTestId('org-info')).toHaveAttribute('data-org', 'test-author')
+
+ // Re-render with same plugin
+ rerender()
+
+ // Should still show same author
+ expect(screen.getByTestId('org-info')).toHaveAttribute('data-org', 'test-author')
+ })
+
+ it('should update orgName when source changes', () => {
+ // Arrange
+ const githubPlugin = createPluginDetail({
+ source: PluginSource.github,
+ declaration: createPluginDeclaration({ author: 'github-author' }),
+ })
+ const localPlugin = createPluginDetail({
+ source: PluginSource.local,
+ declaration: createPluginDeclaration({ author: 'local-author' }),
+ })
+
+ // Act
+ const { rerender } = render()
+ expect(screen.getByTestId('org-info')).toHaveAttribute('data-org', 'github-author')
+
+ rerender()
+ expect(screen.getByTestId('org-info')).toHaveAttribute('data-org', '')
+ })
+
+ it('should memoize isDeprecated based on status and deprecated_reason', () => {
+ // Arrange
+ mockEnableMarketplace.mockReturnValue(true)
+ const activePlugin = createPluginDetail({
+ source: PluginSource.marketplace,
+ status: 'active',
+ deprecated_reason: '',
+ })
+ const deprecatedPlugin = createPluginDetail({
+ source: PluginSource.marketplace,
+ status: 'deleted',
+ deprecated_reason: 'Deprecated',
+ })
+
+ // Act
+ const { rerender } = render()
+ expect(screen.queryByText('plugin.deprecated')).not.toBeInTheDocument()
+
+ rerender()
+ expect(screen.getByText('plugin.deprecated')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Edge Cases ====================
+ describe('Edge Cases', () => {
+ it('should handle empty icon gracefully', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ declaration: createPluginDeclaration({ icon: '' }),
+ })
+
+ // Act & Assert - Should not throw when icon is empty
+ expect(() => render()).not.toThrow()
+
+ // The img element should still be rendered
+ const img = screen.getByRole('img')
+ expect(img).toBeInTheDocument()
+ })
+
+ it('should handle missing meta for non-GitHub source', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ source: PluginSource.local,
+ meta: undefined,
+ })
+
+ // Act & Assert - Should not throw
+ expect(() => render()).not.toThrow()
+ })
+
+ it('should handle empty label gracefully', () => {
+ // Arrange
+ mockGetValueFromI18nObject.mockReturnValue('')
+ const plugin = createPluginDetail()
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('plugin-title')).toHaveTextContent('')
+ })
+
+ it('should handle zero endpoints_active', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ declaration: createPluginDeclaration({ category: PluginCategoryEnum.extension }),
+ endpoints_active: 0,
+ })
+
+ // Act
+ render()
+
+ // Assert - Should still render endpoints info with zero
+ expect(screen.getByText(/plugin\.endpointsEnabled/)).toBeInTheDocument()
+ })
+
+ it('should handle null latest_version', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ source: PluginSource.marketplace,
+ version: '1.0.0',
+ latest_version: null as any,
+ })
+
+ // Act
+ render()
+
+ // Assert - Should not show update indicator
+ expect(screen.getByTestId('version-badge')).toHaveAttribute('data-has-update', 'false')
+ })
+ })
+
+ // ==================== Prop Variations ====================
+ describe('Prop Variations', () => {
+ it('should render correctly with minimal required props', () => {
+ // Arrange
+ const plugin = createPluginDetail()
+
+ // Act & Assert
+ expect(() => render()).not.toThrow()
+ })
+
+ it('should handle different category types', () => {
+ // Arrange
+ const categories = [
+ PluginCategoryEnum.tool,
+ PluginCategoryEnum.model,
+ PluginCategoryEnum.extension,
+ PluginCategoryEnum.agent,
+ PluginCategoryEnum.datasource,
+ ]
+
+ categories.forEach((category) => {
+ const plugin = createPluginDetail({
+ declaration: createPluginDeclaration({ category }),
+ })
+
+ // Act & Assert
+ expect(() => render()).not.toThrow()
+ })
+ })
+
+ it('should handle all source types', () => {
+ // Arrange
+ const sources = [
+ PluginSource.marketplace,
+ PluginSource.github,
+ PluginSource.local,
+ PluginSource.debugging,
+ ]
+
+ sources.forEach((source) => {
+ const plugin = createPluginDetail({ source })
+
+ // Act & Assert
+ expect(() => render()).not.toThrow()
+ })
+ })
+ })
+
+ // ==================== Callback Stability Tests ====================
+ describe('Callback Stability', () => {
+ it('should have stable handleDelete callback', () => {
+ // Arrange
+ const plugin = createPluginDetail({
+ declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }),
+ })
+
+ // Act
+ const { rerender } = render()
+ fireEvent.click(screen.getByTestId('delete-button'))
+ const firstCallArgs = mockRefreshPluginList.mock.calls[0]
+
+ mockRefreshPluginList.mockClear()
+ rerender()
+ fireEvent.click(screen.getByTestId('delete-button'))
+ const secondCallArgs = mockRefreshPluginList.mock.calls[0]
+
+ // Assert - Both calls should have same arguments
+ expect(firstCallArgs).toEqual(secondCallArgs)
+ })
+
+ it('should update handleDelete when category changes', () => {
+ // Arrange
+ const toolPlugin = createPluginDetail({
+ declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }),
+ })
+ const modelPlugin = createPluginDetail({
+ declaration: createPluginDeclaration({ category: PluginCategoryEnum.model }),
+ })
+
+ // Act
+ const { rerender } = render()
+ fireEvent.click(screen.getByTestId('delete-button'))
+ expect(mockRefreshPluginList).toHaveBeenCalledWith({ category: PluginCategoryEnum.tool })
+
+ mockRefreshPluginList.mockClear()
+ rerender()
+ fireEvent.click(screen.getByTestId('delete-button'))
+ expect(mockRefreshPluginList).toHaveBeenCalledWith({ category: PluginCategoryEnum.model })
+ })
+ })
+
+ // ==================== React.memo Tests ====================
+ describe('React.memo Behavior', () => {
+ it('should be wrapped with React.memo', () => {
+ // Arrange & Assert
+ // The component is exported as React.memo(PluginItem)
+ // We can verify by checking the displayName or type
+ expect(PluginItem).toBeDefined()
+ // React.memo components have a $$typeof property
+ expect((PluginItem as any).$$typeof?.toString()).toContain('Symbol')
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-page/empty/index.spec.tsx b/web/app/components/plugins/plugin-page/empty/index.spec.tsx
new file mode 100644
index 0000000000..51d4af919d
--- /dev/null
+++ b/web/app/components/plugins/plugin-page/empty/index.spec.tsx
@@ -0,0 +1,583 @@
+import type { FilterState } from '../filter-management'
+import type { SystemFeatures } from '@/types/feature'
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { defaultSystemFeatures, InstallationScope } from '@/types/feature'
+
+// ==================== Imports (after mocks) ====================
+
+import Empty from './index'
+
+// ==================== Mock Setup ====================
+
+// Use vi.hoisted to define ALL mock state and functions
+const {
+ mockSetActiveTab,
+ mockUseInstalledPluginList,
+ mockState,
+ stableT,
+} = vi.hoisted(() => {
+ const state = {
+ filters: {
+ categories: [] as string[],
+ tags: [] as string[],
+ searchQuery: '',
+ } as FilterState,
+ systemFeatures: {
+ enable_marketplace: true,
+ plugin_installation_permission: {
+ plugin_installation_scope: 'all' as const,
+ restrict_to_marketplace_only: false,
+ },
+ } as Partial,
+ pluginList: { plugins: [] as Array<{ id: string }> } as { plugins: Array<{ id: string }> } | undefined,
+ }
+ // Stable t function to prevent infinite re-renders
+ // The component's useEffect and useMemo depend on t
+ const t = (key: string) => key
+ return {
+ mockSetActiveTab: vi.fn(),
+ mockUseInstalledPluginList: vi.fn(() => ({ data: state.pluginList })),
+ mockState: state,
+ stableT: t,
+ }
+})
+
+// Mock plugin page context
+vi.mock('../context', () => ({
+ usePluginPageContext: (selector: (value: any) => any) => {
+ const contextValue = {
+ filters: mockState.filters,
+ setActiveTab: mockSetActiveTab,
+ }
+ return selector(contextValue)
+ },
+}))
+
+// Mock global public store (Zustand store)
+vi.mock('@/context/global-public-context', () => ({
+ useGlobalPublicStore: (selector: (state: any) => any) => {
+ return selector({
+ systemFeatures: {
+ ...defaultSystemFeatures,
+ ...mockState.systemFeatures,
+ },
+ })
+ },
+}))
+
+// Mock useInstalledPluginList hook
+vi.mock('@/service/use-plugins', () => ({
+ useInstalledPluginList: () => mockUseInstalledPluginList(),
+}))
+
+// Mock InstallFromGitHub component
+vi.mock('@/app/components/plugins/install-plugin/install-from-github', () => ({
+ default: ({ onClose }: { onSuccess: () => void, onClose: () => void }) => (
+
+
+
+
+ ),
+}))
+
+// Mock InstallFromLocalPackage component
+vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', () => ({
+ default: ({ file, onClose }: { file: File, onSuccess: () => void, onClose: () => void }) => (
+
+
+
+
+ ),
+}))
+
+// Mock Line component
+vi.mock('../../marketplace/empty/line', () => ({
+ default: ({ className }: { className?: string }) => ,
+}))
+
+// Override react-i18next with stable t function reference to prevent infinite re-renders
+// The component's useEffect and useMemo depend on t, so it MUST be stable
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: stableT,
+ i18n: {
+ language: 'en',
+ changeLanguage: vi.fn(),
+ },
+ }),
+}))
+
+// ==================== Test Utilities ====================
+
+const resetMockState = () => {
+ mockState.filters = { categories: [], tags: [], searchQuery: '' }
+ mockState.systemFeatures = {
+ enable_marketplace: true,
+ plugin_installation_permission: {
+ plugin_installation_scope: InstallationScope.ALL,
+ restrict_to_marketplace_only: false,
+ },
+ }
+ mockState.pluginList = { plugins: [] }
+ mockUseInstalledPluginList.mockReturnValue({ data: mockState.pluginList })
+}
+
+const setMockFilters = (filters: Partial) => {
+ mockState.filters = { ...mockState.filters, ...filters }
+}
+
+const setMockSystemFeatures = (features: Partial) => {
+ mockState.systemFeatures = { ...mockState.systemFeatures, ...features }
+}
+
+const setMockPluginList = (list: { plugins: Array<{ id: string }> } | undefined) => {
+ mockState.pluginList = list
+ mockUseInstalledPluginList.mockReturnValue({ data: list })
+}
+
+const createMockFile = (name: string, type = 'application/octet-stream'): File => {
+ return new File(['test'], name, { type })
+}
+
+// Helper to wait for useEffect to complete (single tick)
+const flushEffects = async () => {
+ await act(async () => {})
+}
+
+// ==================== Tests ====================
+
+describe('Empty Component', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetMockState()
+ })
+
+ // ==================== Rendering Tests ====================
+ describe('Rendering', () => {
+ it('should render basic structure correctly', async () => {
+ // Arrange & Act
+ const { container } = render()
+ await flushEffects()
+
+ // Assert - file input
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
+ expect(fileInput).toBeInTheDocument()
+ expect(fileInput.style.display).toBe('none')
+ expect(fileInput.accept).toBe('.difypkg,.difybndl')
+
+ // Assert - skeleton cards (20 in the grid + 1 icon container)
+ const skeletonCards = container.querySelectorAll('.rounded-xl.bg-components-card-bg')
+ expect(skeletonCards.length).toBeGreaterThanOrEqual(20)
+
+ // Assert - group icon container
+ const iconContainer = document.querySelector('.size-14')
+ expect(iconContainer).toBeInTheDocument()
+
+ // Assert - line components
+ const lines = screen.getAllByTestId('line-component')
+ expect(lines).toHaveLength(4)
+ })
+ })
+
+ // ==================== Text Display Tests (useMemo) ====================
+ describe('Text Display (useMemo)', () => {
+ it('should display "noInstalled" text when plugin list is empty', async () => {
+ // Arrange
+ setMockPluginList({ plugins: [] })
+
+ // Act
+ render()
+ await flushEffects()
+
+ // Assert
+ expect(screen.getByText('list.noInstalled')).toBeInTheDocument()
+ })
+
+ it('should display "notFound" text when filters are active with plugins', async () => {
+ // Arrange
+ setMockPluginList({ plugins: [{ id: 'plugin-1' }] })
+
+ // Test categories filter
+ setMockFilters({ categories: ['model'] })
+ const { rerender } = render()
+ await flushEffects()
+ expect(screen.getByText('list.notFound')).toBeInTheDocument()
+
+ // Test tags filter
+ setMockFilters({ categories: [], tags: ['tag1'] })
+ rerender()
+ await flushEffects()
+ expect(screen.getByText('list.notFound')).toBeInTheDocument()
+
+ // Test searchQuery filter
+ setMockFilters({ tags: [], searchQuery: 'test query' })
+ rerender()
+ await flushEffects()
+ expect(screen.getByText('list.notFound')).toBeInTheDocument()
+ })
+
+ it('should prioritize "noInstalled" over "notFound" when no plugins exist', async () => {
+ // Arrange
+ setMockFilters({ categories: ['model'], searchQuery: 'test' })
+ setMockPluginList({ plugins: [] })
+
+ // Act
+ render()
+ await flushEffects()
+
+ // Assert
+ expect(screen.getByText('list.noInstalled')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Install Methods Tests (useEffect) ====================
+ describe('Install Methods (useEffect)', () => {
+ it('should render all three install methods when marketplace enabled and not restricted', async () => {
+ // Arrange
+ setMockSystemFeatures({
+ enable_marketplace: true,
+ plugin_installation_permission: {
+ plugin_installation_scope: InstallationScope.ALL,
+ restrict_to_marketplace_only: false,
+ },
+ })
+
+ // Act
+ render()
+ await flushEffects()
+
+ // Assert
+ const buttons = screen.getAllByRole('button')
+ expect(buttons).toHaveLength(3)
+ expect(screen.getByText('source.marketplace')).toBeInTheDocument()
+ expect(screen.getByText('source.github')).toBeInTheDocument()
+ expect(screen.getByText('source.local')).toBeInTheDocument()
+
+ // Verify button order
+ const buttonTexts = buttons.map(btn => btn.textContent)
+ expect(buttonTexts[0]).toContain('source.marketplace')
+ expect(buttonTexts[1]).toContain('source.github')
+ expect(buttonTexts[2]).toContain('source.local')
+ })
+
+ it('should render only marketplace method when restricted to marketplace only', async () => {
+ // Arrange
+ setMockSystemFeatures({
+ enable_marketplace: true,
+ plugin_installation_permission: {
+ plugin_installation_scope: InstallationScope.ALL,
+ restrict_to_marketplace_only: true,
+ },
+ })
+
+ // Act
+ render()
+ await flushEffects()
+
+ // Assert
+ const buttons = screen.getAllByRole('button')
+ expect(buttons).toHaveLength(1)
+ expect(screen.getByText('source.marketplace')).toBeInTheDocument()
+ expect(screen.queryByText('source.github')).not.toBeInTheDocument()
+ expect(screen.queryByText('source.local')).not.toBeInTheDocument()
+ })
+
+ it('should render github and local methods when marketplace is disabled', async () => {
+ // Arrange
+ setMockSystemFeatures({
+ enable_marketplace: false,
+ plugin_installation_permission: {
+ plugin_installation_scope: InstallationScope.ALL,
+ restrict_to_marketplace_only: false,
+ },
+ })
+
+ // Act
+ render()
+ await flushEffects()
+
+ // Assert
+ const buttons = screen.getAllByRole('button')
+ expect(buttons).toHaveLength(2)
+ expect(screen.queryByText('source.marketplace')).not.toBeInTheDocument()
+ expect(screen.getByText('source.github')).toBeInTheDocument()
+ expect(screen.getByText('source.local')).toBeInTheDocument()
+ })
+
+ it('should render no methods when marketplace disabled and restricted', async () => {
+ // Arrange
+ setMockSystemFeatures({
+ enable_marketplace: false,
+ plugin_installation_permission: {
+ plugin_installation_scope: InstallationScope.ALL,
+ restrict_to_marketplace_only: true,
+ },
+ })
+
+ // Act
+ render()
+ await flushEffects()
+
+ // Assert
+ const buttons = screen.queryAllByRole('button')
+ expect(buttons).toHaveLength(0)
+ })
+ })
+
+ // ==================== User Interactions Tests ====================
+ describe('User Interactions', () => {
+ it('should call setActiveTab with "discover" when marketplace button is clicked', async () => {
+ // Arrange
+ render()
+ await flushEffects()
+
+ // Act
+ fireEvent.click(screen.getByText('source.marketplace'))
+
+ // Assert
+ expect(mockSetActiveTab).toHaveBeenCalledWith('discover')
+ })
+
+ it('should open and close GitHub modal correctly', async () => {
+ // Arrange
+ render()
+ await flushEffects()
+
+ // Assert - initially no modal
+ expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument()
+
+ // Act - open modal
+ fireEvent.click(screen.getByText('source.github'))
+
+ // Assert - modal is open
+ expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument()
+
+ // Act - close modal
+ fireEvent.click(screen.getByTestId('github-modal-close'))
+
+ // Assert - modal is closed
+ expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument()
+ })
+
+ it('should trigger file input click when local button is clicked', async () => {
+ // Arrange
+ render()
+ await flushEffects()
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
+ const clickSpy = vi.spyOn(fileInput, 'click')
+
+ // Act
+ fireEvent.click(screen.getByText('source.local'))
+
+ // Assert
+ expect(clickSpy).toHaveBeenCalled()
+ })
+
+ it('should open and close local modal when file is selected', async () => {
+ // Arrange
+ render()
+ await flushEffects()
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
+ const mockFile = createMockFile('test-plugin.difypkg')
+
+ // Assert - initially no modal
+ expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
+
+ // Act - select file
+ Object.defineProperty(fileInput, 'files', { value: [mockFile], writable: true })
+ fireEvent.change(fileInput)
+
+ // Assert - modal is open with correct file
+ expect(screen.getByTestId('install-from-local-modal')).toBeInTheDocument()
+ expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'test-plugin.difypkg')
+
+ // Act - close modal
+ fireEvent.click(screen.getByTestId('local-modal-close'))
+
+ // Assert - modal is closed
+ expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
+ })
+
+ it('should not open local modal when no file is selected', async () => {
+ // Arrange
+ render()
+ await flushEffects()
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
+
+ // Act - trigger change with empty files
+ Object.defineProperty(fileInput, 'files', { value: [], writable: true })
+ fireEvent.change(fileInput)
+
+ // Assert
+ expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
+ })
+ })
+
+ // ==================== State Management Tests ====================
+ describe('State Management', () => {
+ it('should maintain modal state correctly and allow reopening', async () => {
+ // Arrange
+ render()
+ await flushEffects()
+
+ // Act - Open, close, and reopen GitHub modal
+ fireEvent.click(screen.getByText('source.github'))
+ expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('github-modal-close'))
+ expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('source.github'))
+ expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument()
+ })
+
+ it('should update selectedFile state when file is selected', async () => {
+ // Arrange
+ render()
+ await flushEffects()
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
+
+ // Act - select .difypkg file
+ Object.defineProperty(fileInput, 'files', { value: [createMockFile('my-plugin.difypkg')], writable: true })
+ fireEvent.change(fileInput)
+ expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'my-plugin.difypkg')
+
+ // Close and select .difybndl file
+ fireEvent.click(screen.getByTestId('local-modal-close'))
+ Object.defineProperty(fileInput, 'files', { value: [createMockFile('test-bundle.difybndl')], writable: true })
+ fireEvent.change(fileInput)
+ expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'test-bundle.difybndl')
+ })
+ })
+
+ // ==================== Side Effects Tests ====================
+ describe('Side Effects', () => {
+ it('should render correct install methods based on system features', async () => {
+ // Test 1: All methods when marketplace enabled and not restricted
+ setMockSystemFeatures({
+ enable_marketplace: true,
+ plugin_installation_permission: {
+ plugin_installation_scope: InstallationScope.ALL,
+ restrict_to_marketplace_only: false,
+ },
+ })
+
+ const { unmount: unmount1 } = render()
+ await flushEffects()
+ expect(screen.getAllByRole('button')).toHaveLength(3)
+ unmount1()
+
+ // Test 2: Only marketplace when restricted
+ setMockSystemFeatures({
+ enable_marketplace: true,
+ plugin_installation_permission: {
+ plugin_installation_scope: InstallationScope.ALL,
+ restrict_to_marketplace_only: true,
+ },
+ })
+
+ render()
+ await flushEffects()
+ expect(screen.getAllByRole('button')).toHaveLength(1)
+ expect(screen.getByText('source.marketplace')).toBeInTheDocument()
+ })
+
+ it('should render correct text based on plugin list and filters', async () => {
+ // Test 1: noInstalled when plugin list is empty
+ setMockPluginList({ plugins: [] })
+ setMockFilters({ categories: [], tags: [], searchQuery: '' })
+
+ const { unmount: unmount1 } = render()
+ await flushEffects()
+ expect(screen.getByText('list.noInstalled')).toBeInTheDocument()
+ unmount1()
+
+ // Test 2: notFound when filters are active with plugins
+ setMockFilters({ categories: ['tool'] })
+ setMockPluginList({ plugins: [{ id: 'plugin-1' }] })
+
+ render()
+ await flushEffects()
+ expect(screen.getByText('list.notFound')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Edge Cases ====================
+ describe('Edge Cases', () => {
+ it('should handle undefined plugin data gracefully', () => {
+ // Test undefined plugin list - component should render without error
+ setMockPluginList(undefined)
+ expect(() => render()).not.toThrow()
+ })
+
+ it('should handle file input edge cases', async () => {
+ // Arrange
+ render()
+ await flushEffects()
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
+
+ // Test undefined files
+ Object.defineProperty(fileInput, 'files', { value: undefined, writable: true })
+ fireEvent.change(fileInput)
+ expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
+ })
+ })
+
+ // ==================== React.memo Tests ====================
+ describe('React.memo Behavior', () => {
+ it('should be wrapped with React.memo and have displayName', () => {
+ // Assert
+ expect(Empty).toBeDefined()
+ expect((Empty as any).$$typeof?.toString()).toContain('Symbol')
+ expect((Empty as any).displayName || (Empty as any).type?.displayName).toBeDefined()
+ })
+ })
+
+ // ==================== Modal Callbacks Tests ====================
+ describe('Modal Callbacks', () => {
+ it('should handle modal onSuccess callbacks (noop)', async () => {
+ // Arrange
+ render()
+ await flushEffects()
+
+ // Test GitHub modal onSuccess
+ fireEvent.click(screen.getByText('source.github'))
+ fireEvent.click(screen.getByTestId('github-modal-success'))
+ expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument()
+
+ // Close GitHub modal and test Local modal onSuccess
+ fireEvent.click(screen.getByTestId('github-modal-close'))
+
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
+ Object.defineProperty(fileInput, 'files', { value: [createMockFile('test-plugin.difypkg')], writable: true })
+ fireEvent.change(fileInput)
+
+ fireEvent.click(screen.getByTestId('local-modal-success'))
+ expect(screen.getByTestId('install-from-local-modal')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Conditional Modal Rendering ====================
+ describe('Conditional Modal Rendering', () => {
+ it('should only render one modal at a time and require file for local modal', async () => {
+ // Arrange
+ render()
+ await flushEffects()
+
+ // Assert - no modals initially
+ expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
+
+ // Open GitHub modal - only GitHub modal visible
+ fireEvent.click(screen.getByText('source.github'))
+ expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument()
+ expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
+
+ // Click local button - triggers file input, no modal yet (no file selected)
+ fireEvent.click(screen.getByText('source.local'))
+ // GitHub modal should still be visible, local modal requires file selection
+ expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-page/filter-management/index.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/index.spec.tsx
new file mode 100644
index 0000000000..58474b4723
--- /dev/null
+++ b/web/app/components/plugins/plugin-page/filter-management/index.spec.tsx
@@ -0,0 +1,1175 @@
+import type { Category, Tag } from './constant'
+import type { FilterState } from './index'
+import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+// ==================== Imports (after mocks) ====================
+
+import CategoriesFilter from './category-filter'
+// Import real components
+import FilterManagement from './index'
+import SearchBox from './search-box'
+import { useStore } from './store'
+import TagFilter from './tag-filter'
+
+// ==================== Mock Setup ====================
+
+// Mock initial filters from context
+let mockInitFilters: FilterState = {
+ categories: [],
+ tags: [],
+ searchQuery: '',
+}
+
+vi.mock('../context', () => ({
+ usePluginPageContext: (selector: (v: { filters: FilterState }) => FilterState) =>
+ selector({ filters: mockInitFilters }),
+}))
+
+// Mock categories data
+const mockCategories = [
+ { name: 'model', label: 'Models' },
+ { name: 'tool', label: 'Tools' },
+ { name: 'extension', label: 'Extensions' },
+ { name: 'agent', label: 'Agents' },
+]
+
+const mockCategoriesMap: Record = {
+ model: { name: 'model', label: 'Models' },
+ tool: { name: 'tool', label: 'Tools' },
+ extension: { name: 'extension', label: 'Extensions' },
+ agent: { name: 'agent', label: 'Agents' },
+}
+
+// Mock tags data
+const mockTags = [
+ { name: 'agent', label: 'Agent' },
+ { name: 'rag', label: 'RAG' },
+ { name: 'search', label: 'Search' },
+ { name: 'image', label: 'Image' },
+]
+
+const mockTagsMap: Record = {
+ agent: { name: 'agent', label: 'Agent' },
+ rag: { name: 'rag', label: 'RAG' },
+ search: { name: 'search', label: 'Search' },
+ image: { name: 'image', label: 'Image' },
+}
+
+vi.mock('../../hooks', () => ({
+ useCategories: () => ({
+ categories: mockCategories,
+ categoriesMap: mockCategoriesMap,
+ }),
+ useTags: () => ({
+ tags: mockTags,
+ tagsMap: mockTagsMap,
+ getTagLabel: (name: string) => mockTagsMap[name]?.label || name,
+ }),
+}))
+
+// Track portal open state for testing
+let mockPortalOpenState = false
+
+vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
+ PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => {
+ mockPortalOpenState = open
+ return {children}
+ },
+ PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
+ {children}
+ ),
+ PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => {
+ if (!mockPortalOpenState)
+ return null
+ return {children}
+ },
+}))
+
+// ==================== Test Utilities ====================
+
+const createFilterState = (overrides: Partial = {}): FilterState => ({
+ categories: [],
+ tags: [],
+ searchQuery: '',
+ ...overrides,
+})
+
+const renderFilterManagement = (onFilterChange = vi.fn()) => {
+ const result = render()
+ return { ...result, onFilterChange }
+}
+
+// ==================== constant.ts Tests ====================
+describe('constant.ts - Type Definitions', () => {
+ it('should define Tag type correctly', () => {
+ // Arrange
+ const tag: Tag = {
+ id: 'test-id',
+ name: 'test-tag',
+ type: 'custom',
+ binding_count: 5,
+ }
+
+ // Assert
+ expect(tag.id).toBe('test-id')
+ expect(tag.name).toBe('test-tag')
+ expect(tag.type).toBe('custom')
+ expect(tag.binding_count).toBe(5)
+ })
+
+ it('should define Category type correctly', () => {
+ // Arrange
+ const category: Category = {
+ name: 'model',
+ binding_count: 10,
+ }
+
+ // Assert
+ expect(category.name).toBe('model')
+ expect(category.binding_count).toBe(10)
+ })
+
+ it('should enforce Category name as specific union type', () => {
+ // Arrange - Valid category names
+ const validNames: Array = ['model', 'tool', 'extension', 'bundle']
+
+ // Assert
+ validNames.forEach((name) => {
+ const category: Category = { name, binding_count: 0 }
+ expect(['model', 'tool', 'extension', 'bundle']).toContain(category.name)
+ })
+ })
+})
+
+// ==================== store.ts Tests ====================
+describe('store.ts - Zustand Store', () => {
+ beforeEach(() => {
+ // Reset store to initial state
+ const { setState } = useStore
+ setState({
+ tagList: [],
+ categoryList: [],
+ showTagManagementModal: false,
+ showCategoryManagementModal: false,
+ })
+ })
+
+ describe('Initial State', () => {
+ it('should have empty tagList initially', () => {
+ const { result } = renderHook(() => useStore(state => state.tagList))
+ expect(result.current).toEqual([])
+ })
+
+ it('should have empty categoryList initially', () => {
+ const { result } = renderHook(() => useStore(state => state.categoryList))
+ expect(result.current).toEqual([])
+ })
+
+ it('should have showTagManagementModal false initially', () => {
+ const { result } = renderHook(() => useStore(state => state.showTagManagementModal))
+ expect(result.current).toBe(false)
+ })
+
+ it('should have showCategoryManagementModal false initially', () => {
+ const { result } = renderHook(() => useStore(state => state.showCategoryManagementModal))
+ expect(result.current).toBe(false)
+ })
+ })
+
+ describe('setTagList', () => {
+ it('should update tagList', () => {
+ // Arrange
+ const mockTagList: Tag[] = [
+ { id: '1', name: 'tag1', type: 'custom', binding_count: 1 },
+ { id: '2', name: 'tag2', type: 'custom', binding_count: 2 },
+ ]
+
+ // Act
+ const { result } = renderHook(() => useStore())
+ act(() => {
+ result.current.setTagList(mockTagList)
+ })
+
+ // Assert
+ expect(result.current.tagList).toEqual(mockTagList)
+ })
+
+ it('should handle undefined tagList', () => {
+ // Arrange & Act
+ const { result } = renderHook(() => useStore())
+ act(() => {
+ result.current.setTagList(undefined)
+ })
+
+ // Assert
+ expect(result.current.tagList).toBeUndefined()
+ })
+
+ it('should handle empty tagList', () => {
+ // Arrange
+ const { result } = renderHook(() => useStore())
+
+ // First set some tags
+ act(() => {
+ result.current.setTagList([{ id: '1', name: 'tag1', type: 'custom', binding_count: 1 }])
+ })
+
+ // Act - Clear the list
+ act(() => {
+ result.current.setTagList([])
+ })
+
+ // Assert
+ expect(result.current.tagList).toEqual([])
+ })
+ })
+
+ describe('setCategoryList', () => {
+ it('should update categoryList', () => {
+ // Arrange
+ const mockCategoryList: Category[] = [
+ { name: 'model', binding_count: 5 },
+ { name: 'tool', binding_count: 10 },
+ ]
+
+ // Act
+ const { result } = renderHook(() => useStore())
+ act(() => {
+ result.current.setCategoryList(mockCategoryList)
+ })
+
+ // Assert
+ expect(result.current.categoryList).toEqual(mockCategoryList)
+ })
+
+ it('should handle undefined categoryList', () => {
+ // Arrange & Act
+ const { result } = renderHook(() => useStore())
+ act(() => {
+ result.current.setCategoryList(undefined)
+ })
+
+ // Assert
+ expect(result.current.categoryList).toBeUndefined()
+ })
+ })
+
+ describe('setShowTagManagementModal', () => {
+ it('should set showTagManagementModal to true', () => {
+ // Arrange & Act
+ const { result } = renderHook(() => useStore())
+ act(() => {
+ result.current.setShowTagManagementModal(true)
+ })
+
+ // Assert
+ expect(result.current.showTagManagementModal).toBe(true)
+ })
+
+ it('should set showTagManagementModal to false', () => {
+ // Arrange
+ const { result } = renderHook(() => useStore())
+ act(() => {
+ result.current.setShowTagManagementModal(true)
+ })
+
+ // Act
+ act(() => {
+ result.current.setShowTagManagementModal(false)
+ })
+
+ // Assert
+ expect(result.current.showTagManagementModal).toBe(false)
+ })
+ })
+
+ describe('setShowCategoryManagementModal', () => {
+ it('should set showCategoryManagementModal to true', () => {
+ // Arrange & Act
+ const { result } = renderHook(() => useStore())
+ act(() => {
+ result.current.setShowCategoryManagementModal(true)
+ })
+
+ // Assert
+ expect(result.current.showCategoryManagementModal).toBe(true)
+ })
+
+ it('should set showCategoryManagementModal to false', () => {
+ // Arrange
+ const { result } = renderHook(() => useStore())
+ act(() => {
+ result.current.setShowCategoryManagementModal(true)
+ })
+
+ // Act
+ act(() => {
+ result.current.setShowCategoryManagementModal(false)
+ })
+
+ // Assert
+ expect(result.current.showCategoryManagementModal).toBe(false)
+ })
+ })
+
+ describe('Store Isolation', () => {
+ it('should maintain separate state for each property', () => {
+ // Arrange
+ const mockTagList: Tag[] = [{ id: '1', name: 'tag1', type: 'custom', binding_count: 1 }]
+ const mockCategoryList: Category[] = [{ name: 'model', binding_count: 5 }]
+
+ // Act
+ const { result } = renderHook(() => useStore())
+ act(() => {
+ result.current.setTagList(mockTagList)
+ result.current.setCategoryList(mockCategoryList)
+ result.current.setShowTagManagementModal(true)
+ result.current.setShowCategoryManagementModal(false)
+ })
+
+ // Assert - All states are independent
+ expect(result.current.tagList).toEqual(mockTagList)
+ expect(result.current.categoryList).toEqual(mockCategoryList)
+ expect(result.current.showTagManagementModal).toBe(true)
+ expect(result.current.showCategoryManagementModal).toBe(false)
+ })
+ })
+})
+
+// ==================== search-box.tsx Tests ====================
+describe('SearchBox Component', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render input with correct placeholder', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByPlaceholderText('plugin.search')).toBeInTheDocument()
+ })
+
+ it('should render with provided searchQuery value', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByDisplayValue('test query')).toBeInTheDocument()
+ })
+
+ it('should render search icon', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - Input should have showLeftIcon which renders search icon
+ const wrapper = container.querySelector('.w-\\[200px\\]')
+ expect(wrapper).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onChange when input value changes', () => {
+ // Arrange
+ const handleChange = vi.fn()
+ render()
+
+ // Act
+ fireEvent.change(screen.getByPlaceholderText('plugin.search'), {
+ target: { value: 'new search' },
+ })
+
+ // Assert
+ expect(handleChange).toHaveBeenCalledWith('new search')
+ })
+
+ it('should call onChange with empty string when cleared', () => {
+ // Arrange
+ const handleChange = vi.fn()
+ render()
+
+ // Act
+ fireEvent.change(screen.getByDisplayValue('existing'), {
+ target: { value: '' },
+ })
+
+ // Assert
+ expect(handleChange).toHaveBeenCalledWith('')
+ })
+
+ it('should handle rapid typing', () => {
+ // Arrange
+ const handleChange = vi.fn()
+ render()
+ const input = screen.getByPlaceholderText('plugin.search')
+
+ // Act
+ fireEvent.change(input, { target: { value: 'a' } })
+ fireEvent.change(input, { target: { value: 'ab' } })
+ fireEvent.change(input, { target: { value: 'abc' } })
+
+ // Assert
+ expect(handleChange).toHaveBeenCalledTimes(3)
+ expect(handleChange).toHaveBeenLastCalledWith('abc')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle special characters', () => {
+ // Arrange
+ const handleChange = vi.fn()
+ render()
+
+ // Act
+ fireEvent.change(screen.getByPlaceholderText('plugin.search'), {
+ target: { value: '!@#$%^&*()' },
+ })
+
+ // Assert
+ expect(handleChange).toHaveBeenCalledWith('!@#$%^&*()')
+ })
+
+ it('should handle unicode characters', () => {
+ // Arrange
+ const handleChange = vi.fn()
+ render()
+
+ // Act
+ fireEvent.change(screen.getByPlaceholderText('plugin.search'), {
+ target: { value: 'δΈζζη΄’ π' },
+ })
+
+ // Assert
+ expect(handleChange).toHaveBeenCalledWith('δΈζζη΄’ π')
+ })
+
+ it('should handle very long input', () => {
+ // Arrange
+ const handleChange = vi.fn()
+ const longText = 'a'.repeat(500)
+ render()
+
+ // Act
+ fireEvent.change(screen.getByPlaceholderText('plugin.search'), {
+ target: { value: longText },
+ })
+
+ // Assert
+ expect(handleChange).toHaveBeenCalledWith(longText)
+ })
+ })
+})
+
+// ==================== category-filter.tsx Tests ====================
+describe('CategoriesFilter Component', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockPortalOpenState = false
+ })
+
+ describe('Rendering', () => {
+ it('should render with "All Categories" text when no selection', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('plugin.allCategories')).toBeInTheDocument()
+ })
+
+ it('should render dropdown arrow when no selection', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - Arrow icon should be visible
+ const arrowIcon = container.querySelector('svg')
+ expect(arrowIcon).toBeInTheDocument()
+ })
+
+ it('should render selected category labels', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Models')).toBeInTheDocument()
+ })
+
+ it('should show clear button when categories are selected', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - Close icon should be visible
+ const closeIcon = container.querySelector('[class*="cursor-pointer"]')
+ expect(closeIcon).toBeInTheDocument()
+ })
+
+ it('should show count badge for more than 2 selections', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('+1')).toBeInTheDocument()
+ })
+ })
+
+ describe('Dropdown Behavior', () => {
+ it('should open dropdown on trigger click', async () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('portal-trigger'))
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByTestId('portal-content')).toBeInTheDocument()
+ })
+ })
+
+ it('should display category options in dropdown', async () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('portal-trigger'))
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText('Models')).toBeInTheDocument()
+ expect(screen.getByText('Tools')).toBeInTheDocument()
+ expect(screen.getByText('Extensions')).toBeInTheDocument()
+ expect(screen.getByText('Agents')).toBeInTheDocument()
+ })
+ })
+
+ it('should have search input in dropdown', async () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('portal-trigger'))
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('plugin.searchCategories')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Selection Behavior', () => {
+ it('should call onChange when category is selected', async () => {
+ // Arrange
+ const handleChange = vi.fn()
+ render()
+
+ // Act - Open dropdown and click category
+ fireEvent.click(screen.getByTestId('portal-trigger'))
+ await waitFor(() => {
+ expect(screen.getByText('Models')).toBeInTheDocument()
+ })
+ fireEvent.click(screen.getByText('Models'))
+
+ // Assert
+ expect(handleChange).toHaveBeenCalledWith(['model'])
+ })
+
+ it('should deselect when clicking selected category', async () => {
+ // Arrange
+ const handleChange = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('portal-trigger'))
+ await waitFor(() => {
+ // Multiple "Models" texts exist - one in trigger, one in dropdown
+ const allModels = screen.getAllByText('Models')
+ expect(allModels.length).toBeGreaterThan(1)
+ })
+ // Click the one in the dropdown (inside portal-content)
+ const portalContent = screen.getByTestId('portal-content')
+ const modelsInDropdown = portalContent.querySelector('.system-sm-medium')!
+ fireEvent.click(modelsInDropdown.parentElement!)
+
+ // Assert
+ expect(handleChange).toHaveBeenCalledWith([])
+ })
+
+ it('should add to selection when clicking unselected category', async () => {
+ // Arrange
+ const handleChange = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('portal-trigger'))
+ await waitFor(() => {
+ expect(screen.getByText('Tools')).toBeInTheDocument()
+ })
+ fireEvent.click(screen.getByText('Tools'))
+
+ // Assert
+ expect(handleChange).toHaveBeenCalledWith(['model', 'tool'])
+ })
+
+ it('should clear all selections when clear button is clicked', () => {
+ // Arrange
+ const handleChange = vi.fn()
+ const { container } = render()
+
+ // Act - Find and click the close icon
+ const closeIcon = container.querySelector('.text-text-quaternary')
+ expect(closeIcon).toBeInTheDocument()
+ fireEvent.click(closeIcon!)
+
+ // Assert
+ expect(handleChange).toHaveBeenCalledWith([])
+ })
+ })
+
+ describe('Search Functionality', () => {
+ it('should filter categories based on search text', async () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('portal-trigger'))
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('plugin.searchCategories')).toBeInTheDocument()
+ })
+ fireEvent.change(screen.getByPlaceholderText('plugin.searchCategories'), {
+ target: { value: 'mod' },
+ })
+
+ // Assert
+ expect(screen.getByText('Models')).toBeInTheDocument()
+ expect(screen.queryByText('Extensions')).not.toBeInTheDocument()
+ })
+
+ it('should be case insensitive', async () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('portal-trigger'))
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('plugin.searchCategories')).toBeInTheDocument()
+ })
+ fireEvent.change(screen.getByPlaceholderText('plugin.searchCategories'), {
+ target: { value: 'MOD' },
+ })
+
+ // Assert
+ expect(screen.getByText('Models')).toBeInTheDocument()
+ })
+ })
+
+ describe('Checkbox State', () => {
+ it('should show checked checkbox for selected categories', async () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('portal-trigger'))
+
+ // Assert - Check icon appears for checked state
+ await waitFor(() => {
+ const checkIcons = screen.getAllByTestId(/check-icon/)
+ expect(checkIcons.length).toBeGreaterThan(0)
+ })
+ })
+
+ it('should show unchecked checkbox for unselected categories', async () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('portal-trigger'))
+
+ // Assert - No check icon for unchecked state
+ await waitFor(() => {
+ const checkIcons = screen.queryAllByTestId(/check-icon/)
+ expect(checkIcons.length).toBe(0)
+ })
+ })
+ })
+})
+
+// ==================== tag-filter.tsx Tests ====================
+describe('TagFilter Component', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockPortalOpenState = false
+ })
+
+ describe('Rendering', () => {
+ it('should render with "All Tags" text when no selection', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('pluginTags.allTags')).toBeInTheDocument()
+ })
+
+ it('should render selected tag labels', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Agent')).toBeInTheDocument()
+ })
+
+ it('should show count badge for more than 2 selections', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('+1')).toBeInTheDocument()
+ })
+
+ it('should show clear button when tags are selected', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const closeIcon = container.querySelector('.text-text-quaternary')
+ expect(closeIcon).toBeInTheDocument()
+ })
+ })
+
+ describe('Dropdown Behavior', () => {
+ it('should open dropdown on trigger click', async () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('portal-trigger'))
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByTestId('portal-content')).toBeInTheDocument()
+ })
+ })
+
+ it('should display tag options in dropdown', async () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('portal-trigger'))
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText('Agent')).toBeInTheDocument()
+ expect(screen.getByText('RAG')).toBeInTheDocument()
+ expect(screen.getByText('Search')).toBeInTheDocument()
+ expect(screen.getByText('Image')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Selection Behavior', () => {
+ it('should call onChange when tag is selected', async () => {
+ // Arrange
+ const handleChange = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('portal-trigger'))
+ await waitFor(() => {
+ expect(screen.getByText('Agent')).toBeInTheDocument()
+ })
+ fireEvent.click(screen.getByText('Agent'))
+
+ // Assert
+ expect(handleChange).toHaveBeenCalledWith(['agent'])
+ })
+
+ it('should deselect when clicking selected tag', async () => {
+ // Arrange
+ const handleChange = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('portal-trigger'))
+ await waitFor(() => {
+ // Find the Agent option in dropdown
+ const agentOptions = screen.getAllByText('Agent')
+ fireEvent.click(agentOptions[agentOptions.length - 1])
+ })
+
+ // Assert
+ expect(handleChange).toHaveBeenCalledWith([])
+ })
+
+ it('should add to selection when clicking unselected tag', async () => {
+ // Arrange
+ const handleChange = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('portal-trigger'))
+ await waitFor(() => {
+ expect(screen.getByText('RAG')).toBeInTheDocument()
+ })
+ fireEvent.click(screen.getByText('RAG'))
+
+ // Assert
+ expect(handleChange).toHaveBeenCalledWith(['agent', 'rag'])
+ })
+
+ it('should clear all selections when clear button is clicked', () => {
+ // Arrange
+ const handleChange = vi.fn()
+ const { container } = render()
+
+ // Act
+ const closeIcon = container.querySelector('.text-text-quaternary')
+ fireEvent.click(closeIcon!)
+
+ // Assert
+ expect(handleChange).toHaveBeenCalledWith([])
+ })
+ })
+
+ describe('Search Functionality', () => {
+ it('should filter tags based on search text', async () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('portal-trigger'))
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('pluginTags.searchTags')).toBeInTheDocument()
+ })
+ fireEvent.change(screen.getByPlaceholderText('pluginTags.searchTags'), {
+ target: { value: 'rag' },
+ })
+
+ // Assert
+ expect(screen.getByText('RAG')).toBeInTheDocument()
+ expect(screen.queryByText('Image')).not.toBeInTheDocument()
+ })
+ })
+})
+
+// ==================== index.tsx (FilterManagement) Tests ====================
+describe('FilterManagement Component', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockInitFilters = createFilterState()
+ mockPortalOpenState = false
+ })
+
+ describe('Rendering', () => {
+ it('should render all filter components', () => {
+ // Arrange & Act
+ renderFilterManagement()
+
+ // Assert - All three filters should be present
+ expect(screen.getByText('plugin.allCategories')).toBeInTheDocument()
+ expect(screen.getByText('pluginTags.allTags')).toBeInTheDocument()
+ expect(screen.getByPlaceholderText('plugin.search')).toBeInTheDocument()
+ })
+
+ it('should render with correct container classes', () => {
+ // Arrange & Act
+ const { container } = renderFilterManagement()
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('flex', 'items-center', 'gap-2', 'self-stretch')
+ })
+ })
+
+ describe('Initial State from Context', () => {
+ it('should initialize with empty filters', () => {
+ // Arrange
+ mockInitFilters = createFilterState()
+
+ // Act
+ renderFilterManagement()
+
+ // Assert
+ expect(screen.getByText('plugin.allCategories')).toBeInTheDocument()
+ expect(screen.getByText('pluginTags.allTags')).toBeInTheDocument()
+ expect(screen.getByPlaceholderText('plugin.search')).toHaveValue('')
+ })
+
+ it('should initialize with pre-selected categories', () => {
+ // Arrange
+ mockInitFilters = createFilterState({ categories: ['model'] })
+
+ // Act
+ renderFilterManagement()
+
+ // Assert
+ expect(screen.getByText('Models')).toBeInTheDocument()
+ })
+
+ it('should initialize with pre-selected tags', () => {
+ // Arrange
+ mockInitFilters = createFilterState({ tags: ['agent'] })
+
+ // Act
+ renderFilterManagement()
+
+ // Assert
+ expect(screen.getByText('Agent')).toBeInTheDocument()
+ })
+
+ it('should initialize with search query', () => {
+ // Arrange
+ mockInitFilters = createFilterState({ searchQuery: 'initial search' })
+
+ // Act
+ renderFilterManagement()
+
+ // Assert
+ expect(screen.getByDisplayValue('initial search')).toBeInTheDocument()
+ })
+ })
+
+ describe('Filter Interactions', () => {
+ it('should call onFilterChange when category is selected', async () => {
+ // Arrange
+ const onFilterChange = vi.fn()
+ render()
+
+ // Act - Open categories dropdown and select
+ const triggers = screen.getAllByTestId('portal-trigger')
+ fireEvent.click(triggers[0]) // Categories filter trigger
+
+ await waitFor(() => {
+ expect(screen.getByText('Models')).toBeInTheDocument()
+ })
+ fireEvent.click(screen.getByText('Models'))
+
+ // Assert
+ expect(onFilterChange).toHaveBeenCalledWith({
+ categories: ['model'],
+ tags: [],
+ searchQuery: '',
+ })
+ })
+
+ it('should call onFilterChange when tag is selected', async () => {
+ // Arrange
+ const onFilterChange = vi.fn()
+ render()
+
+ // Act - Open tags dropdown and select
+ const triggers = screen.getAllByTestId('portal-trigger')
+ fireEvent.click(triggers[1]) // Tags filter trigger
+
+ await waitFor(() => {
+ expect(screen.getByText('Agent')).toBeInTheDocument()
+ })
+ fireEvent.click(screen.getByText('Agent'))
+
+ // Assert
+ expect(onFilterChange).toHaveBeenCalledWith({
+ categories: [],
+ tags: ['agent'],
+ searchQuery: '',
+ })
+ })
+
+ it('should call onFilterChange when search query changes', () => {
+ // Arrange
+ const onFilterChange = vi.fn()
+ render()
+
+ // Act
+ fireEvent.change(screen.getByPlaceholderText('plugin.search'), {
+ target: { value: 'test query' },
+ })
+
+ // Assert
+ expect(onFilterChange).toHaveBeenCalledWith({
+ categories: [],
+ tags: [],
+ searchQuery: 'test query',
+ })
+ })
+ })
+
+ describe('State Management', () => {
+ it('should accumulate filter changes', async () => {
+ // Arrange
+ const onFilterChange = vi.fn()
+ render()
+
+ // Act 1 - Select a category
+ const triggers = screen.getAllByTestId('portal-trigger')
+ fireEvent.click(triggers[0])
+ await waitFor(() => {
+ expect(screen.getByText('Models')).toBeInTheDocument()
+ })
+ fireEvent.click(screen.getByText('Models'))
+
+ expect(onFilterChange).toHaveBeenLastCalledWith({
+ categories: ['model'],
+ tags: [],
+ searchQuery: '',
+ })
+
+ // Close dropdown by clicking trigger again
+ fireEvent.click(triggers[0])
+
+ // Act 2 - Select a tag (state should include previous category)
+ fireEvent.click(triggers[1])
+ await waitFor(() => {
+ expect(screen.getByText('Agent')).toBeInTheDocument()
+ })
+ fireEvent.click(screen.getByText('Agent'))
+
+ // Assert - Both category and tag should be in the state
+ expect(onFilterChange).toHaveBeenLastCalledWith({
+ categories: ['model'],
+ tags: ['agent'],
+ searchQuery: '',
+ })
+ })
+
+ it('should preserve other filters when updating one', () => {
+ // Arrange
+ mockInitFilters = createFilterState({
+ categories: ['model'],
+ tags: ['agent'],
+ })
+ const onFilterChange = vi.fn()
+ render()
+
+ // Act - Change only search query
+ fireEvent.change(screen.getByPlaceholderText('plugin.search'), {
+ target: { value: 'new search' },
+ })
+
+ // Assert - Other filters should be preserved
+ expect(onFilterChange).toHaveBeenCalledWith({
+ categories: ['model'],
+ tags: ['agent'],
+ searchQuery: 'new search',
+ })
+ })
+ })
+
+ describe('Integration Tests', () => {
+ it('should handle complete filter workflow', async () => {
+ // Arrange
+ const onFilterChange = vi.fn()
+ render()
+
+ // Act 1 - Select categories
+ const triggers = screen.getAllByTestId('portal-trigger')
+ fireEvent.click(triggers[0])
+ await waitFor(() => {
+ expect(screen.getByText('Models')).toBeInTheDocument()
+ })
+ fireEvent.click(screen.getByText('Models'))
+ fireEvent.click(triggers[0]) // Close
+
+ // Act 2 - Select tags
+ fireEvent.click(triggers[1])
+ await waitFor(() => {
+ expect(screen.getByText('RAG')).toBeInTheDocument()
+ })
+ fireEvent.click(screen.getByText('RAG'))
+ fireEvent.click(triggers[1]) // Close
+
+ // Act 3 - Enter search
+ fireEvent.change(screen.getByPlaceholderText('plugin.search'), {
+ target: { value: 'gpt' },
+ })
+
+ // Assert - Final state should include all filters
+ expect(onFilterChange).toHaveBeenLastCalledWith({
+ categories: ['model'],
+ tags: ['rag'],
+ searchQuery: 'gpt',
+ })
+ })
+
+ it('should handle filter clearing', async () => {
+ // Arrange
+ mockInitFilters = createFilterState({
+ categories: ['model'],
+ tags: ['agent'],
+ searchQuery: 'test',
+ })
+ const onFilterChange = vi.fn()
+ const { container } = render()
+
+ // Act - Clear search
+ fireEvent.change(screen.getByDisplayValue('test'), {
+ target: { value: '' },
+ })
+
+ // Assert
+ expect(onFilterChange).toHaveBeenLastCalledWith({
+ categories: ['model'],
+ tags: ['agent'],
+ searchQuery: '',
+ })
+
+ // Act - Clear categories (click clear button)
+ const closeIcons = container.querySelectorAll('.text-text-quaternary')
+ fireEvent.click(closeIcons[0]) // First close icon is for categories
+
+ // Assert
+ expect(onFilterChange).toHaveBeenLastCalledWith({
+ categories: [],
+ tags: ['agent'],
+ searchQuery: '',
+ })
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty initial state', () => {
+ // Arrange
+ mockInitFilters = createFilterState()
+ const onFilterChange = vi.fn()
+
+ // Act
+ render()
+
+ // Assert - Should render without errors
+ expect(screen.getByText('plugin.allCategories')).toBeInTheDocument()
+ })
+
+ it('should handle multiple rapid filter changes', () => {
+ // Arrange
+ const onFilterChange = vi.fn()
+ render()
+
+ // Act - Rapid search input changes
+ const searchInput = screen.getByPlaceholderText('plugin.search')
+ fireEvent.change(searchInput, { target: { value: 'a' } })
+ fireEvent.change(searchInput, { target: { value: 'ab' } })
+ fireEvent.change(searchInput, { target: { value: 'abc' } })
+
+ // Assert
+ expect(onFilterChange).toHaveBeenCalledTimes(3)
+ expect(onFilterChange).toHaveBeenLastCalledWith(
+ expect.objectContaining({ searchQuery: 'abc' }),
+ )
+ })
+
+ it('should handle special characters in search', () => {
+ // Arrange
+ const onFilterChange = vi.fn()
+ render()
+
+ // Act
+ fireEvent.change(screen.getByPlaceholderText('plugin.search'), {
+ target: { value: '!@#$%^&*()' },
+ })
+
+ // Assert
+ expect(onFilterChange).toHaveBeenCalledWith(
+ expect.objectContaining({ searchQuery: '!@#$%^&*()' }),
+ )
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-page/list/index.spec.tsx b/web/app/components/plugins/plugin-page/list/index.spec.tsx
new file mode 100644
index 0000000000..7709585e8e
--- /dev/null
+++ b/web/app/components/plugins/plugin-page/list/index.spec.tsx
@@ -0,0 +1,702 @@
+import type { PluginDeclaration, PluginDetail } from '../../types'
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { PluginCategoryEnum, PluginSource } from '../../types'
+
+// ==================== Imports (after mocks) ====================
+
+import PluginList from './index'
+
+// ==================== Mock Setup ====================
+
+// Mock PluginItem component to avoid complex dependency chain
+vi.mock('../../plugin-item', () => ({
+ default: ({ plugin }: { plugin: PluginDetail }) => (
+
+ {plugin.name}
+
+ ),
+}))
+
+// ==================== Test Utilities ====================
+
+/**
+ * Factory function to create a PluginDeclaration with defaults
+ */
+const createPluginDeclaration = (overrides: Partial = {}): PluginDeclaration => ({
+ plugin_unique_identifier: 'test-plugin-id',
+ version: '1.0.0',
+ author: 'test-author',
+ icon: 'test-icon.png',
+ icon_dark: 'test-icon-dark.png',
+ name: 'test-plugin',
+ category: PluginCategoryEnum.tool,
+ label: { en_US: 'Test Plugin' } as any,
+ description: { en_US: 'Test plugin description' } as any,
+ created_at: '2024-01-01',
+ resource: null,
+ plugins: null,
+ verified: false,
+ endpoint: {} as any,
+ model: null,
+ tags: [],
+ agent_strategy: null,
+ meta: {
+ version: '1.0.0',
+ minimum_dify_version: '0.5.0',
+ },
+ trigger: {} as any,
+ ...overrides,
+})
+
+/**
+ * Factory function to create a PluginDetail with defaults
+ */
+const createPluginDetail = (overrides: Partial = {}): PluginDetail => ({
+ id: 'plugin-1',
+ created_at: '2024-01-01',
+ updated_at: '2024-01-01',
+ name: 'test-plugin',
+ plugin_id: 'plugin-1',
+ plugin_unique_identifier: 'test-author/test-plugin@1.0.0',
+ declaration: createPluginDeclaration(),
+ installation_id: 'install-1',
+ tenant_id: 'tenant-1',
+ endpoints_setups: 0,
+ endpoints_active: 0,
+ version: '1.0.0',
+ latest_version: '1.0.0',
+ latest_unique_identifier: 'test-author/test-plugin@1.0.0',
+ source: PluginSource.marketplace,
+ meta: {
+ repo: 'test-author/test-plugin',
+ version: '1.0.0',
+ package: 'test-plugin.difypkg',
+ },
+ status: 'active',
+ deprecated_reason: '',
+ alternative_plugin_id: '',
+ ...overrides,
+})
+
+/**
+ * Factory function to create a list of plugins
+ */
+const createPluginList = (count: number, baseOverrides: Partial = {}): PluginDetail[] => {
+ return Array.from({ length: count }, (_, index) => createPluginDetail({
+ id: `plugin-${index + 1}`,
+ plugin_id: `plugin-${index + 1}`,
+ name: `plugin-${index + 1}`,
+ plugin_unique_identifier: `test-author/plugin-${index + 1}@1.0.0`,
+ ...baseOverrides,
+ }))
+}
+
+// ==================== Tests ====================
+
+describe('PluginList', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // ==================== Rendering Tests ====================
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange
+ const pluginList: PluginDetail[] = []
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ expect(container).toBeInTheDocument()
+ })
+
+ it('should render container with correct structure', () => {
+ // Arrange
+ const pluginList: PluginDetail[] = []
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ const outerDiv = container.firstChild as HTMLElement
+ expect(outerDiv).toHaveClass('pb-3')
+
+ const gridDiv = outerDiv.firstChild as HTMLElement
+ expect(gridDiv).toHaveClass('grid', 'grid-cols-2', 'gap-3')
+ })
+
+ it('should render single plugin correctly', () => {
+ // Arrange
+ const pluginList = [createPluginDetail({ name: 'single-plugin' })]
+
+ // Act
+ render()
+
+ // Assert
+ const pluginItems = screen.getAllByTestId('plugin-item')
+ expect(pluginItems).toHaveLength(1)
+ expect(pluginItems[0]).toHaveAttribute('data-plugin-name', 'single-plugin')
+ })
+
+ it('should render multiple plugins correctly', () => {
+ // Arrange
+ const pluginList = createPluginList(5)
+
+ // Act
+ render()
+
+ // Assert
+ const pluginItems = screen.getAllByTestId('plugin-item')
+ expect(pluginItems).toHaveLength(5)
+ })
+
+ it('should render plugins in correct order', () => {
+ // Arrange
+ const pluginList = [
+ createPluginDetail({ plugin_id: 'first', name: 'First Plugin' }),
+ createPluginDetail({ plugin_id: 'second', name: 'Second Plugin' }),
+ createPluginDetail({ plugin_id: 'third', name: 'Third Plugin' }),
+ ]
+
+ // Act
+ render()
+
+ // Assert
+ const pluginItems = screen.getAllByTestId('plugin-item')
+ expect(pluginItems[0]).toHaveAttribute('data-plugin-id', 'first')
+ expect(pluginItems[1]).toHaveAttribute('data-plugin-id', 'second')
+ expect(pluginItems[2]).toHaveAttribute('data-plugin-id', 'third')
+ })
+
+ it('should pass plugin prop to each PluginItem', () => {
+ // Arrange
+ const pluginList = [
+ createPluginDetail({ plugin_id: 'plugin-a', name: 'Plugin A' }),
+ createPluginDetail({ plugin_id: 'plugin-b', name: 'Plugin B' }),
+ ]
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Plugin A')).toBeInTheDocument()
+ expect(screen.getByText('Plugin B')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Props Testing ====================
+ describe('Props', () => {
+ it('should accept empty pluginList array', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const gridDiv = container.querySelector('.grid')
+ expect(gridDiv).toBeEmptyDOMElement()
+ })
+
+ it('should handle pluginList with various categories', () => {
+ // Arrange
+ const pluginList = [
+ createPluginDetail({
+ plugin_id: 'tool-plugin',
+ declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }),
+ }),
+ createPluginDetail({
+ plugin_id: 'model-plugin',
+ declaration: createPluginDeclaration({ category: PluginCategoryEnum.model }),
+ }),
+ createPluginDetail({
+ plugin_id: 'extension-plugin',
+ declaration: createPluginDeclaration({ category: PluginCategoryEnum.extension }),
+ }),
+ ]
+
+ // Act
+ render()
+
+ // Assert
+ const pluginItems = screen.getAllByTestId('plugin-item')
+ expect(pluginItems).toHaveLength(3)
+ })
+
+ it('should handle pluginList with various sources', () => {
+ // Arrange
+ const pluginList = [
+ createPluginDetail({ plugin_id: 'marketplace-plugin', source: PluginSource.marketplace }),
+ createPluginDetail({ plugin_id: 'github-plugin', source: PluginSource.github }),
+ createPluginDetail({ plugin_id: 'local-plugin', source: PluginSource.local }),
+ createPluginDetail({ plugin_id: 'debugging-plugin', source: PluginSource.debugging }),
+ ]
+
+ // Act
+ render()
+
+ // Assert
+ const pluginItems = screen.getAllByTestId('plugin-item')
+ expect(pluginItems).toHaveLength(4)
+ })
+ })
+
+ // ==================== Edge Cases ====================
+ describe('Edge Cases', () => {
+ it('should handle empty array', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.queryByTestId('plugin-item')).not.toBeInTheDocument()
+ })
+
+ it('should handle large number of plugins', () => {
+ // Arrange
+ const pluginList = createPluginList(100)
+
+ // Act
+ render()
+
+ // Assert
+ const pluginItems = screen.getAllByTestId('plugin-item')
+ expect(pluginItems).toHaveLength(100)
+ })
+
+ it('should handle plugins with duplicate plugin_ids (key warning scenario)', () => {
+ // Arrange - Testing that the component uses plugin_id as key
+ const pluginList = [
+ createPluginDetail({ plugin_id: 'unique-1', name: 'Plugin 1' }),
+ createPluginDetail({ plugin_id: 'unique-2', name: 'Plugin 2' }),
+ ]
+
+ // Act & Assert - Should render without issues
+ expect(() => render()).not.toThrow()
+ expect(screen.getAllByTestId('plugin-item')).toHaveLength(2)
+ })
+
+ it('should handle plugins with special characters in names', () => {
+ // Arrange
+ const pluginList = [
+ createPluginDetail({ plugin_id: 'special-1', name: 'Plugin "special" & chars' }),
+ createPluginDetail({ plugin_id: 'special-2', name: 'ζ₯ζ¬θͺγγ©γ°γ€γ³' }),
+ createPluginDetail({ plugin_id: 'special-3', name: 'Emoji Plugin π' }),
+ ]
+
+ // Act
+ render()
+
+ // Assert
+ const pluginItems = screen.getAllByTestId('plugin-item')
+ expect(pluginItems).toHaveLength(3)
+ })
+
+ it('should handle plugins with very long names', () => {
+ // Arrange
+ const longName = 'A'.repeat(500)
+ const pluginList = [createPluginDetail({ name: longName })]
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('plugin-item')).toBeInTheDocument()
+ })
+
+ it('should handle plugin with minimal data', () => {
+ // Arrange
+ const minimalPlugin = createPluginDetail({
+ name: '',
+ plugin_id: 'minimal',
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('plugin-item')).toBeInTheDocument()
+ })
+
+ it('should handle plugins with undefined optional fields', () => {
+ // Arrange
+ const pluginList = [
+ createPluginDetail({
+ plugin_id: 'no-meta',
+ meta: undefined,
+ }),
+ ]
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('plugin-item')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Grid Layout Tests ====================
+ describe('Grid Layout', () => {
+ it('should render with 2-column grid', () => {
+ // Arrange
+ const pluginList = createPluginList(4)
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ const gridDiv = container.querySelector('.grid')
+ expect(gridDiv).toHaveClass('grid-cols-2')
+ })
+
+ it('should have proper gap between items', () => {
+ // Arrange
+ const pluginList = createPluginList(4)
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ const gridDiv = container.querySelector('.grid')
+ expect(gridDiv).toHaveClass('gap-3')
+ })
+
+ it('should have bottom padding on container', () => {
+ // Arrange
+ const pluginList = createPluginList(2)
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ const outerDiv = container.firstChild as HTMLElement
+ expect(outerDiv).toHaveClass('pb-3')
+ })
+ })
+
+ // ==================== Re-render Tests ====================
+ describe('Re-render Behavior', () => {
+ it('should update when pluginList changes', () => {
+ // Arrange
+ const initialList = createPluginList(2)
+ const updatedList = createPluginList(4)
+
+ // Act
+ const { rerender } = render()
+ expect(screen.getAllByTestId('plugin-item')).toHaveLength(2)
+
+ rerender()
+
+ // Assert
+ expect(screen.getAllByTestId('plugin-item')).toHaveLength(4)
+ })
+
+ it('should handle pluginList update from non-empty to empty', () => {
+ // Arrange
+ const initialList = createPluginList(3)
+ const emptyList: PluginDetail[] = []
+
+ // Act
+ const { rerender } = render()
+ expect(screen.getAllByTestId('plugin-item')).toHaveLength(3)
+
+ rerender()
+
+ // Assert
+ expect(screen.queryByTestId('plugin-item')).not.toBeInTheDocument()
+ })
+
+ it('should handle pluginList update from empty to non-empty', () => {
+ // Arrange
+ const emptyList: PluginDetail[] = []
+ const filledList = createPluginList(3)
+
+ // Act
+ const { rerender } = render()
+ expect(screen.queryByTestId('plugin-item')).not.toBeInTheDocument()
+
+ rerender()
+
+ // Assert
+ expect(screen.getAllByTestId('plugin-item')).toHaveLength(3)
+ })
+
+ it('should update individual plugin data on re-render', () => {
+ // Arrange
+ const initialList = [createPluginDetail({ plugin_id: 'plugin-1', name: 'Original Name' })]
+ const updatedList = [createPluginDetail({ plugin_id: 'plugin-1', name: 'Updated Name' })]
+
+ // Act
+ const { rerender } = render()
+ expect(screen.getByText('Original Name')).toBeInTheDocument()
+
+ rerender()
+
+ // Assert
+ expect(screen.getByText('Updated Name')).toBeInTheDocument()
+ expect(screen.queryByText('Original Name')).not.toBeInTheDocument()
+ })
+ })
+
+ // ==================== Key Prop Tests ====================
+ describe('Key Prop Behavior', () => {
+ it('should use plugin_id as key for efficient re-renders', () => {
+ // Arrange - Create plugins with unique plugin_ids
+ const pluginList = [
+ createPluginDetail({ plugin_id: 'stable-key-1', name: 'Plugin 1' }),
+ createPluginDetail({ plugin_id: 'stable-key-2', name: 'Plugin 2' }),
+ createPluginDetail({ plugin_id: 'stable-key-3', name: 'Plugin 3' }),
+ ]
+
+ // Act
+ const { rerender } = render()
+
+ // Reorder the list
+ const reorderedList = [pluginList[2], pluginList[0], pluginList[1]]
+ rerender()
+
+ // Assert - All items should still be present
+ const items = screen.getAllByTestId('plugin-item')
+ expect(items).toHaveLength(3)
+ expect(items[0]).toHaveAttribute('data-plugin-id', 'stable-key-3')
+ expect(items[1]).toHaveAttribute('data-plugin-id', 'stable-key-1')
+ expect(items[2]).toHaveAttribute('data-plugin-id', 'stable-key-2')
+ })
+ })
+
+ // ==================== Plugin Status Variations ====================
+ describe('Plugin Status Variations', () => {
+ it('should render active plugins', () => {
+ // Arrange
+ const pluginList = [createPluginDetail({ status: 'active' })]
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('plugin-item')).toBeInTheDocument()
+ })
+
+ it('should render deleted/deprecated plugins', () => {
+ // Arrange
+ const pluginList = [
+ createPluginDetail({
+ status: 'deleted',
+ deprecated_reason: 'No longer maintained',
+ }),
+ ]
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('plugin-item')).toBeInTheDocument()
+ })
+
+ it('should render mixed status plugins', () => {
+ // Arrange
+ const pluginList = [
+ createPluginDetail({ plugin_id: 'active-plugin', status: 'active' }),
+ createPluginDetail({
+ plugin_id: 'deprecated-plugin',
+ status: 'deleted',
+ deprecated_reason: 'Deprecated',
+ }),
+ ]
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getAllByTestId('plugin-item')).toHaveLength(2)
+ })
+ })
+
+ // ==================== Version Variations ====================
+ describe('Version Variations', () => {
+ it('should render plugins with same version as latest', () => {
+ // Arrange
+ const pluginList = [
+ createPluginDetail({
+ version: '1.0.0',
+ latest_version: '1.0.0',
+ }),
+ ]
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('plugin-item')).toBeInTheDocument()
+ })
+
+ it('should render plugins with outdated version', () => {
+ // Arrange
+ const pluginList = [
+ createPluginDetail({
+ version: '1.0.0',
+ latest_version: '2.0.0',
+ }),
+ ]
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('plugin-item')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Accessibility ====================
+ describe('Accessibility', () => {
+ it('should render as a semantic container', () => {
+ // Arrange
+ const pluginList = createPluginList(2)
+
+ // Act
+ const { container } = render()
+
+ // Assert - The list is rendered as divs which is appropriate for a grid layout
+ const outerDiv = container.firstChild as HTMLElement
+ expect(outerDiv.tagName).toBe('DIV')
+ })
+ })
+
+ // ==================== Component Type ====================
+ describe('Component Type', () => {
+ it('should be a functional component', () => {
+ // Assert
+ expect(typeof PluginList).toBe('function')
+ })
+
+ it('should accept pluginList as required prop', () => {
+ // Arrange & Act - TypeScript ensures this at compile time
+ // but we verify runtime behavior
+ const pluginList = createPluginList(1)
+
+ // Assert
+ expect(() => render()).not.toThrow()
+ })
+ })
+
+ // ==================== Mixed Content Tests ====================
+ describe('Mixed Content', () => {
+ it('should render plugins from different sources together', () => {
+ // Arrange
+ const pluginList = [
+ createPluginDetail({
+ plugin_id: 'marketplace-1',
+ name: 'Marketplace Plugin',
+ source: PluginSource.marketplace,
+ }),
+ createPluginDetail({
+ plugin_id: 'github-1',
+ name: 'GitHub Plugin',
+ source: PluginSource.github,
+ }),
+ createPluginDetail({
+ plugin_id: 'local-1',
+ name: 'Local Plugin',
+ source: PluginSource.local,
+ }),
+ ]
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Marketplace Plugin')).toBeInTheDocument()
+ expect(screen.getByText('GitHub Plugin')).toBeInTheDocument()
+ expect(screen.getByText('Local Plugin')).toBeInTheDocument()
+ })
+
+ it('should render plugins of different categories together', () => {
+ // Arrange
+ const pluginList = [
+ createPluginDetail({
+ plugin_id: 'tool-1',
+ name: 'Tool Plugin',
+ declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }),
+ }),
+ createPluginDetail({
+ plugin_id: 'model-1',
+ name: 'Model Plugin',
+ declaration: createPluginDeclaration({ category: PluginCategoryEnum.model }),
+ }),
+ createPluginDetail({
+ plugin_id: 'agent-1',
+ name: 'Agent Plugin',
+ declaration: createPluginDeclaration({ category: PluginCategoryEnum.agent }),
+ }),
+ ]
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Tool Plugin')).toBeInTheDocument()
+ expect(screen.getByText('Model Plugin')).toBeInTheDocument()
+ expect(screen.getByText('Agent Plugin')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Boundary Tests ====================
+ describe('Boundary Tests', () => {
+ it('should handle single item list', () => {
+ // Arrange
+ const pluginList = createPluginList(1)
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getAllByTestId('plugin-item')).toHaveLength(1)
+ })
+
+ it('should handle two items (fills one row)', () => {
+ // Arrange
+ const pluginList = createPluginList(2)
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getAllByTestId('plugin-item')).toHaveLength(2)
+ })
+
+ it('should handle three items (partial second row)', () => {
+ // Arrange
+ const pluginList = createPluginList(3)
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getAllByTestId('plugin-item')).toHaveLength(3)
+ })
+
+ it('should handle odd number of items', () => {
+ // Arrange
+ const pluginList = createPluginList(7)
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getAllByTestId('plugin-item')).toHaveLength(7)
+ })
+
+ it('should handle even number of items', () => {
+ // Arrange
+ const pluginList = createPluginList(8)
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getAllByTestId('plugin-item')).toHaveLength(8)
+ })
+ })
+})