diff --git a/web/__tests__/plugins/plugin-auth-flow.test.tsx b/web/__tests__/plugins/plugin-auth-flow.test.tsx new file mode 100644 index 0000000000..a2ec8703ca --- /dev/null +++ b/web/__tests__/plugins/plugin-auth-flow.test.tsx @@ -0,0 +1,271 @@ +/** + * Integration Test: Plugin Authentication Flow + * + * Tests the integration between PluginAuth, usePluginAuth hook, + * Authorize/Authorized components, and credential management. + * Verifies the complete auth flow from checking authorization status + * to rendering the correct UI state. + */ +import { cleanup, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { AuthCategory, CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const map: Record = { + 'plugin.auth.setUpTip': 'Set up your credentials', + 'plugin.auth.authorized': 'Authorized', + 'plugin.auth.apiKey': 'API Key', + 'plugin.auth.oauth': 'OAuth', + } + return map[key] ?? key + }, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: true, + }), +})) + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), +})) + +const mockUsePluginAuth = vi.fn() +vi.mock('@/app/components/plugins/plugin-auth/hooks/use-plugin-auth', () => ({ + usePluginAuth: (...args: unknown[]) => mockUsePluginAuth(...args), +})) + +vi.mock('@/app/components/plugins/plugin-auth/authorize', () => ({ + default: ({ pluginPayload, canOAuth, canApiKey }: { + pluginPayload: { provider: string } + canOAuth: boolean + canApiKey: boolean + }) => ( +
+ {pluginPayload.provider} + {canOAuth && OAuth available} + {canApiKey && API Key available} +
+ ), +})) + +vi.mock('@/app/components/plugins/plugin-auth/authorized', () => ({ + default: ({ pluginPayload, credentials }: { + pluginPayload: { provider: string } + credentials: Array<{ id: string, name: string }> + }) => ( +
+ {pluginPayload.provider} + + {credentials.length} + {' '} + credentials + +
+ ), +})) + +const { default: PluginAuth } = await import('@/app/components/plugins/plugin-auth/plugin-auth') + +describe('Plugin Authentication Flow Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + }) + + const basePayload = { + category: AuthCategory.tool, + provider: 'test-provider', + } + + describe('Unauthorized State', () => { + it('renders Authorize component when not authorized', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: false, + canOAuth: false, + canApiKey: true, + credentials: [], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + render() + + expect(screen.getByTestId('authorize-component')).toBeInTheDocument() + expect(screen.queryByTestId('authorized-component')).not.toBeInTheDocument() + expect(screen.getByTestId('auth-apikey')).toBeInTheDocument() + }) + + it('shows OAuth option when plugin supports it', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: false, + canOAuth: true, + canApiKey: true, + credentials: [], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + render() + + expect(screen.getByTestId('auth-oauth')).toBeInTheDocument() + expect(screen.getByTestId('auth-apikey')).toBeInTheDocument() + }) + + it('applies className to wrapper when not authorized', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: false, + canOAuth: false, + canApiKey: true, + credentials: [], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + const { container } = render( + , + ) + + expect(container.firstChild).toHaveClass('custom-class') + }) + }) + + describe('Authorized State', () => { + it('renders Authorized component when authorized and no children', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: true, + canOAuth: false, + canApiKey: true, + credentials: [ + { id: 'cred-1', name: 'My API Key', is_default: true }, + ], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + render() + + expect(screen.queryByTestId('authorize-component')).not.toBeInTheDocument() + expect(screen.getByTestId('authorized-component')).toBeInTheDocument() + expect(screen.getByTestId('auth-credential-count')).toHaveTextContent('1 credentials') + }) + + it('renders children instead of Authorized when authorized and children provided', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: true, + canOAuth: false, + canApiKey: true, + credentials: [{ id: 'cred-1', name: 'Key', is_default: true }], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + render( + +
Custom authorized view
+
, + ) + + expect(screen.queryByTestId('authorize-component')).not.toBeInTheDocument() + expect(screen.queryByTestId('authorized-component')).not.toBeInTheDocument() + expect(screen.getByTestId('custom-children')).toBeInTheDocument() + }) + + it('does not apply className when authorized', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: true, + canOAuth: false, + canApiKey: true, + credentials: [{ id: 'cred-1', name: 'Key', is_default: true }], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + const { container } = render( + , + ) + + expect(container.firstChild).not.toHaveClass('custom-class') + }) + }) + + describe('Auth Category Integration', () => { + it('passes correct provider to usePluginAuth for tool category', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: false, + canOAuth: false, + canApiKey: true, + credentials: [], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + const toolPayload = { + category: AuthCategory.tool, + provider: 'google-search-provider', + } + + render() + + expect(mockUsePluginAuth).toHaveBeenCalledWith(toolPayload, true) + expect(screen.getByTestId('auth-provider')).toHaveTextContent('google-search-provider') + }) + + it('passes correct provider to usePluginAuth for datasource category', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: false, + canOAuth: true, + canApiKey: false, + credentials: [], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + const dsPayload = { + category: AuthCategory.datasource, + provider: 'notion-datasource', + } + + render() + + expect(mockUsePluginAuth).toHaveBeenCalledWith(dsPayload, true) + expect(screen.getByTestId('auth-oauth')).toBeInTheDocument() + expect(screen.queryByTestId('auth-apikey')).not.toBeInTheDocument() + }) + }) + + describe('Multiple Credentials', () => { + it('shows credential count when multiple credentials exist', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: true, + canOAuth: true, + canApiKey: true, + credentials: [ + { id: 'cred-1', name: 'API Key 1', is_default: true }, + { id: 'cred-2', name: 'API Key 2', is_default: false }, + { id: 'cred-3', name: 'OAuth Token', is_default: false, credential_type: CredentialTypeEnum.OAUTH2 }, + ], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + render() + + expect(screen.getByTestId('auth-credential-count')).toHaveTextContent('3 credentials') + }) + }) +}) diff --git a/web/__tests__/plugins/plugin-card-rendering.test.tsx b/web/__tests__/plugins/plugin-card-rendering.test.tsx new file mode 100644 index 0000000000..7abcb01b49 --- /dev/null +++ b/web/__tests__/plugins/plugin-card-rendering.test.tsx @@ -0,0 +1,224 @@ +/** + * Integration Test: Plugin Card Rendering Pipeline + * + * Tests the integration between Card, Icon, Title, Description, + * OrgInfo, CornerMark, and CardMoreInfo components. Verifies that + * plugin data flows correctly through the card rendering pipeline. + */ +import { cleanup, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en_US', +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: 'light' }), +})) + +vi.mock('@/i18n-config', () => ({ + renderI18nObject: (obj: Record, locale: string) => obj[locale] || obj.en_US || '', +})) + +vi.mock('@/types/app', () => ({ + Theme: { dark: 'dark', light: 'light' }, +})) + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: unknown[]) => args.filter(a => typeof a === 'string' && a).join(' '), +})) + +vi.mock('@/app/components/plugins/hooks', () => ({ + useCategories: () => ({ + categoriesMap: { + tool: { label: 'Tool' }, + model: { label: 'Model' }, + extension: { label: 'Extension' }, + }, + }), +})) + +vi.mock('@/app/components/plugins/base/badges/partner', () => ({ + default: () => Partner, +})) + +vi.mock('@/app/components/plugins/base/badges/verified', () => ({ + default: () => Verified, +})) + +vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ + default: ({ src, installed, installFailed }: { src: string | object, installed?: boolean, installFailed?: boolean }) => ( +
+ {typeof src === 'string' ? src : 'emoji-icon'} +
+ ), +})) + +vi.mock('@/app/components/plugins/card/base/corner-mark', () => ({ + default: ({ text }: { text: string }) => ( +
{text}
+ ), +})) + +vi.mock('@/app/components/plugins/card/base/description', () => ({ + default: ({ text, descriptionLineRows }: { text: string, descriptionLineRows?: number }) => ( +
{text}
+ ), +})) + +vi.mock('@/app/components/plugins/card/base/org-info', () => ({ + default: ({ orgName, packageName }: { orgName: string, packageName: string }) => ( +
+ {orgName} + / + {packageName} +
+ ), +})) + +vi.mock('@/app/components/plugins/card/base/placeholder', () => ({ + default: ({ text }: { text: string }) => ( +
{text}
+ ), +})) + +vi.mock('@/app/components/plugins/card/base/title', () => ({ + default: ({ title }: { title: string }) => ( +
{title}
+ ), +})) + +const { default: Card } = await import('@/app/components/plugins/card/index') +type CardPayload = Parameters[0]['payload'] + +describe('Plugin Card Rendering Integration', () => { + beforeEach(() => { + cleanup() + }) + + const makePayload = (overrides = {}) => ({ + category: 'tool', + type: 'plugin', + name: 'google-search', + org: 'langgenius', + label: { en_US: 'Google Search', zh_Hans: 'Google搜索' }, + brief: { en_US: 'Search the web using Google', zh_Hans: '使用Google搜索网页' }, + icon: 'https://example.com/icon.png', + verified: true, + badges: [] as string[], + ...overrides, + }) as CardPayload + + it('renders a complete plugin card with all subcomponents', () => { + const payload = makePayload() + render() + + expect(screen.getByTestId('card-icon')).toBeInTheDocument() + expect(screen.getByTestId('title')).toHaveTextContent('Google Search') + expect(screen.getByTestId('org-info')).toHaveTextContent('langgenius/google-search') + expect(screen.getByTestId('description')).toHaveTextContent('Search the web using Google') + }) + + it('shows corner mark with category label when not hidden', () => { + const payload = makePayload() + render() + + expect(screen.getByTestId('corner-mark')).toBeInTheDocument() + }) + + it('hides corner mark when hideCornerMark is true', () => { + const payload = makePayload() + render() + + expect(screen.queryByTestId('corner-mark')).not.toBeInTheDocument() + }) + + it('shows installed status on icon', () => { + const payload = makePayload() + render() + + const icon = screen.getByTestId('card-icon') + expect(icon).toHaveAttribute('data-installed', 'true') + }) + + it('shows install failed status on icon', () => { + const payload = makePayload() + render() + + const icon = screen.getByTestId('card-icon') + expect(icon).toHaveAttribute('data-install-failed', 'true') + }) + + it('renders verified badge when plugin is verified', () => { + const payload = makePayload({ verified: true }) + render() + + expect(screen.getByTestId('verified-badge')).toBeInTheDocument() + }) + + it('renders partner badge when plugin has partner badge', () => { + const payload = makePayload({ badges: ['partner'] }) + render() + + expect(screen.getByTestId('partner-badge')).toBeInTheDocument() + }) + + it('renders footer content when provided', () => { + const payload = makePayload() + render( + Custom footer} + />, + ) + + expect(screen.getByTestId('custom-footer')).toBeInTheDocument() + }) + + it('renders titleLeft content when provided', () => { + const payload = makePayload() + render( + New} + />, + ) + + expect(screen.getByTestId('title-left-content')).toBeInTheDocument() + }) + + it('uses dark icon when theme is dark and icon_dark is provided', () => { + vi.doMock('@/hooks/use-theme', () => ({ + default: () => ({ theme: 'dark' }), + })) + + const payload = makePayload({ + icon: 'https://example.com/icon-light.png', + icon_dark: 'https://example.com/icon-dark.png', + }) + + render() + expect(screen.getByTestId('card-icon')).toBeInTheDocument() + }) + + it('shows loading placeholder when isLoading is true', () => { + const payload = makePayload() + render() + + expect(screen.getByTestId('placeholder')).toBeInTheDocument() + }) + + it('renders description with custom line rows', () => { + const payload = makePayload() + render() + + const description = screen.getByTestId('description') + expect(description).toHaveAttribute('data-rows', '3') + }) +}) diff --git a/web/__tests__/plugins/plugin-data-utilities.test.ts b/web/__tests__/plugins/plugin-data-utilities.test.ts new file mode 100644 index 0000000000..068b0e3238 --- /dev/null +++ b/web/__tests__/plugins/plugin-data-utilities.test.ts @@ -0,0 +1,159 @@ +/** + * Integration Test: Plugin Data Utilities + * + * Tests the integration between plugin utility functions, including + * tag/category validation, form schema transformation, and + * credential data processing. Verifies that these utilities work + * correctly together in processing plugin metadata. + */ +import { describe, expect, it } from 'vitest' + +import { transformFormSchemasSecretInput } from '@/app/components/plugins/plugin-auth/utils' +import { getValidCategoryKeys, getValidTagKeys } from '@/app/components/plugins/utils' + +type TagInput = Parameters[0] + +describe('Plugin Data Utilities Integration', () => { + describe('Tag and Category Validation Pipeline', () => { + it('validates tags and categories in a metadata processing flow', () => { + const pluginMetadata = { + tags: ['search', 'productivity', 'invalid-tag', 'media-generate'], + category: 'tool', + } + + const validTags = getValidTagKeys(pluginMetadata.tags as TagInput) + expect(validTags.length).toBeGreaterThan(0) + expect(validTags.length).toBeLessThanOrEqual(pluginMetadata.tags.length) + + const validCategory = getValidCategoryKeys(pluginMetadata.category) + expect(validCategory).toBeDefined() + }) + + it('handles completely invalid metadata gracefully', () => { + const invalidMetadata = { + tags: ['nonexistent-1', 'nonexistent-2'], + category: 'nonexistent-category', + } + + const validTags = getValidTagKeys(invalidMetadata.tags as TagInput) + expect(validTags).toHaveLength(0) + + const validCategory = getValidCategoryKeys(invalidMetadata.category) + expect(validCategory).toBeUndefined() + }) + + it('handles undefined and empty inputs', () => { + expect(getValidTagKeys([] as TagInput)).toHaveLength(0) + expect(getValidCategoryKeys(undefined)).toBeUndefined() + expect(getValidCategoryKeys('')).toBeUndefined() + }) + }) + + describe('Credential Secret Masking Pipeline', () => { + it('masks secrets when displaying credential form data', () => { + const credentialValues = { + api_key: 'sk-abc123456789', + api_endpoint: 'https://api.example.com', + secret_token: 'secret-token-value', + description: 'My credential set', + } + + const secretFields = ['api_key', 'secret_token'] + + const displayValues = transformFormSchemasSecretInput(secretFields, credentialValues) + + expect(displayValues.api_key).toBe('[__HIDDEN__]') + expect(displayValues.secret_token).toBe('[__HIDDEN__]') + expect(displayValues.api_endpoint).toBe('https://api.example.com') + expect(displayValues.description).toBe('My credential set') + }) + + it('preserves original values when no secret fields', () => { + const values = { + name: 'test', + endpoint: 'https://api.example.com', + } + + const result = transformFormSchemasSecretInput([], values) + expect(result).toEqual(values) + }) + + it('handles falsy secret values without masking', () => { + const values = { + api_key: '', + secret: null as unknown as string, + other: 'visible', + } + + const result = transformFormSchemasSecretInput(['api_key', 'secret'], values) + expect(result.api_key).toBe('') + expect(result.secret).toBeNull() + expect(result.other).toBe('visible') + }) + + it('does not mutate the original values object', () => { + const original = { + api_key: 'my-secret-key', + name: 'test', + } + const originalCopy = { ...original } + + transformFormSchemasSecretInput(['api_key'], original) + + expect(original).toEqual(originalCopy) + }) + }) + + describe('Combined Plugin Metadata Validation', () => { + it('processes a complete plugin entry with tags and credentials', () => { + const pluginEntry = { + name: 'test-plugin', + category: 'tool', + tags: ['search', 'invalid-tag'], + credentials: { + api_key: 'sk-test-key-123', + base_url: 'https://api.test.com', + }, + secretFields: ['api_key'], + } + + const validCategory = getValidCategoryKeys(pluginEntry.category) + expect(validCategory).toBe('tool') + + const validTags = getValidTagKeys(pluginEntry.tags as TagInput) + expect(validTags).toContain('search') + + const displayCredentials = transformFormSchemasSecretInput( + pluginEntry.secretFields, + pluginEntry.credentials, + ) + expect(displayCredentials.api_key).toBe('[__HIDDEN__]') + expect(displayCredentials.base_url).toBe('https://api.test.com') + + expect(pluginEntry.credentials.api_key).toBe('sk-test-key-123') + }) + + it('handles multiple plugins in batch processing', () => { + const plugins = [ + { tags: ['search', 'productivity'], category: 'tool' }, + { tags: ['image', 'design'], category: 'model' }, + { tags: ['invalid'], category: 'extension' }, + ] + + const results = plugins.map(p => ({ + validTags: getValidTagKeys(p.tags as TagInput), + validCategory: getValidCategoryKeys(p.category), + })) + + expect(results[0].validTags.length).toBeGreaterThan(0) + expect(results[0].validCategory).toBe('tool') + + expect(results[1].validTags).toContain('image') + expect(results[1].validTags).toContain('design') + expect(results[1].validCategory).toBe('model') + + expect(results[2].validTags).toHaveLength(0) + expect(results[2].validCategory).toBe('extension') + }) + }) +}) diff --git a/web/__tests__/plugins/plugin-install-flow.test.ts b/web/__tests__/plugins/plugin-install-flow.test.ts new file mode 100644 index 0000000000..7ceca4535b --- /dev/null +++ b/web/__tests__/plugins/plugin-install-flow.test.ts @@ -0,0 +1,269 @@ +/** + * Integration Test: Plugin Installation Flow + * + * Tests the integration between GitHub release fetching, version comparison, + * upload handling, and task status polling. Verifies the complete plugin + * installation pipeline from source discovery to completion. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/config', () => ({ + GITHUB_ACCESS_TOKEN: '', +})) + +const mockToastNotify = vi.fn() +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: (...args: unknown[]) => mockToastNotify(...args) }, +})) + +const mockUploadGitHub = vi.fn() +vi.mock('@/service/plugins', () => ({ + uploadGitHub: (...args: unknown[]) => mockUploadGitHub(...args), + checkTaskStatus: vi.fn(), +})) + +vi.mock('@/utils/semver', () => ({ + compareVersion: (a: string, b: string) => { + const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number) + const [aMajor, aMinor = 0, aPatch = 0] = parse(a) + const [bMajor, bMinor = 0, bPatch = 0] = parse(b) + if (aMajor !== bMajor) + return aMajor > bMajor ? 1 : -1 + if (aMinor !== bMinor) + return aMinor > bMinor ? 1 : -1 + if (aPatch !== bPatch) + return aPatch > bPatch ? 1 : -1 + return 0 + }, + getLatestVersion: (versions: string[]) => { + return versions.sort((a, b) => { + const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number) + const [aMaj, aMin = 0, aPat = 0] = parse(a) + const [bMaj, bMin = 0, bPat = 0] = parse(b) + if (aMaj !== bMaj) + return bMaj - aMaj + if (aMin !== bMin) + return bMin - aMin + return bPat - aPat + })[0] + }, +})) + +const { useGitHubReleases, useGitHubUpload } = await import( + '@/app/components/plugins/install-plugin/hooks', +) + +describe('Plugin Installation Flow Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + globalThis.fetch = vi.fn() + }) + + describe('GitHub Release Discovery → Version Check → Upload Pipeline', () => { + it('fetches releases, checks for updates, and uploads the new version', async () => { + const mockReleases = [ + { + tag_name: 'v2.0.0', + assets: [{ browser_download_url: 'https://github.com/test/v2.difypkg', name: 'plugin-v2.difypkg' }], + }, + { + tag_name: 'v1.5.0', + assets: [{ browser_download_url: 'https://github.com/test/v1.5.difypkg', name: 'plugin-v1.5.difypkg' }], + }, + { + tag_name: 'v1.0.0', + assets: [{ browser_download_url: 'https://github.com/test/v1.difypkg', name: 'plugin-v1.difypkg' }], + }, + ] + + ;(globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockReleases), + }) + + mockUploadGitHub.mockResolvedValue({ + manifest: { name: 'test-plugin', version: '2.0.0' }, + unique_identifier: 'test-plugin:2.0.0', + }) + + const { fetchReleases, checkForUpdates } = useGitHubReleases() + + const releases = await fetchReleases('test-org', 'test-repo') + expect(releases).toHaveLength(3) + expect(releases[0].tag_name).toBe('v2.0.0') + + const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0') + expect(needUpdate).toBe(true) + expect(toastProps.message).toContain('v2.0.0') + + const { handleUpload } = useGitHubUpload() + const onSuccess = vi.fn() + const result = await handleUpload( + 'https://github.com/test-org/test-repo', + 'v2.0.0', + 'plugin-v2.difypkg', + onSuccess, + ) + + expect(mockUploadGitHub).toHaveBeenCalledWith( + 'https://github.com/test-org/test-repo', + 'v2.0.0', + 'plugin-v2.difypkg', + ) + expect(onSuccess).toHaveBeenCalledWith({ + manifest: { name: 'test-plugin', version: '2.0.0' }, + unique_identifier: 'test-plugin:2.0.0', + }) + expect(result).toEqual({ + manifest: { name: 'test-plugin', version: '2.0.0' }, + unique_identifier: 'test-plugin:2.0.0', + }) + }) + + it('handles no new version available', async () => { + const mockReleases = [ + { + tag_name: 'v1.0.0', + assets: [{ browser_download_url: 'https://github.com/test/v1.difypkg', name: 'plugin-v1.difypkg' }], + }, + ] + + ;(globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockReleases), + }) + + const { fetchReleases, checkForUpdates } = useGitHubReleases() + + const releases = await fetchReleases('test-org', 'test-repo') + const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0') + + expect(needUpdate).toBe(false) + expect(toastProps.type).toBe('info') + expect(toastProps.message).toBe('No new version available') + }) + + it('handles empty releases', async () => { + ;(globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }) + + const { fetchReleases, checkForUpdates } = useGitHubReleases() + + const releases = await fetchReleases('test-org', 'test-repo') + expect(releases).toHaveLength(0) + + const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0') + expect(needUpdate).toBe(false) + expect(toastProps.type).toBe('error') + expect(toastProps.message).toBe('Input releases is empty') + }) + + it('handles fetch failure gracefully', async () => { + ;(globalThis.fetch as ReturnType).mockResolvedValue({ + ok: false, + status: 404, + }) + + const { fetchReleases } = useGitHubReleases() + const releases = await fetchReleases('nonexistent-org', 'nonexistent-repo') + + expect(releases).toEqual([]) + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + + it('handles upload failure gracefully', async () => { + mockUploadGitHub.mockRejectedValue(new Error('Upload failed')) + + const { handleUpload } = useGitHubUpload() + const onSuccess = vi.fn() + + await expect( + handleUpload('https://github.com/test/repo', 'v1.0.0', 'plugin.difypkg', onSuccess), + ).rejects.toThrow('Upload failed') + + expect(onSuccess).not.toHaveBeenCalled() + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error', message: 'Error uploading package' }), + ) + }) + }) + + describe('Task Status Polling Integration', () => { + it('polls until plugin installation succeeds', async () => { + const mockCheckTaskStatus = vi.fn() + .mockResolvedValueOnce({ + task: { + plugins: [{ plugin_unique_identifier: 'test:1.0.0', status: 'running' }], + }, + }) + .mockResolvedValueOnce({ + task: { + plugins: [{ plugin_unique_identifier: 'test:1.0.0', status: 'success' }], + }, + }) + + const { checkTaskStatus: fetchCheckTaskStatus } = await import('@/service/plugins') + ;(fetchCheckTaskStatus as ReturnType).mockImplementation(mockCheckTaskStatus) + + await vi.doMock('@/utils', () => ({ + sleep: () => Promise.resolve(), + })) + + const { default: checkTaskStatus } = await import( + '@/app/components/plugins/install-plugin/base/check-task-status', + ) + + const checker = checkTaskStatus() + const result = await checker.check({ + taskId: 'task-123', + pluginUniqueIdentifier: 'test:1.0.0', + }) + + expect(result.status).toBe('success') + }) + + it('returns failure when plugin not found in task', async () => { + const mockCheckTaskStatus = vi.fn().mockResolvedValue({ + task: { + plugins: [{ plugin_unique_identifier: 'other:1.0.0', status: 'success' }], + }, + }) + + const { checkTaskStatus: fetchCheckTaskStatus } = await import('@/service/plugins') + ;(fetchCheckTaskStatus as ReturnType).mockImplementation(mockCheckTaskStatus) + + const { default: checkTaskStatus } = await import( + '@/app/components/plugins/install-plugin/base/check-task-status', + ) + + const checker = checkTaskStatus() + const result = await checker.check({ + taskId: 'task-123', + pluginUniqueIdentifier: 'test:1.0.0', + }) + + expect(result.status).toBe('failed') + expect(result.error).toBe('Plugin package not found') + }) + + it('stops polling when stop() is called', async () => { + const { default: checkTaskStatus } = await import( + '@/app/components/plugins/install-plugin/base/check-task-status', + ) + + const checker = checkTaskStatus() + checker.stop() + + const result = await checker.check({ + taskId: 'task-123', + pluginUniqueIdentifier: 'test:1.0.0', + }) + + expect(result.status).toBe('success') + }) + }) +}) diff --git a/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx b/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx new file mode 100644 index 0000000000..91e32155e7 --- /dev/null +++ b/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest' +import { pluginInstallLimit } from '@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit' +import { InstallationScope } from '@/types/feature' + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: () => ({ + plugin_installation_permission: { + restrict_to_marketplace_only: false, + plugin_installation_scope: InstallationScope.ALL, + }, + }), +})) + +describe('Plugin Marketplace to Install Flow', () => { + describe('install permission validation pipeline', () => { + const systemFeaturesAll = { + plugin_installation_permission: { + restrict_to_marketplace_only: false, + plugin_installation_scope: InstallationScope.ALL, + }, + } + + const systemFeaturesMarketplaceOnly = { + plugin_installation_permission: { + restrict_to_marketplace_only: true, + plugin_installation_scope: InstallationScope.ALL, + }, + } + + const systemFeaturesOfficialOnly = { + plugin_installation_permission: { + restrict_to_marketplace_only: false, + plugin_installation_scope: InstallationScope.OFFICIAL_ONLY, + }, + } + + it('should allow marketplace plugin when all sources allowed', () => { + const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' } } + const result = pluginInstallLimit(plugin as never, systemFeaturesAll as never) + expect(result.canInstall).toBe(true) + }) + + it('should allow github plugin when all sources allowed', () => { + const plugin = { from: 'github' as const, verification: { authorized_category: 'langgenius' } } + const result = pluginInstallLimit(plugin as never, systemFeaturesAll as never) + expect(result.canInstall).toBe(true) + }) + + it('should block github plugin when marketplace only', () => { + const plugin = { from: 'github' as const, verification: { authorized_category: 'langgenius' } } + const result = pluginInstallLimit(plugin as never, systemFeaturesMarketplaceOnly as never) + expect(result.canInstall).toBe(false) + }) + + it('should allow marketplace plugin when marketplace only', () => { + const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'partner' } } + const result = pluginInstallLimit(plugin as never, systemFeaturesMarketplaceOnly as never) + expect(result.canInstall).toBe(true) + }) + + it('should allow official plugin when official only', () => { + const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' } } + const result = pluginInstallLimit(plugin as never, systemFeaturesOfficialOnly as never) + expect(result.canInstall).toBe(true) + }) + + it('should block community plugin when official only', () => { + const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'community' } } + const result = pluginInstallLimit(plugin as never, systemFeaturesOfficialOnly as never) + expect(result.canInstall).toBe(false) + }) + }) + + describe('plugin source classification', () => { + it('should correctly classify plugin install sources', () => { + const sources = ['marketplace', 'github', 'package'] as const + const features = { + plugin_installation_permission: { + restrict_to_marketplace_only: true, + plugin_installation_scope: InstallationScope.ALL, + }, + } + + const results = sources.map(source => ({ + source, + canInstall: pluginInstallLimit( + { from: source, verification: { authorized_category: 'langgenius' } } as never, + features as never, + ).canInstall, + })) + + expect(results.find(r => r.source === 'marketplace')?.canInstall).toBe(true) + expect(results.find(r => r.source === 'github')?.canInstall).toBe(false) + expect(results.find(r => r.source === 'package')?.canInstall).toBe(false) + }) + }) +}) diff --git a/web/__tests__/plugins/plugin-page-filter-management.test.tsx b/web/__tests__/plugins/plugin-page-filter-management.test.tsx new file mode 100644 index 0000000000..9f6fbabc31 --- /dev/null +++ b/web/__tests__/plugins/plugin-page-filter-management.test.tsx @@ -0,0 +1,120 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import { useStore } from '@/app/components/plugins/plugin-page/filter-management/store' + +describe('Plugin Page Filter Management Integration', () => { + beforeEach(() => { + const { result } = renderHook(() => useStore()) + act(() => { + result.current.setTagList([]) + result.current.setCategoryList([]) + result.current.setShowTagManagementModal(false) + result.current.setShowCategoryManagementModal(false) + }) + }) + + describe('tag and category filter lifecycle', () => { + it('should manage full tag lifecycle: add -> update -> clear', () => { + const { result } = renderHook(() => useStore()) + + const initialTags = [ + { name: 'search', label: { en_US: 'Search' } }, + { name: 'productivity', label: { en_US: 'Productivity' } }, + ] + + act(() => { + result.current.setTagList(initialTags as never[]) + }) + expect(result.current.tagList).toHaveLength(2) + + const updatedTags = [ + ...initialTags, + { name: 'image', label: { en_US: 'Image' } }, + ] + + act(() => { + result.current.setTagList(updatedTags as never[]) + }) + expect(result.current.tagList).toHaveLength(3) + + act(() => { + result.current.setTagList([]) + }) + expect(result.current.tagList).toHaveLength(0) + }) + + it('should manage full category lifecycle: add -> update -> clear', () => { + const { result } = renderHook(() => useStore()) + + const categories = [ + { name: 'tool', label: { en_US: 'Tool' } }, + { name: 'model', label: { en_US: 'Model' } }, + ] + + act(() => { + result.current.setCategoryList(categories as never[]) + }) + expect(result.current.categoryList).toHaveLength(2) + + act(() => { + result.current.setCategoryList([]) + }) + expect(result.current.categoryList).toHaveLength(0) + }) + }) + + describe('modal state management', () => { + it('should manage tag management modal independently', () => { + const { result } = renderHook(() => useStore()) + + act(() => { + result.current.setShowTagManagementModal(true) + }) + expect(result.current.showTagManagementModal).toBe(true) + expect(result.current.showCategoryManagementModal).toBe(false) + + act(() => { + result.current.setShowTagManagementModal(false) + }) + expect(result.current.showTagManagementModal).toBe(false) + }) + + it('should manage category management modal independently', () => { + const { result } = renderHook(() => useStore()) + + act(() => { + result.current.setShowCategoryManagementModal(true) + }) + expect(result.current.showCategoryManagementModal).toBe(true) + expect(result.current.showTagManagementModal).toBe(false) + }) + + it('should support both modals open simultaneously', () => { + const { result } = renderHook(() => useStore()) + + act(() => { + result.current.setShowTagManagementModal(true) + result.current.setShowCategoryManagementModal(true) + }) + + expect(result.current.showTagManagementModal).toBe(true) + expect(result.current.showCategoryManagementModal).toBe(true) + }) + }) + + describe('state persistence across renders', () => { + it('should maintain filter state when re-rendered', () => { + const { result, rerender } = renderHook(() => useStore()) + + act(() => { + result.current.setTagList([{ name: 'search' }] as never[]) + result.current.setCategoryList([{ name: 'tool' }] as never[]) + }) + + rerender() + + expect(result.current.tagList).toHaveLength(1) + expect(result.current.categoryList).toHaveLength(1) + }) + }) +}) diff --git a/web/__tests__/tools/tool-browsing-and-filtering.test.tsx b/web/__tests__/tools/tool-browsing-and-filtering.test.tsx new file mode 100644 index 0000000000..4e7fa4952b --- /dev/null +++ b/web/__tests__/tools/tool-browsing-and-filtering.test.tsx @@ -0,0 +1,369 @@ +import type { Collection } from '@/app/components/tools/types' +/** + * Integration Test: Tool Browsing & Filtering Flow + * + * Tests the integration between ProviderList, TabSliderNew, LabelFilter, + * Input (search), and card rendering. Verifies that tab switching, keyword + * filtering, and label filtering work together correctly. + */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CollectionType } from '@/app/components/tools/types' + +// ---- Mocks ---- + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const map: Record = { + 'type.builtIn': 'Built-in', + 'type.custom': 'Custom', + 'type.workflow': 'Workflow', + 'noTools': 'No tools found', + } + return map[key] ?? key + }, + }), +})) + +vi.mock('nuqs', () => ({ + useQueryState: () => ['builtin', vi.fn()], +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: () => ({ enable_marketplace: false }), +})) + +vi.mock('@/app/components/plugins/hooks', () => ({ + useTags: () => ({ + getTagLabel: (key: string) => key, + tags: [], + }), +})) + +vi.mock('@/service/use-plugins', () => ({ + useCheckInstalled: () => ({ data: null }), + useInvalidateInstalledPluginList: () => vi.fn(), +})) + +const mockCollections: Collection[] = [ + { + id: 'google-search', + name: 'google_search', + author: 'Dify', + description: { en_US: 'Google Search Tool', zh_Hans: 'Google搜索工具' }, + icon: 'https://example.com/google.png', + label: { en_US: 'Google Search', zh_Hans: 'Google搜索' }, + type: CollectionType.builtIn, + team_credentials: {}, + is_team_authorization: true, + allow_delete: false, + labels: ['search'], + }, + { + id: 'weather-api', + name: 'weather_api', + author: 'Dify', + description: { en_US: 'Weather API Tool', zh_Hans: '天气API工具' }, + icon: 'https://example.com/weather.png', + label: { en_US: 'Weather API', zh_Hans: '天气API' }, + type: CollectionType.builtIn, + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: ['utility'], + }, + { + id: 'my-custom-tool', + name: 'my_custom_tool', + author: 'User', + description: { en_US: 'My Custom Tool', zh_Hans: '我的自定义工具' }, + icon: 'https://example.com/custom.png', + label: { en_US: 'My Custom Tool', zh_Hans: '我的自定义工具' }, + type: CollectionType.custom, + team_credentials: {}, + is_team_authorization: false, + allow_delete: true, + labels: [], + }, + { + id: 'workflow-tool-1', + name: 'workflow_tool_1', + author: 'User', + description: { en_US: 'Workflow Tool', zh_Hans: '工作流工具' }, + icon: 'https://example.com/workflow.png', + label: { en_US: 'Workflow Tool', zh_Hans: '工作流工具' }, + type: CollectionType.workflow, + team_credentials: {}, + is_team_authorization: false, + allow_delete: true, + labels: [], + }, +] + +const mockRefetch = vi.fn() +vi.mock('@/service/use-tools', () => ({ + useAllToolProviders: () => ({ + data: mockCollections, + refetch: mockRefetch, + isSuccess: true, + }), +})) + +vi.mock('@/app/components/base/tab-slider-new', () => ({ + default: ({ value, onChange, options }: { value: string, onChange: (v: string) => void, options: Array<{ value: string, text: string }> }) => ( +
+ {options.map((opt: { value: string, text: string }) => ( + + ))} +
+ ), +})) + +vi.mock('@/app/components/base/input', () => ({ + default: ({ value, onChange, onClear, showLeftIcon, showClearIcon, wrapperClassName }: { + value: string + onChange: (e: { target: { value: string } }) => void + onClear: () => void + showLeftIcon?: boolean + showClearIcon?: boolean + wrapperClassName?: string + }) => ( +
+ + {showClearIcon && value && ( + + )} +
+ ), +})) + +vi.mock('@/app/components/plugins/card', () => ({ + default: ({ payload, className }: { payload: { brief: Record | string, name: string }, className?: string }) => { + const briefText = typeof payload.brief === 'object' ? payload.brief?.en_US || '' : payload.brief + return ( +
+ {payload.name} + {briefText} +
+ ) + }, +})) + +vi.mock('@/app/components/plugins/card/card-more-info', () => ({ + default: ({ tags }: { tags: string[] }) => ( +
{tags.join(', ')}
+ ), +})) + +vi.mock('@/app/components/tools/labels/filter', () => ({ + default: ({ value: _value, onChange }: { value: string[], onChange: (v: string[]) => void }) => ( +
+ + + +
+ ), +})) + +vi.mock('@/app/components/tools/provider/custom-create-card', () => ({ + default: () =>
Create Custom Tool
, +})) + +vi.mock('@/app/components/tools/provider/detail', () => ({ + default: ({ collection, onHide }: { collection: Collection, onHide: () => void }) => ( +
+ {collection.name} + +
+ ), +})) + +vi.mock('@/app/components/tools/provider/empty', () => ({ + default: () =>
No workflow tools
, +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({ + default: ({ detail, onHide }: { detail: unknown, onHide: () => void }) => ( + detail ?
: null + ), +})) + +vi.mock('@/app/components/plugins/marketplace/empty', () => ({ + default: ({ text }: { text: string }) =>
{text}
, +})) + +vi.mock('@/app/components/tools/marketplace', () => ({ + default: () => null, +})) + +vi.mock('@/app/components/tools/mcp', () => ({ + default: () =>
MCP List
, +})) + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), +})) + +vi.mock('@/app/components/workflow/block-selector/types', () => ({ + ToolTypeEnum: { BuiltIn: 'builtin', Custom: 'api', Workflow: 'workflow', MCP: 'mcp' }, +})) + +const { default: ProviderList } = await import('@/app/components/tools/provider-list') + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return ({ children }: { children: React.ReactNode }) => ( + {children} + ) +} + +describe('Tool Browsing & Filtering Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + }) + + it('renders tab options and built-in tools by default', () => { + render(, { wrapper: createWrapper() }) + + expect(screen.getByTestId('tab-slider')).toBeInTheDocument() + expect(screen.getByTestId('tab-builtin')).toBeInTheDocument() + expect(screen.getByTestId('tab-api')).toBeInTheDocument() + expect(screen.getByTestId('tab-workflow')).toBeInTheDocument() + expect(screen.getByTestId('tab-mcp')).toBeInTheDocument() + + expect(screen.getByTestId('card-google_search')).toBeInTheDocument() + expect(screen.getByTestId('card-weather_api')).toBeInTheDocument() + expect(screen.queryByTestId('card-my_custom_tool')).not.toBeInTheDocument() + expect(screen.queryByTestId('card-workflow_tool_1')).not.toBeInTheDocument() + }) + + it('filters tools by keyword search', async () => { + render(, { wrapper: createWrapper() }) + + const searchInput = screen.getByTestId('search-input') + fireEvent.change(searchInput, { target: { value: 'Google' } }) + + await waitFor(() => { + expect(screen.getByTestId('card-google_search')).toBeInTheDocument() + expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument() + }) + }) + + it('clears search keyword and shows all tools again', async () => { + render(, { wrapper: createWrapper() }) + + const searchInput = screen.getByTestId('search-input') + fireEvent.change(searchInput, { target: { value: 'Google' } }) + await waitFor(() => { + expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument() + }) + + fireEvent.change(searchInput, { target: { value: '' } }) + await waitFor(() => { + expect(screen.getByTestId('card-google_search')).toBeInTheDocument() + expect(screen.getByTestId('card-weather_api')).toBeInTheDocument() + }) + }) + + it('filters tools by label tags', async () => { + render(, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByTestId('filter-search')) + + await waitFor(() => { + expect(screen.getByTestId('card-google_search')).toBeInTheDocument() + expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument() + }) + }) + + it('clears label filter and shows all tools', async () => { + render(, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByTestId('filter-utility')) + await waitFor(() => { + expect(screen.queryByTestId('card-google_search')).not.toBeInTheDocument() + expect(screen.getByTestId('card-weather_api')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('filter-clear')) + await waitFor(() => { + expect(screen.getByTestId('card-google_search')).toBeInTheDocument() + expect(screen.getByTestId('card-weather_api')).toBeInTheDocument() + }) + }) + + it('combines keyword search and label filter', async () => { + render(, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByTestId('filter-search')) + await waitFor(() => { + expect(screen.getByTestId('card-google_search')).toBeInTheDocument() + }) + + const searchInput = screen.getByTestId('search-input') + fireEvent.change(searchInput, { target: { value: 'Weather' } }) + await waitFor(() => { + expect(screen.queryByTestId('card-google_search')).not.toBeInTheDocument() + expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument() + }) + }) + + it('opens provider detail when clicking a non-plugin collection card', async () => { + render(, { wrapper: createWrapper() }) + + const card = screen.getByTestId('card-google_search') + fireEvent.click(card.parentElement!) + + await waitFor(() => { + expect(screen.getByTestId('provider-detail')).toBeInTheDocument() + expect(screen.getByTestId('detail-name')).toHaveTextContent('google_search') + }) + }) + + it('closes provider detail and deselects current provider', async () => { + render(, { wrapper: createWrapper() }) + + const card = screen.getByTestId('card-google_search') + fireEvent.click(card.parentElement!) + + await waitFor(() => { + expect(screen.getByTestId('provider-detail')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('detail-close')) + await waitFor(() => { + expect(screen.queryByTestId('provider-detail')).not.toBeInTheDocument() + }) + }) + + it('shows label filter for non-MCP tabs', () => { + render(, { wrapper: createWrapper() }) + + expect(screen.getByTestId('label-filter')).toBeInTheDocument() + }) + + it('shows search input on all tabs', () => { + render(, { wrapper: createWrapper() }) + + expect(screen.getByTestId('search-input')).toBeInTheDocument() + }) +}) diff --git a/web/__tests__/tools/tool-data-processing.test.ts b/web/__tests__/tools/tool-data-processing.test.ts new file mode 100644 index 0000000000..120461201f --- /dev/null +++ b/web/__tests__/tools/tool-data-processing.test.ts @@ -0,0 +1,239 @@ +/** + * Integration Test: Tool Data Processing Pipeline + * + * Tests the integration between tool utility functions and type conversions. + * Verifies that data flows correctly through the processing pipeline: + * raw API data → form schemas → form values → configured values. + */ +import { describe, expect, it } from 'vitest' + +import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils/index' +import { + addDefaultValue, + generateFormValue, + getConfiguredValue, + getPlainValue, + getStructureValue, + toolCredentialToFormSchemas, + toolParametersToFormSchemas, + toType, + triggerEventParametersToFormSchemas, +} from '@/app/components/tools/utils/to-form-schema' + +describe('Tool Data Processing Pipeline Integration', () => { + describe('End-to-end: API schema → form schema → form value', () => { + it('processes tool parameters through the full pipeline', () => { + const rawParameters = [ + { + name: 'query', + label: { en_US: 'Search Query', zh_Hans: '搜索查询' }, + type: 'string', + required: true, + default: 'hello', + form: 'llm', + human_description: { en_US: 'Enter your search query', zh_Hans: '输入搜索查询' }, + llm_description: 'The search query string', + options: [], + }, + { + name: 'limit', + label: { en_US: 'Result Limit', zh_Hans: '结果限制' }, + type: 'number', + required: false, + default: '10', + form: 'form', + human_description: { en_US: 'Maximum results', zh_Hans: '最大结果数' }, + llm_description: 'Limit for results', + options: [], + }, + ] + + const formSchemas = toolParametersToFormSchemas(rawParameters as unknown as Parameters[0]) + expect(formSchemas).toHaveLength(2) + expect(formSchemas[0].variable).toBe('query') + expect(formSchemas[0].required).toBe(true) + expect(formSchemas[0].type).toBe('text-input') + expect(formSchemas[1].variable).toBe('limit') + expect(formSchemas[1].type).toBe('number-input') + + const withDefaults = addDefaultValue({}, formSchemas) + expect(withDefaults.query).toBe('hello') + expect(withDefaults.limit).toBe('10') + + const formValues = generateFormValue({}, formSchemas, false) + expect(formValues).toBeDefined() + expect(formValues.query).toBeDefined() + expect(formValues.limit).toBeDefined() + }) + + it('processes tool credentials through the pipeline', () => { + const rawCredentials = [ + { + name: 'api_key', + label: { en_US: 'API Key', zh_Hans: 'API 密钥' }, + type: 'secret-input', + required: true, + default: '', + placeholder: { en_US: 'Enter API key', zh_Hans: '输入 API 密钥' }, + help: { en_US: 'Your API key', zh_Hans: '你的 API 密钥' }, + url: 'https://example.com/get-key', + options: [], + }, + ] + + const credentialSchemas = toolCredentialToFormSchemas(rawCredentials as Parameters[0]) + expect(credentialSchemas).toHaveLength(1) + expect(credentialSchemas[0].variable).toBe('api_key') + expect(credentialSchemas[0].required).toBe(true) + expect(credentialSchemas[0].type).toBe('secret-input') + }) + + it('processes trigger event parameters through the pipeline', () => { + const rawParams = [ + { + name: 'event_type', + label: { en_US: 'Event Type', zh_Hans: '事件类型' }, + type: 'select', + required: true, + default: 'push', + form: 'form', + description: { en_US: 'Type of event', zh_Hans: '事件类型' }, + options: [ + { value: 'push', label: { en_US: 'Push', zh_Hans: '推送' } }, + { value: 'pull', label: { en_US: 'Pull', zh_Hans: '拉取' } }, + ], + }, + ] + + const schemas = triggerEventParametersToFormSchemas(rawParams as unknown as Parameters[0]) + expect(schemas).toHaveLength(1) + expect(schemas[0].name).toBe('event_type') + expect(schemas[0].type).toBe('select') + expect(schemas[0].options).toHaveLength(2) + }) + }) + + describe('Type conversion integration', () => { + it('converts all supported types correctly', () => { + const typeConversions = [ + { input: 'string', expected: 'text-input' }, + { input: 'number', expected: 'number-input' }, + { input: 'boolean', expected: 'checkbox' }, + { input: 'select', expected: 'select' }, + { input: 'secret-input', expected: 'secret-input' }, + { input: 'file', expected: 'file' }, + { input: 'files', expected: 'files' }, + ] + + typeConversions.forEach(({ input, expected }) => { + expect(toType(input)).toBe(expected) + }) + }) + + it('returns the original type for unrecognized types', () => { + expect(toType('unknown-type')).toBe('unknown-type') + expect(toType('app-selector')).toBe('app-selector') + }) + }) + + describe('Value extraction integration', () => { + it('wraps values with getStructureValue and extracts inner value with getPlainValue', () => { + const plainInput = { query: 'test', limit: 10 } + const structured = getStructureValue(plainInput) + + expect(structured.query).toEqual({ value: 'test' }) + expect(structured.limit).toEqual({ value: 10 }) + + const objectStructured = { + query: { value: { type: 'constant', content: 'test search' } }, + limit: { value: { type: 'constant', content: 10 } }, + } + const extracted = getPlainValue(objectStructured) + expect(extracted.query).toEqual({ type: 'constant', content: 'test search' }) + expect(extracted.limit).toEqual({ type: 'constant', content: 10 }) + }) + + it('handles getConfiguredValue for workflow tool configurations', () => { + const formSchemas = [ + { variable: 'query', type: 'text-input', default: 'default-query' }, + { variable: 'format', type: 'select', default: 'json' }, + ] + + const configured = getConfiguredValue({}, formSchemas) + expect(configured).toBeDefined() + expect(configured.query).toBeDefined() + expect(configured.format).toBeDefined() + }) + + it('preserves existing values in getConfiguredValue', () => { + const formSchemas = [ + { variable: 'query', type: 'text-input', default: 'default-query' }, + ] + + const configured = getConfiguredValue({ query: 'my-existing-query' }, formSchemas) + expect(configured.query).toBe('my-existing-query') + }) + }) + + describe('Agent utilities integration', () => { + it('sorts agent thoughts and enriches with file infos end-to-end', () => { + const thoughts = [ + { id: 't3', position: 3, tool: 'search', files: ['f1'] }, + { id: 't1', position: 1, tool: 'analyze', files: [] }, + { id: 't2', position: 2, tool: 'summarize', files: ['f2'] }, + ] as Parameters[0] + + const messageFiles = [ + { id: 'f1', name: 'result.txt', type: 'document' }, + { id: 'f2', name: 'summary.pdf', type: 'document' }, + ] as Parameters[1] + + const sorted = sortAgentSorts(thoughts) + expect(sorted[0].id).toBe('t1') + expect(sorted[1].id).toBe('t2') + expect(sorted[2].id).toBe('t3') + + const enriched = addFileInfos(sorted, messageFiles) + expect(enriched[0].message_files).toBeUndefined() + expect(enriched[1].message_files).toHaveLength(1) + expect(enriched[1].message_files![0].id).toBe('f2') + expect(enriched[2].message_files).toHaveLength(1) + expect(enriched[2].message_files![0].id).toBe('f1') + }) + + it('handles null inputs gracefully in the pipeline', () => { + const sortedNull = sortAgentSorts(null as never) + expect(sortedNull).toBeNull() + + const enrichedNull = addFileInfos(null as never, []) + expect(enrichedNull).toBeNull() + + // addFileInfos with empty list and null files returns the mapped (empty) list + const enrichedEmptyList = addFileInfos([], null as never) + expect(enrichedEmptyList).toEqual([]) + }) + }) + + describe('Default value application', () => { + it('applies defaults only to empty fields, preserving user values', () => { + const userValues = { api_key: 'user-provided-key' } + const schemas = [ + { variable: 'api_key', type: 'text-input', default: 'default-key', name: 'api_key' }, + { variable: 'secret', type: 'secret-input', default: 'default-secret', name: 'secret' }, + ] + + const result = addDefaultValue(userValues, schemas) + expect(result.api_key).toBe('user-provided-key') + expect(result.secret).toBe('default-secret') + }) + + it('handles boolean type conversion in defaults', () => { + const schemas = [ + { variable: 'enabled', type: 'boolean', default: 'true', name: 'enabled' }, + ] + + const result = addDefaultValue({ enabled: 'true' }, schemas) + expect(result.enabled).toBe(true) + }) + }) +}) diff --git a/web/__tests__/tools/tool-provider-detail-flow.test.tsx b/web/__tests__/tools/tool-provider-detail-flow.test.tsx new file mode 100644 index 0000000000..0101f83f22 --- /dev/null +++ b/web/__tests__/tools/tool-provider-detail-flow.test.tsx @@ -0,0 +1,548 @@ +import type { Collection } from '@/app/components/tools/types' +/** + * Integration Test: Tool Provider Detail Flow + * + * Tests the integration between ProviderDetail, ConfigCredential, + * EditCustomToolModal, WorkflowToolModal, and service APIs. + * Verifies that different provider types render correctly and + * handle auth/edit/delete flows. + */ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CollectionType } from '@/app/components/tools/types' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + const map: Record = { + 'auth.authorized': 'Authorized', + 'auth.unauthorized': 'Set up credentials', + 'auth.setup': 'NEEDS SETUP', + 'createTool.editAction': 'Edit', + 'createTool.deleteToolConfirmTitle': 'Delete Tool', + 'createTool.deleteToolConfirmContent': 'Are you sure?', + 'createTool.toolInput.title': 'Tool Input', + 'createTool.toolInput.required': 'Required', + 'openInStudio': 'Open in Studio', + 'api.actionSuccess': 'Action succeeded', + } + if (key === 'detailPanel.actionNum') + return `${opts?.num ?? 0} actions` + if (key === 'includeToolNum') + return `${opts?.num ?? 0} actions` + return map[key] ?? key + }, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'en', +})) + +vi.mock('@/i18n-config/language', () => ({ + getLanguage: () => 'en_US', +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: true, + }), +})) + +const mockSetShowModelModal = vi.fn() +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowModelModal: mockSetShowModelModal, + }), +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + modelProviders: [ + { provider: 'model-provider-1', name: 'Model Provider 1' }, + ], + }), +})) + +const mockFetchBuiltInToolList = vi.fn().mockResolvedValue([ + { name: 'tool-1', description: { en_US: 'Tool 1' }, parameters: [] }, + { name: 'tool-2', description: { en_US: 'Tool 2' }, parameters: [] }, +]) +const mockFetchModelToolList = vi.fn().mockResolvedValue([]) +const mockFetchCustomToolList = vi.fn().mockResolvedValue([]) +const mockFetchCustomCollection = vi.fn().mockResolvedValue({ + credentials: { auth_type: 'none' }, + schema: '', + schema_type: 'openapi', +}) +const mockFetchWorkflowToolDetail = vi.fn().mockResolvedValue({ + workflow_app_id: 'app-123', + tool: { + parameters: [ + { name: 'query', llm_description: 'Search query', form: 'text', required: true, type: 'string' }, + ], + labels: ['search'], + }, +}) +const mockUpdateBuiltInToolCredential = vi.fn().mockResolvedValue({}) +const mockRemoveBuiltInToolCredential = vi.fn().mockResolvedValue({}) +const mockUpdateCustomCollection = vi.fn().mockResolvedValue({}) +const mockRemoveCustomCollection = vi.fn().mockResolvedValue({}) +const mockDeleteWorkflowTool = vi.fn().mockResolvedValue({}) +const mockSaveWorkflowToolProvider = vi.fn().mockResolvedValue({}) + +vi.mock('@/service/tools', () => ({ + fetchBuiltInToolList: (...args: unknown[]) => mockFetchBuiltInToolList(...args), + fetchModelToolList: (...args: unknown[]) => mockFetchModelToolList(...args), + fetchCustomToolList: (...args: unknown[]) => mockFetchCustomToolList(...args), + fetchCustomCollection: (...args: unknown[]) => mockFetchCustomCollection(...args), + fetchWorkflowToolDetail: (...args: unknown[]) => mockFetchWorkflowToolDetail(...args), + updateBuiltInToolCredential: (...args: unknown[]) => mockUpdateBuiltInToolCredential(...args), + removeBuiltInToolCredential: (...args: unknown[]) => mockRemoveBuiltInToolCredential(...args), + updateCustomCollection: (...args: unknown[]) => mockUpdateCustomCollection(...args), + removeCustomCollection: (...args: unknown[]) => mockRemoveCustomCollection(...args), + deleteWorkflowTool: (...args: unknown[]) => mockDeleteWorkflowTool(...args), + saveWorkflowToolProvider: (...args: unknown[]) => mockSaveWorkflowToolProvider(...args), + fetchBuiltInToolCredential: vi.fn().mockResolvedValue({}), + fetchBuiltInToolCredentialSchema: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/service/use-tools', () => ({ + useInvalidateAllWorkflowTools: () => vi.fn(), +})) + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), +})) + +vi.mock('@/utils/var', () => ({ + basePath: '', +})) + +vi.mock('@/app/components/base/drawer', () => ({ + default: ({ isOpen, children, onClose }: { isOpen: boolean, children: React.ReactNode, onClose: () => void }) => ( + isOpen + ? ( +
+ {children} + +
+ ) + : null + ), +})) + +vi.mock('@/app/components/base/confirm', () => ({ + default: ({ title, isShow, onConfirm, onCancel }: { + title: string + content: string + isShow: boolean + onConfirm: () => void + onCancel: () => void + }) => ( + isShow + ? ( +
+ {title} + + +
+ ) + : null + ), +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: vi.fn() }, +})) + +vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({ + LinkExternal02: () => , + Settings01: () => , +})) + +vi.mock('@remixicon/react', () => ({ + RiCloseLine: () => , +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({ + ConfigurationMethodEnum: { predefinedModel: 'predefined-model' }, +})) + +vi.mock('@/app/components/header/indicator', () => ({ + default: ({ color }: { color: string }) => , +})) + +vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ + default: ({ src }: { src: string }) =>
, +})) + +vi.mock('@/app/components/plugins/card/base/description', () => ({ + default: ({ text }: { text: string }) =>
{text}
, +})) + +vi.mock('@/app/components/plugins/card/base/org-info', () => ({ + default: ({ orgName, packageName }: { orgName: string, packageName: string }) => ( +
+ {orgName} + {' '} + / + {' '} + {packageName} +
+ ), +})) + +vi.mock('@/app/components/plugins/card/base/title', () => ({ + default: ({ title }: { title: string }) =>
{title}
, +})) + +vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({ + default: ({ onHide, onEdit, onRemove }: { onHide: () => void, onEdit: (data: unknown) => void, onRemove: () => void, payload: unknown }) => ( +
+ + + +
+ ), +})) + +vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({ + default: ({ onCancel, onSaved, onRemove }: { collection: Collection, onCancel: () => void, onSaved: (v: Record) => void, onRemove: () => void }) => ( +
+ + + +
+ ), +})) + +vi.mock('@/app/components/tools/workflow-tool', () => ({ + default: ({ onHide, onSave, onRemove }: { payload: unknown, onHide: () => void, onSave: (d: unknown) => void, onRemove: () => void }) => ( +
+ + + +
+ ), +})) + +vi.mock('@/app/components/tools/provider/tool-item', () => ({ + default: ({ tool }: { tool: { name: string } }) => ( +
{tool.name}
+ ), +})) + +const { default: ProviderDetail } = await import('@/app/components/tools/provider/detail') + +const makeCollection = (overrides: Partial = {}): Collection => ({ + id: 'test-collection', + name: 'test_collection', + author: 'Dify', + description: { en_US: 'Test collection description', zh_Hans: '测试集合描述' }, + icon: 'https://example.com/icon.png', + label: { en_US: 'Test Collection', zh_Hans: '测试集合' }, + type: CollectionType.builtIn, + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: [], + ...overrides, +}) + +const mockOnHide = vi.fn() +const mockOnRefreshData = vi.fn() + +describe('Tool Provider Detail Flow Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + }) + + describe('Built-in Provider', () => { + it('renders provider detail with title, author, and description', async () => { + const collection = makeCollection() + render() + + await waitFor(() => { + expect(screen.getByTestId('title')).toHaveTextContent('Test Collection') + expect(screen.getByTestId('org-info')).toHaveTextContent('Dify') + expect(screen.getByTestId('description')).toHaveTextContent('Test collection description') + }) + }) + + it('loads tool list from API on mount', async () => { + const collection = makeCollection() + render() + + await waitFor(() => { + expect(mockFetchBuiltInToolList).toHaveBeenCalledWith('test_collection') + }) + + await waitFor(() => { + expect(screen.getByTestId('tool-item-tool-1')).toBeInTheDocument() + expect(screen.getByTestId('tool-item-tool-2')).toBeInTheDocument() + }) + }) + + it('shows "Set up credentials" button when not authorized and needs auth', async () => { + const collection = makeCollection({ + allow_delete: true, + is_team_authorization: false, + }) + render() + + await waitFor(() => { + expect(screen.getByText('Set up credentials')).toBeInTheDocument() + }) + }) + + it('shows "Authorized" button when authorized', async () => { + const collection = makeCollection({ + allow_delete: true, + is_team_authorization: true, + }) + render() + + await waitFor(() => { + expect(screen.getByText('Authorized')).toBeInTheDocument() + expect(screen.getByTestId('indicator-green')).toBeInTheDocument() + }) + }) + + it('opens ConfigCredential when clicking auth button (built-in type)', async () => { + const collection = makeCollection({ + allow_delete: true, + is_team_authorization: false, + }) + render() + + await waitFor(() => { + expect(screen.getByText('Set up credentials')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('Set up credentials')) + await waitFor(() => { + expect(screen.getByTestId('config-credential')).toBeInTheDocument() + }) + }) + + it('saves credential and refreshes data', async () => { + const collection = makeCollection({ + allow_delete: true, + is_team_authorization: false, + }) + render() + + await waitFor(() => { + expect(screen.getByText('Set up credentials')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('Set up credentials')) + await waitFor(() => { + expect(screen.getByTestId('config-credential')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('cred-save')) + await waitFor(() => { + expect(mockUpdateBuiltInToolCredential).toHaveBeenCalledWith('test_collection', { api_key: 'test-key' }) + expect(mockOnRefreshData).toHaveBeenCalled() + }) + }) + + it('removes credential and refreshes data', async () => { + const collection = makeCollection({ + allow_delete: true, + is_team_authorization: false, + }) + render() + + await waitFor(() => { + fireEvent.click(screen.getByText('Set up credentials')) + }) + + await waitFor(() => { + expect(screen.getByTestId('config-credential')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('cred-remove')) + await waitFor(() => { + expect(mockRemoveBuiltInToolCredential).toHaveBeenCalledWith('test_collection') + expect(mockOnRefreshData).toHaveBeenCalled() + }) + }) + }) + + describe('Model Provider', () => { + it('opens model modal when clicking auth button for model type', async () => { + const collection = makeCollection({ + id: 'model-provider-1', + type: CollectionType.model, + allow_delete: true, + is_team_authorization: false, + }) + render() + + await waitFor(() => { + expect(screen.getByText('Set up credentials')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('Set up credentials')) + await waitFor(() => { + expect(mockSetShowModelModal).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + currentProvider: expect.objectContaining({ provider: 'model-provider-1' }), + }), + }), + ) + }) + }) + }) + + describe('Custom Provider', () => { + it('fetches custom collection details and shows edit button', async () => { + const collection = makeCollection({ + type: CollectionType.custom, + allow_delete: true, + }) + render() + + await waitFor(() => { + expect(mockFetchCustomCollection).toHaveBeenCalledWith('test_collection') + }) + + await waitFor(() => { + expect(screen.getByText('Edit')).toBeInTheDocument() + }) + }) + + it('opens edit modal and saves changes', async () => { + const collection = makeCollection({ + type: CollectionType.custom, + allow_delete: true, + }) + render() + + await waitFor(() => { + expect(screen.getByText('Edit')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('Edit')) + await waitFor(() => { + expect(screen.getByTestId('edit-custom-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('custom-modal-save')) + await waitFor(() => { + expect(mockUpdateCustomCollection).toHaveBeenCalled() + expect(mockOnRefreshData).toHaveBeenCalled() + }) + }) + + it('shows delete confirmation and removes collection', async () => { + const collection = makeCollection({ + type: CollectionType.custom, + allow_delete: true, + }) + render() + + await waitFor(() => { + expect(screen.getByText('Edit')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('Edit')) + await waitFor(() => { + expect(screen.getByTestId('edit-custom-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('custom-modal-remove')) + await waitFor(() => { + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument() + expect(screen.getByText('Delete Tool')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('confirm-ok')) + await waitFor(() => { + expect(mockRemoveCustomCollection).toHaveBeenCalledWith('test_collection') + expect(mockOnRefreshData).toHaveBeenCalled() + }) + }) + }) + + describe('Workflow Provider', () => { + it('fetches workflow tool detail and shows "Open in Studio" and "Edit" buttons', async () => { + const collection = makeCollection({ + type: CollectionType.workflow, + allow_delete: true, + }) + render() + + await waitFor(() => { + expect(mockFetchWorkflowToolDetail).toHaveBeenCalledWith('test-collection') + }) + + await waitFor(() => { + expect(screen.getByText('Open in Studio')).toBeInTheDocument() + expect(screen.getByText('Edit')).toBeInTheDocument() + }) + }) + + it('shows workflow tool parameters', async () => { + const collection = makeCollection({ + type: CollectionType.workflow, + allow_delete: true, + }) + render() + + await waitFor(() => { + expect(screen.getByText('query')).toBeInTheDocument() + expect(screen.getByText('string')).toBeInTheDocument() + expect(screen.getByText('Search query')).toBeInTheDocument() + }) + }) + + it('deletes workflow tool through confirmation dialog', async () => { + const collection = makeCollection({ + type: CollectionType.workflow, + allow_delete: true, + }) + render() + + await waitFor(() => { + expect(screen.getByText('Edit')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('Edit')) + await waitFor(() => { + expect(screen.getByTestId('workflow-tool-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('wf-modal-remove')) + await waitFor(() => { + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('confirm-ok')) + await waitFor(() => { + expect(mockDeleteWorkflowTool).toHaveBeenCalledWith('test-collection') + expect(mockOnRefreshData).toHaveBeenCalled() + }) + }) + }) + + describe('Drawer Interaction', () => { + it('calls onHide when closing the drawer', async () => { + const collection = makeCollection() + render() + + await waitFor(() => { + expect(screen.getByTestId('drawer')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('drawer-close')) + expect(mockOnHide).toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/plugins/hooks.spec.ts b/web/app/components/plugins/__tests__/hooks.spec.ts similarity index 70% rename from web/app/components/plugins/hooks.spec.ts rename to web/app/components/plugins/__tests__/hooks.spec.ts index 079d4de831..a8a8c43102 100644 --- a/web/app/components/plugins/hooks.spec.ts +++ b/web/app/components/plugins/__tests__/hooks.spec.ts @@ -1,59 +1,10 @@ import { renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PLUGIN_PAGE_TABS_MAP, useCategories, usePluginPageTabs, useTags } from './hooks' - -// Create mock translation function -const mockT = vi.fn((key: string, _options?: Record) => { - const translations: Record = { - 'tags.agent': 'Agent', - 'tags.rag': 'RAG', - 'tags.search': 'Search', - 'tags.image': 'Image', - 'tags.videos': 'Videos', - 'tags.weather': 'Weather', - 'tags.finance': 'Finance', - 'tags.design': 'Design', - 'tags.travel': 'Travel', - 'tags.social': 'Social', - 'tags.news': 'News', - 'tags.medical': 'Medical', - 'tags.productivity': 'Productivity', - 'tags.education': 'Education', - 'tags.business': 'Business', - 'tags.entertainment': 'Entertainment', - 'tags.utilities': 'Utilities', - 'tags.other': 'Other', - 'category.models': 'Models', - 'category.tools': 'Tools', - 'category.datasources': 'Datasources', - 'category.agents': 'Agents', - 'category.extensions': 'Extensions', - 'category.bundles': 'Bundles', - 'category.triggers': 'Triggers', - 'categorySingle.model': 'Model', - 'categorySingle.tool': 'Tool', - 'categorySingle.datasource': 'Datasource', - 'categorySingle.agent': 'Agent', - 'categorySingle.extension': 'Extension', - 'categorySingle.bundle': 'Bundle', - 'categorySingle.trigger': 'Trigger', - 'menus.plugins': 'Plugins', - 'menus.exploreMarketplace': 'Explore Marketplace', - } - return translations[key] || key -}) - -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: mockT, - }), -})) +import { PLUGIN_PAGE_TABS_MAP, useCategories, usePluginPageTabs, useTags } from '../hooks' describe('useTags', () => { beforeEach(() => { vi.clearAllMocks() - mockT.mockClear() }) describe('Rendering', () => { @@ -65,13 +16,12 @@ describe('useTags', () => { expect(result.current.tags.length).toBeGreaterThan(0) }) - it('should call translation function for each tag', () => { - renderHook(() => useTags()) + it('should return tags with translated labels', () => { + const { result } = renderHook(() => useTags()) - // Verify t() was called for tag translations - expect(mockT).toHaveBeenCalled() - const tagCalls = mockT.mock.calls.filter(call => call[0].startsWith('tags.')) - expect(tagCalls.length).toBeGreaterThan(0) + result.current.tags.forEach((tag) => { + expect(tag.label).toBe(`pluginTags.tags.${tag.name}`) + }) }) it('should return tags with name and label properties', () => { @@ -99,7 +49,7 @@ describe('useTags', () => { expect(result.current.tagsMap.agent).toBeDefined() expect(result.current.tagsMap.agent.name).toBe('agent') - expect(result.current.tagsMap.agent.label).toBe('Agent') + expect(result.current.tagsMap.agent.label).toBe('pluginTags.tags.agent') }) it('should contain all tags from tags array', () => { @@ -116,9 +66,8 @@ describe('useTags', () => { it('should return label for existing tag', () => { const { result } = renderHook(() => useTags()) - // Test existing tags - this covers the branch where tagsMap[name] exists - expect(result.current.getTagLabel('agent')).toBe('Agent') - expect(result.current.getTagLabel('search')).toBe('Search') + expect(result.current.getTagLabel('agent')).toBe('pluginTags.tags.agent') + expect(result.current.getTagLabel('search')).toBe('pluginTags.tags.search') }) it('should return name for non-existing tag', () => { @@ -132,11 +81,9 @@ describe('useTags', () => { it('should cover both branches of getTagLabel conditional', () => { const { result } = renderHook(() => useTags()) - // Branch 1: tag exists in tagsMap - returns label const existingTagResult = result.current.getTagLabel('rag') - expect(existingTagResult).toBe('RAG') + expect(existingTagResult).toBe('pluginTags.tags.rag') - // Branch 2: tag does not exist in tagsMap - returns name itself const nonExistingTagResult = result.current.getTagLabel('unknown-tag-xyz') expect(nonExistingTagResult).toBe('unknown-tag-xyz') }) @@ -150,23 +97,22 @@ describe('useTags', () => { it('should return correct labels for all predefined tags', () => { const { result } = renderHook(() => useTags()) - // Test all predefined tags - expect(result.current.getTagLabel('rag')).toBe('RAG') - expect(result.current.getTagLabel('image')).toBe('Image') - expect(result.current.getTagLabel('videos')).toBe('Videos') - expect(result.current.getTagLabel('weather')).toBe('Weather') - expect(result.current.getTagLabel('finance')).toBe('Finance') - expect(result.current.getTagLabel('design')).toBe('Design') - expect(result.current.getTagLabel('travel')).toBe('Travel') - expect(result.current.getTagLabel('social')).toBe('Social') - expect(result.current.getTagLabel('news')).toBe('News') - expect(result.current.getTagLabel('medical')).toBe('Medical') - expect(result.current.getTagLabel('productivity')).toBe('Productivity') - expect(result.current.getTagLabel('education')).toBe('Education') - expect(result.current.getTagLabel('business')).toBe('Business') - expect(result.current.getTagLabel('entertainment')).toBe('Entertainment') - expect(result.current.getTagLabel('utilities')).toBe('Utilities') - expect(result.current.getTagLabel('other')).toBe('Other') + expect(result.current.getTagLabel('rag')).toBe('pluginTags.tags.rag') + expect(result.current.getTagLabel('image')).toBe('pluginTags.tags.image') + expect(result.current.getTagLabel('videos')).toBe('pluginTags.tags.videos') + expect(result.current.getTagLabel('weather')).toBe('pluginTags.tags.weather') + expect(result.current.getTagLabel('finance')).toBe('pluginTags.tags.finance') + expect(result.current.getTagLabel('design')).toBe('pluginTags.tags.design') + expect(result.current.getTagLabel('travel')).toBe('pluginTags.tags.travel') + expect(result.current.getTagLabel('social')).toBe('pluginTags.tags.social') + expect(result.current.getTagLabel('news')).toBe('pluginTags.tags.news') + expect(result.current.getTagLabel('medical')).toBe('pluginTags.tags.medical') + expect(result.current.getTagLabel('productivity')).toBe('pluginTags.tags.productivity') + expect(result.current.getTagLabel('education')).toBe('pluginTags.tags.education') + expect(result.current.getTagLabel('business')).toBe('pluginTags.tags.business') + expect(result.current.getTagLabel('entertainment')).toBe('pluginTags.tags.entertainment') + expect(result.current.getTagLabel('utilities')).toBe('pluginTags.tags.utilities') + expect(result.current.getTagLabel('other')).toBe('pluginTags.tags.other') }) it('should handle empty string tag name', () => { @@ -255,27 +201,27 @@ describe('useCategories', () => { it('should use plural labels when isSingle is false', () => { const { result } = renderHook(() => useCategories(false)) - expect(result.current.categoriesMap.tool.label).toBe('Tools') + expect(result.current.categoriesMap.tool.label).toBe('plugin.category.tools') }) it('should use plural labels when isSingle is undefined', () => { const { result } = renderHook(() => useCategories()) - expect(result.current.categoriesMap.tool.label).toBe('Tools') + expect(result.current.categoriesMap.tool.label).toBe('plugin.category.tools') }) it('should use singular labels when isSingle is true', () => { const { result } = renderHook(() => useCategories(true)) - expect(result.current.categoriesMap.tool.label).toBe('Tool') + expect(result.current.categoriesMap.tool.label).toBe('plugin.categorySingle.tool') }) it('should handle agent category specially', () => { const { result: resultPlural } = renderHook(() => useCategories(false)) const { result: resultSingle } = renderHook(() => useCategories(true)) - expect(resultPlural.current.categoriesMap['agent-strategy'].label).toBe('Agents') - expect(resultSingle.current.categoriesMap['agent-strategy'].label).toBe('Agent') + expect(resultPlural.current.categoriesMap['agent-strategy'].label).toBe('plugin.category.agents') + expect(resultSingle.current.categoriesMap['agent-strategy'].label).toBe('plugin.categorySingle.agent') }) }) @@ -298,7 +244,6 @@ describe('useCategories', () => { describe('usePluginPageTabs', () => { beforeEach(() => { vi.clearAllMocks() - mockT.mockClear() }) describe('Rendering', () => { @@ -326,12 +271,11 @@ describe('usePluginPageTabs', () => { }) }) - it('should call translation function for tab texts', () => { - renderHook(() => usePluginPageTabs()) + it('should return tabs with translated texts', () => { + const { result } = renderHook(() => usePluginPageTabs()) - // Verify t() was called for menu translations - expect(mockT).toHaveBeenCalledWith('menus.plugins', { ns: 'common' }) - expect(mockT).toHaveBeenCalledWith('menus.exploreMarketplace', { ns: 'common' }) + expect(result.current[0].text).toBe('common.menus.plugins') + expect(result.current[1].text).toBe('common.menus.exploreMarketplace') }) }) @@ -342,7 +286,7 @@ describe('usePluginPageTabs', () => { const pluginsTab = result.current.find(tab => tab.value === PLUGIN_PAGE_TABS_MAP.plugins) expect(pluginsTab).toBeDefined() expect(pluginsTab?.value).toBe('plugins') - expect(pluginsTab?.text).toBe('Plugins') + expect(pluginsTab?.text).toBe('common.menus.plugins') }) it('should have marketplace tab with correct value', () => { @@ -351,7 +295,7 @@ describe('usePluginPageTabs', () => { const marketplaceTab = result.current.find(tab => tab.value === PLUGIN_PAGE_TABS_MAP.marketplace) expect(marketplaceTab).toBeDefined() expect(marketplaceTab?.value).toBe('discover') - expect(marketplaceTab?.text).toBe('Explore Marketplace') + expect(marketplaceTab?.text).toBe('common.menus.exploreMarketplace') }) }) @@ -360,14 +304,14 @@ describe('usePluginPageTabs', () => { const { result } = renderHook(() => usePluginPageTabs()) expect(result.current[0].value).toBe('plugins') - expect(result.current[0].text).toBe('Plugins') + expect(result.current[0].text).toBe('common.menus.plugins') }) it('should return marketplace tab as second tab', () => { const { result } = renderHook(() => usePluginPageTabs()) expect(result.current[1].value).toBe('discover') - expect(result.current[1].text).toBe('Explore Marketplace') + expect(result.current[1].text).toBe('common.menus.exploreMarketplace') }) }) diff --git a/web/app/components/plugins/__tests__/utils.spec.ts b/web/app/components/plugins/__tests__/utils.spec.ts new file mode 100644 index 0000000000..0dc166b175 --- /dev/null +++ b/web/app/components/plugins/__tests__/utils.spec.ts @@ -0,0 +1,50 @@ +import type { TagKey } from '../constants' +import { describe, expect, it } from 'vitest' +import { PluginCategoryEnum } from '../types' +import { getValidCategoryKeys, getValidTagKeys } from '../utils' + +describe('plugins/utils', () => { + describe('getValidTagKeys', () => { + it('returns only valid tag keys from the predefined set', () => { + const input = ['agent', 'rag', 'invalid-tag', 'search'] as TagKey[] + const result = getValidTagKeys(input) + expect(result).toEqual(['agent', 'rag', 'search']) + }) + + it('returns empty array when no valid tags', () => { + const result = getValidTagKeys(['foo', 'bar'] as unknown as TagKey[]) + expect(result).toEqual([]) + }) + + it('returns empty array for empty input', () => { + expect(getValidTagKeys([])).toEqual([]) + }) + + it('preserves all valid tags when all are valid', () => { + const input: TagKey[] = ['agent', 'rag', 'search', 'image'] + const result = getValidTagKeys(input) + expect(result).toEqual(input) + }) + }) + + describe('getValidCategoryKeys', () => { + it('returns matching category for valid key', () => { + expect(getValidCategoryKeys(PluginCategoryEnum.model)).toBe(PluginCategoryEnum.model) + expect(getValidCategoryKeys(PluginCategoryEnum.tool)).toBe(PluginCategoryEnum.tool) + expect(getValidCategoryKeys(PluginCategoryEnum.agent)).toBe(PluginCategoryEnum.agent) + expect(getValidCategoryKeys('bundle')).toBe('bundle') + }) + + it('returns undefined for invalid category', () => { + expect(getValidCategoryKeys('nonexistent')).toBeUndefined() + }) + + it('returns undefined for undefined input', () => { + expect(getValidCategoryKeys(undefined)).toBeUndefined() + }) + + it('returns undefined for empty string', () => { + expect(getValidCategoryKeys('')).toBeUndefined() + }) + }) +}) diff --git a/web/app/components/plugins/base/__tests__/deprecation-notice.spec.tsx b/web/app/components/plugins/base/__tests__/deprecation-notice.spec.tsx new file mode 100644 index 0000000000..42616f3138 --- /dev/null +++ b/web/app/components/plugins/base/__tests__/deprecation-notice.spec.tsx @@ -0,0 +1,92 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import DeprecationNotice from '../deprecation-notice' + +vi.mock('next/link', () => ({ + default: ({ children, href }: { children: React.ReactNode, href: string }) => ( + {children} + ), +})) + +describe('DeprecationNotice', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + cleanup() + }) + + it('returns null when status is not "deleted"', () => { + const { container } = render( + , + ) + expect(container.firstChild).toBeNull() + }) + + it('renders deprecation notice when status is "deleted"', () => { + render( + , + ) + expect(screen.getByText('plugin.detailPanel.deprecation.noReason')).toBeInTheDocument() + }) + + it('renders with valid reason and alternative plugin', () => { + render( + , + ) + expect(screen.getByText('detailPanel.deprecation.fullMessage')).toBeInTheDocument() + }) + + it('renders only reason without alternative plugin', () => { + render( + , + ) + expect(screen.getByText(/plugin\.detailPanel\.deprecation\.onlyReason/)).toBeInTheDocument() + }) + + it('renders no-reason message for invalid reason', () => { + render( + , + ) + expect(screen.getByText('plugin.detailPanel.deprecation.noReason')).toBeInTheDocument() + }) + + it('applies custom className', () => { + const { container } = render( + , + ) + expect((container.firstChild as HTMLElement).className).toContain('my-custom-class') + }) +}) diff --git a/web/app/components/plugins/base/__tests__/key-value-item.spec.tsx b/web/app/components/plugins/base/__tests__/key-value-item.spec.tsx new file mode 100644 index 0000000000..4b3869e616 --- /dev/null +++ b/web/app/components/plugins/base/__tests__/key-value-item.spec.tsx @@ -0,0 +1,59 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import KeyValueItem from '../key-value-item' + +vi.mock('../../../base/icons/src/vender/line/files', () => ({ + CopyCheck: () => , +})) + +vi.mock('../../../base/tooltip', () => ({ + default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => ( +
{children}
+ ), +})) + +vi.mock('@/app/components/base/action-button', () => ({ + default: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( + + ), +})) + +const mockCopy = vi.fn() +vi.mock('copy-to-clipboard', () => ({ + default: (...args: unknown[]) => mockCopy(...args), +})) + +describe('KeyValueItem', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + cleanup() + }) + + it('renders label and value', () => { + render() + expect(screen.getByText('ID')).toBeInTheDocument() + expect(screen.getByText('abc-123')).toBeInTheDocument() + }) + + it('renders maskedValue instead of value when provided', () => { + render() + expect(screen.getByText('sk-***')).toBeInTheDocument() + expect(screen.queryByText('sk-secret')).not.toBeInTheDocument() + }) + + it('copies actual value (not masked) when copy button is clicked', () => { + render() + fireEvent.click(screen.getByTestId('action-button')) + expect(mockCopy).toHaveBeenCalledWith('sk-secret') + }) + + it('renders copy tooltip', () => { + render() + expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'common.operation.copy') + }) +}) diff --git a/web/app/components/plugins/base/badges/icon-with-tooltip.spec.tsx b/web/app/components/plugins/base/badges/__tests__/icon-with-tooltip.spec.tsx similarity index 99% rename from web/app/components/plugins/base/badges/icon-with-tooltip.spec.tsx rename to web/app/components/plugins/base/badges/__tests__/icon-with-tooltip.spec.tsx index f1261d2984..e24aa5a873 100644 --- a/web/app/components/plugins/base/badges/icon-with-tooltip.spec.tsx +++ b/web/app/components/plugins/base/badges/__tests__/icon-with-tooltip.spec.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Theme } from '@/types/app' -import IconWithTooltip from './icon-with-tooltip' +import IconWithTooltip from '../icon-with-tooltip' // Mock Tooltip component vi.mock('@/app/components/base/tooltip', () => ({ diff --git a/web/app/components/plugins/base/badges/partner.spec.tsx b/web/app/components/plugins/base/badges/__tests__/partner.spec.tsx similarity index 97% rename from web/app/components/plugins/base/badges/partner.spec.tsx rename to web/app/components/plugins/base/badges/__tests__/partner.spec.tsx index 3bdd2508fc..1685564018 100644 --- a/web/app/components/plugins/base/badges/partner.spec.tsx +++ b/web/app/components/plugins/base/badges/__tests__/partner.spec.tsx @@ -2,7 +2,7 @@ import type { ComponentProps } from 'react' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Theme } from '@/types/app' -import Partner from './partner' +import Partner from '../partner' // Mock useTheme hook const mockUseTheme = vi.fn() @@ -11,9 +11,9 @@ vi.mock('@/hooks/use-theme', () => ({ })) // Mock IconWithTooltip to directly test Partner's behavior -type IconWithTooltipProps = ComponentProps +type IconWithTooltipProps = ComponentProps const mockIconWithTooltip = vi.fn() -vi.mock('./icon-with-tooltip', () => ({ +vi.mock('../icon-with-tooltip', () => ({ default: (props: IconWithTooltipProps) => { mockIconWithTooltip(props) const { theme, BadgeIconLight, BadgeIconDark, className, popupContent } = props diff --git a/web/app/components/plugins/base/badges/__tests__/verified.spec.tsx b/web/app/components/plugins/base/badges/__tests__/verified.spec.tsx new file mode 100644 index 0000000000..809922a801 --- /dev/null +++ b/web/app/components/plugins/base/badges/__tests__/verified.spec.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/app/components/base/icons/src/public/plugins/VerifiedDark', () => ({ + default: () => , +})) + +vi.mock('@/app/components/base/icons/src/public/plugins/VerifiedLight', () => ({ + default: () => , +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: 'light' }), +})) + +vi.mock('../icon-with-tooltip', () => ({ + default: ({ popupContent, BadgeIconLight, BadgeIconDark, theme }: { + popupContent: string + BadgeIconLight: React.FC + BadgeIconDark: React.FC + theme: string + [key: string]: unknown + }) => ( +
+ {theme === 'light' ? : } +
+ ), +})) + +describe('Verified', () => { + let Verified: (typeof import('../verified'))['default'] + + beforeEach(async () => { + vi.clearAllMocks() + const mod = await import('../verified') + Verified = mod.default + }) + + it('should render with tooltip text', () => { + render() + + const tooltip = screen.getByTestId('icon-with-tooltip') + expect(tooltip).toHaveAttribute('data-popup', 'Verified Plugin') + }) + + it('should render light theme icon by default', () => { + render() + + expect(screen.getByTestId('verified-light')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/card/__tests__/card-more-info.spec.tsx b/web/app/components/plugins/card/__tests__/card-more-info.spec.tsx new file mode 100644 index 0000000000..769abf5f89 --- /dev/null +++ b/web/app/components/plugins/card/__tests__/card-more-info.spec.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import CardMoreInfo from '../card-more-info' + +vi.mock('../base/download-count', () => ({ + default: ({ downloadCount }: { downloadCount: number }) => ( + {downloadCount} + ), +})) + +describe('CardMoreInfo', () => { + it('renders tags with # prefix', () => { + render() + expect(screen.getByText('search')).toBeInTheDocument() + expect(screen.getByText('agent')).toBeInTheDocument() + // # prefixes + const hashmarks = screen.getAllByText('#') + expect(hashmarks).toHaveLength(2) + }) + + it('renders download count when provided', () => { + render() + expect(screen.getByTestId('download-count')).toHaveTextContent('1000') + }) + + it('does not render download count when undefined', () => { + render() + expect(screen.queryByTestId('download-count')).not.toBeInTheDocument() + }) + + it('renders separator between download count and tags', () => { + render() + expect(screen.getByText('·')).toBeInTheDocument() + }) + + it('does not render separator when no tags', () => { + render() + expect(screen.queryByText('·')).not.toBeInTheDocument() + }) + + it('does not render separator when no download count', () => { + render() + expect(screen.queryByText('·')).not.toBeInTheDocument() + }) + + it('handles empty tags array', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/card/__tests__/index.spec.tsx b/web/app/components/plugins/card/__tests__/index.spec.tsx new file mode 100644 index 0000000000..aef89bd371 --- /dev/null +++ b/web/app/components/plugins/card/__tests__/index.spec.tsx @@ -0,0 +1,589 @@ +import type { Plugin } from '../../types' +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum } from '../../types' +import Card from '../index' + +let mockTheme = 'light' +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: mockTheme }), +})) + +vi.mock('@/i18n-config', () => ({ + renderI18nObject: (obj: Record, locale: string) => { + return obj?.[locale] || obj?.['en-US'] || '' + }, +})) + +vi.mock('@/i18n-config/language', () => ({ + getLanguage: (locale: string) => locale || 'en-US', +})) + +const mockCategoriesMap: Record = { + 'tool': { label: 'Tool' }, + 'model': { label: 'Model' }, + 'extension': { label: 'Extension' }, + 'agent-strategy': { label: 'Agent' }, + 'datasource': { label: 'Datasource' }, + 'trigger': { label: 'Trigger' }, + 'bundle': { label: 'Bundle' }, +} + +vi.mock('../../hooks', () => ({ + useCategories: () => ({ + categoriesMap: mockCategoriesMap, + }), +})) + +vi.mock('@/utils/format', () => ({ + formatNumber: (num: number) => num.toLocaleString(), +})) + +vi.mock('@/utils/mcp', () => ({ + shouldUseMcpIcon: (src: unknown) => typeof src === 'object' && src !== null && (src as { content?: string })?.content === '🔗', +})) + +vi.mock('@/app/components/base/app-icon', () => ({ + default: ({ icon, background, innerIcon, size, iconType }: { + icon?: string + background?: string + innerIcon?: React.ReactNode + size?: string + iconType?: string + }) => ( +
+ {!!innerIcon &&
{innerIcon}
} +
+ ), +})) + +vi.mock('@/app/components/base/icons/src/vender/other', () => ({ + Mcp: ({ className }: { className?: string }) => ( +
MCP
+ ), + Group: ({ className }: { className?: string }) => ( +
Group
+ ), +})) + +vi.mock('../../../base/icons/src/vender/plugin', () => ({ + LeftCorner: ({ className }: { className?: string }) => ( +
LeftCorner
+ ), +})) + +vi.mock('../../base/badges/partner', () => ({ + default: ({ className, text }: { className?: string, text?: string }) => ( +
Partner
+ ), +})) + +vi.mock('../../base/badges/verified', () => ({ + default: ({ className, text }: { className?: string, text?: string }) => ( +
Verified
+ ), +})) + +vi.mock('@/app/components/base/skeleton', () => ({ + SkeletonContainer: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SkeletonPoint: () =>
, + SkeletonRectangle: ({ className }: { className?: string }) => ( +
+ ), + SkeletonRow: ({ children, className }: { children: React.ReactNode, className?: string }) => ( +
{children}
+ ), +})) + +const createMockPlugin = (overrides?: Partial): Plugin => ({ + type: 'plugin', + org: 'test-org', + name: 'test-plugin', + plugin_id: 'plugin-123', + version: '1.0.0', + latest_version: '1.0.0', + latest_package_identifier: 'test-org/test-plugin:1.0.0', + icon: '/test-icon.png', + verified: false, + label: { 'en-US': 'Test Plugin' }, + brief: { 'en-US': 'Test plugin description' }, + description: { 'en-US': 'Full test plugin description' }, + introduction: 'Test plugin introduction', + repository: 'https://github.com/test/plugin', + category: PluginCategoryEnum.tool, + install_count: 1000, + endpoint: { settings: [] }, + tags: [{ name: 'search' }], + badges: [], + verification: { authorized_category: 'community' }, + from: 'marketplace', + ...overrides, +}) + +describe('Card', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render without crashing', () => { + const plugin = createMockPlugin() + render() + + expect(document.body).toBeInTheDocument() + }) + + it('should render plugin title from label', () => { + const plugin = createMockPlugin({ + label: { 'en-US': 'My Plugin Title' }, + }) + + render() + + expect(screen.getByText('My Plugin Title')).toBeInTheDocument() + }) + + it('should render plugin description from brief', () => { + const plugin = createMockPlugin({ + brief: { 'en-US': 'This is a brief description' }, + }) + + render() + + expect(screen.getByText('This is a brief description')).toBeInTheDocument() + }) + + it('should render organization info with org name and package name', () => { + const plugin = createMockPlugin({ + org: 'my-org', + name: 'my-plugin', + }) + + render() + + expect(screen.getByText('my-org')).toBeInTheDocument() + expect(screen.getByText('my-plugin')).toBeInTheDocument() + }) + + it('should render plugin icon', () => { + const plugin = createMockPlugin({ + icon: '/custom-icon.png', + }) + + const { container } = render() + + // Check for background image style on icon element + const iconElement = container.querySelector('[style*="background-image"]') + expect(iconElement).toBeInTheDocument() + }) + + it('should use icon_dark when theme is dark and icon_dark is provided', () => { + // Set theme to dark + mockTheme = 'dark' + + const plugin = createMockPlugin({ + icon: '/light-icon.png', + icon_dark: '/dark-icon.png', + }) + + const { container } = render() + + // Check that icon uses dark icon + const iconElement = container.querySelector('[style*="background-image"]') + expect(iconElement).toBeInTheDocument() + expect(iconElement).toHaveStyle({ backgroundImage: 'url(/dark-icon.png)' }) + + // Reset theme + mockTheme = 'light' + }) + + it('should use icon when theme is dark but icon_dark is not provided', () => { + mockTheme = 'dark' + + const plugin = createMockPlugin({ + icon: '/light-icon.png', + }) + + const { container } = render() + + // Should fallback to light icon + const iconElement = container.querySelector('[style*="background-image"]') + expect(iconElement).toBeInTheDocument() + expect(iconElement).toHaveStyle({ backgroundImage: 'url(/light-icon.png)' }) + + mockTheme = 'light' + }) + + it('should render corner mark with category label', () => { + const plugin = createMockPlugin({ + category: PluginCategoryEnum.tool, + }) + + render() + + expect(screen.getByText('Tool')).toBeInTheDocument() + }) + }) + + // ================================ + // Props Testing + // ================================ + describe('Props', () => { + it('should apply custom className', () => { + const plugin = createMockPlugin() + const { container } = render( + , + ) + + expect(container.querySelector('.custom-class')).toBeInTheDocument() + }) + + it('should hide corner mark when hideCornerMark is true', () => { + const plugin = createMockPlugin({ + category: PluginCategoryEnum.tool, + }) + + render() + + expect(screen.queryByTestId('left-corner')).not.toBeInTheDocument() + }) + + it('should show corner mark by default', () => { + const plugin = createMockPlugin() + + render() + + expect(screen.getByTestId('left-corner')).toBeInTheDocument() + }) + + it('should pass installed prop to Icon component', () => { + const plugin = createMockPlugin() + const { container } = render() + + expect(container.querySelector('.bg-state-success-solid')).toBeInTheDocument() + }) + + it('should pass installFailed prop to Icon component', () => { + const plugin = createMockPlugin() + const { container } = render() + + expect(container.querySelector('.bg-state-destructive-solid')).toBeInTheDocument() + }) + + it('should render footer when provided', () => { + const plugin = createMockPlugin() + render( + Footer Content
} />, + ) + + expect(screen.getByTestId('custom-footer')).toBeInTheDocument() + expect(screen.getByText('Footer Content')).toBeInTheDocument() + }) + + it('should render titleLeft when provided', () => { + const plugin = createMockPlugin() + render( + v1.0} />, + ) + + expect(screen.getByTestId('title-left')).toBeInTheDocument() + }) + + it('should use custom descriptionLineRows', () => { + const plugin = createMockPlugin() + + const { container } = render( + , + ) + + // Check for h-4 truncate class when descriptionLineRows is 1 + expect(container.querySelector('.h-4.truncate')).toBeInTheDocument() + }) + + it('should use default descriptionLineRows of 2', () => { + const plugin = createMockPlugin() + + const { container } = render() + + // Check for h-8 line-clamp-2 class when descriptionLineRows is 2 (default) + expect(container.querySelector('.h-8.line-clamp-2')).toBeInTheDocument() + }) + }) + + // ================================ + // Loading State Tests + // ================================ + describe('Loading State', () => { + it('should render Placeholder when isLoading is true', () => { + const plugin = createMockPlugin() + + render() + + // Should render skeleton elements + expect(screen.getByTestId('skeleton-container')).toBeInTheDocument() + }) + + it('should render loadingFileName in Placeholder', () => { + const plugin = createMockPlugin() + + render() + + expect(screen.getByText('my-plugin.zip')).toBeInTheDocument() + }) + + it('should not render card content when loading', () => { + const plugin = createMockPlugin({ + label: { 'en-US': 'Plugin Title' }, + }) + + render() + + // Plugin content should not be visible during loading + expect(screen.queryByText('Plugin Title')).not.toBeInTheDocument() + }) + + it('should not render loading state by default', () => { + const plugin = createMockPlugin() + + render() + + expect(screen.queryByTestId('skeleton-container')).not.toBeInTheDocument() + }) + }) + + // ================================ + // Badges Tests + // ================================ + describe('Badges', () => { + it('should render Partner badge when badges includes partner', () => { + const plugin = createMockPlugin({ + badges: ['partner'], + }) + + render() + + expect(screen.getByTestId('partner-badge')).toBeInTheDocument() + }) + + it('should render Verified badge when verified is true', () => { + const plugin = createMockPlugin({ + verified: true, + }) + + render() + + expect(screen.getByTestId('verified-badge')).toBeInTheDocument() + }) + + it('should render both Partner and Verified badges', () => { + const plugin = createMockPlugin({ + badges: ['partner'], + verified: true, + }) + + render() + + expect(screen.getByTestId('partner-badge')).toBeInTheDocument() + expect(screen.getByTestId('verified-badge')).toBeInTheDocument() + }) + + it('should not render Partner badge when badges is empty', () => { + const plugin = createMockPlugin({ + badges: [], + }) + + render() + + expect(screen.queryByTestId('partner-badge')).not.toBeInTheDocument() + }) + + it('should not render Verified badge when verified is false', () => { + const plugin = createMockPlugin({ + verified: false, + }) + + render() + + expect(screen.queryByTestId('verified-badge')).not.toBeInTheDocument() + }) + + it('should handle undefined badges gracefully', () => { + const plugin = createMockPlugin() + // @ts-expect-error - Testing undefined badges + plugin.badges = undefined + + render() + + expect(screen.queryByTestId('partner-badge')).not.toBeInTheDocument() + }) + }) + + // ================================ + // Limited Install Warning Tests + // ================================ + describe('Limited Install Warning', () => { + it('should render warning when limitedInstall is true', () => { + const plugin = createMockPlugin() + + const { container } = render() + + expect(container.querySelector('.text-text-warning-secondary')).toBeInTheDocument() + }) + + it('should not render warning by default', () => { + const plugin = createMockPlugin() + + const { container } = render() + + expect(container.querySelector('.text-text-warning-secondary')).not.toBeInTheDocument() + }) + + it('should apply limited padding when limitedInstall is true', () => { + const plugin = createMockPlugin() + + const { container } = render() + + expect(container.querySelector('.pb-1')).toBeInTheDocument() + }) + }) + + // ================================ + // Category Type Tests + // ================================ + describe('Category Types', () => { + it('should display bundle label for bundle type', () => { + const plugin = createMockPlugin({ + type: 'bundle', + category: PluginCategoryEnum.tool, + }) + + render() + + // For bundle type, should show 'Bundle' instead of category + expect(screen.getByText('Bundle')).toBeInTheDocument() + }) + + it('should display category label for non-bundle types', () => { + const plugin = createMockPlugin({ + type: 'plugin', + category: PluginCategoryEnum.model, + }) + + render() + + expect(screen.getByText('Model')).toBeInTheDocument() + }) + }) + + // ================================ + // Memoization Tests + // ================================ + describe('Memoization', () => { + it('should be memoized with React.memo', () => { + // Card is wrapped with React.memo + expect(Card).toBeDefined() + // The component should have the memo display name characteristic + expect(typeof Card).toBe('object') + }) + + it('should not re-render when props are the same', () => { + const plugin = createMockPlugin() + const renderCount = vi.fn() + + const TestWrapper = ({ p }: { p: Plugin }) => { + renderCount() + return + } + + const { rerender } = render() + expect(renderCount).toHaveBeenCalledTimes(1) + + // Re-render with same plugin reference + rerender() + expect(renderCount).toHaveBeenCalledTimes(2) + }) + }) + + // ================================ + // Edge Cases Tests + // ================================ + describe('Edge Cases', () => { + it('should handle empty label object', () => { + const plugin = createMockPlugin({ + label: {}, + }) + + render() + + // Should render without crashing + expect(document.body).toBeInTheDocument() + }) + + it('should handle empty brief object', () => { + const plugin = createMockPlugin({ + brief: {}, + }) + + render() + + expect(document.body).toBeInTheDocument() + }) + + it('should handle undefined label', () => { + const plugin = createMockPlugin() + // @ts-expect-error - Testing undefined label + plugin.label = undefined + + render() + + expect(document.body).toBeInTheDocument() + }) + + it('should handle special characters in plugin name', () => { + const plugin = createMockPlugin({ + name: 'plugin-with-special-chars!@#$%', + org: 'org', + }) + + render() + + expect(screen.getByText('plugin-with-special-chars!@#$%')).toBeInTheDocument() + }) + + it('should handle very long title', () => { + const longTitle = 'A'.repeat(500) + const plugin = createMockPlugin({ + label: { 'en-US': longTitle }, + }) + + const { container } = render() + + // Should have truncate class for long text + expect(container.querySelector('.truncate')).toBeInTheDocument() + }) + + it('should handle very long description', () => { + const longDescription = 'B'.repeat(1000) + const plugin = createMockPlugin({ + brief: { 'en-US': longDescription }, + }) + + const { container } = render() + + // Should have line-clamp class for long text + expect(container.querySelector('.line-clamp-2')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/card/base/__tests__/card-icon.spec.tsx b/web/app/components/plugins/card/base/__tests__/card-icon.spec.tsx new file mode 100644 index 0000000000..7eacd1c5ee --- /dev/null +++ b/web/app/components/plugins/card/base/__tests__/card-icon.spec.tsx @@ -0,0 +1,61 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import Icon from '../card-icon' + +vi.mock('@/app/components/base/app-icon', () => ({ + default: ({ icon, background }: { icon: string, background: string }) => ( +
+ ), +})) + +vi.mock('@/app/components/base/icons/src/vender/other', () => ({ + Mcp: () => , +})) + +vi.mock('@/utils/mcp', () => ({ + shouldUseMcpIcon: () => false, +})) + +describe('Icon', () => { + it('renders string src as background image', () => { + const { container } = render() + const el = container.firstChild as HTMLElement + expect(el.style.backgroundImage).toContain('https://example.com/icon.png') + }) + + it('renders emoji src using AppIcon', () => { + render() + expect(screen.getByTestId('app-icon')).toBeInTheDocument() + expect(screen.getByTestId('app-icon')).toHaveAttribute('data-icon', '🔍') + expect(screen.getByTestId('app-icon')).toHaveAttribute('data-bg', '#fff') + }) + + it('shows check icon when installed', () => { + const { container } = render() + expect(container.querySelector('.bg-state-success-solid')).toBeInTheDocument() + }) + + it('shows close icon when installFailed', () => { + const { container } = render() + expect(container.querySelector('.bg-state-destructive-solid')).toBeInTheDocument() + }) + + it('does not show status icons by default', () => { + const { container } = render() + expect(container.querySelector('.bg-state-success-solid')).not.toBeInTheDocument() + expect(container.querySelector('.bg-state-destructive-solid')).not.toBeInTheDocument() + }) + + it('applies custom className', () => { + const { container } = render() + const el = container.firstChild as HTMLElement + expect(el.className).toContain('my-class') + }) + + it('applies correct size class', () => { + const { container } = render() + const el = container.firstChild as HTMLElement + expect(el.className).toContain('w-8') + expect(el.className).toContain('h-8') + }) +}) diff --git a/web/app/components/plugins/card/base/__tests__/corner-mark.spec.tsx b/web/app/components/plugins/card/base/__tests__/corner-mark.spec.tsx new file mode 100644 index 0000000000..8c2e50dc44 --- /dev/null +++ b/web/app/components/plugins/card/base/__tests__/corner-mark.spec.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import CornerMark from '../corner-mark' + +vi.mock('../../../../base/icons/src/vender/plugin', () => ({ + LeftCorner: ({ className }: { className: string }) => , +})) + +describe('CornerMark', () => { + it('renders the text content', () => { + render() + expect(screen.getByText('NEW')).toBeInTheDocument() + }) + + it('renders the LeftCorner icon', () => { + render() + expect(screen.getByTestId('left-corner')).toBeInTheDocument() + }) + + it('renders with absolute positioning', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('absolute') + expect(wrapper.className).toContain('right-0') + expect(wrapper.className).toContain('top-0') + }) +}) diff --git a/web/app/components/plugins/card/base/__tests__/description.spec.tsx b/web/app/components/plugins/card/base/__tests__/description.spec.tsx new file mode 100644 index 0000000000..5008e8f63f --- /dev/null +++ b/web/app/components/plugins/card/base/__tests__/description.spec.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import Description from '../description' + +describe('Description', () => { + it('renders description text', () => { + render() + expect(screen.getByText('A great plugin')).toBeInTheDocument() + }) + + it('applies truncate class for 1 line', () => { + render() + const el = screen.getByText('Single line') + expect(el.className).toContain('truncate') + expect(el.className).toContain('h-4') + }) + + it('applies line-clamp-2 class for 2 lines', () => { + render() + const el = screen.getByText('Two lines') + expect(el.className).toContain('line-clamp-2') + expect(el.className).toContain('h-8') + }) + + it('applies line-clamp-3 class for 3 lines', () => { + render() + const el = screen.getByText('Three lines') + expect(el.className).toContain('line-clamp-3') + expect(el.className).toContain('h-12') + }) + + it('applies custom className', () => { + render() + const el = screen.getByText('test') + expect(el.className).toContain('mt-2') + }) +}) diff --git a/web/app/components/plugins/card/base/__tests__/download-count.spec.tsx b/web/app/components/plugins/card/base/__tests__/download-count.spec.tsx new file mode 100644 index 0000000000..6bb52f8528 --- /dev/null +++ b/web/app/components/plugins/card/base/__tests__/download-count.spec.tsx @@ -0,0 +1,28 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import DownloadCount from '../download-count' + +vi.mock('@/utils/format', () => ({ + formatNumber: (n: number) => { + if (n >= 1000) + return `${(n / 1000).toFixed(1)}k` + return String(n) + }, +})) + +describe('DownloadCount', () => { + it('renders formatted download count', () => { + render() + expect(screen.getByText('1.5k')).toBeInTheDocument() + }) + + it('renders small numbers directly', () => { + render() + expect(screen.getByText('42')).toBeInTheDocument() + }) + + it('renders zero download count', () => { + render() + expect(screen.getByText('0')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/card/base/__tests__/org-info.spec.tsx b/web/app/components/plugins/card/base/__tests__/org-info.spec.tsx new file mode 100644 index 0000000000..ac3461938f --- /dev/null +++ b/web/app/components/plugins/card/base/__tests__/org-info.spec.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import OrgInfo from '../org-info' + +describe('OrgInfo', () => { + it('renders package name', () => { + render() + expect(screen.getByText('my-plugin')).toBeInTheDocument() + }) + + it('renders org name with separator when provided', () => { + render() + expect(screen.getByText('dify')).toBeInTheDocument() + expect(screen.getByText('/')).toBeInTheDocument() + expect(screen.getByText('search-tool')).toBeInTheDocument() + }) + + it('does not render org name or separator when orgName is not provided', () => { + render() + expect(screen.queryByText('/')).not.toBeInTheDocument() + expect(screen.getByText('standalone')).toBeInTheDocument() + }) + + it('applies custom className', () => { + const { container } = render() + expect((container.firstChild as HTMLElement).className).toContain('custom-class') + }) + + it('applies packageNameClassName to package name element', () => { + render() + const pkgEl = screen.getByText('pkg') + expect(pkgEl.className).toContain('w-auto') + }) +}) diff --git a/web/app/components/plugins/card/base/__tests__/placeholder.spec.tsx b/web/app/components/plugins/card/base/__tests__/placeholder.spec.tsx new file mode 100644 index 0000000000..076f4d69dd --- /dev/null +++ b/web/app/components/plugins/card/base/__tests__/placeholder.spec.tsx @@ -0,0 +1,71 @@ +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../title', () => ({ + default: ({ title }: { title: string }) => {title}, +})) + +vi.mock('../../../../base/icons/src/vender/other', () => ({ + Group: ({ className }: { className: string }) => , +})) + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), +})) + +describe('Placeholder', () => { + let Placeholder: (typeof import('../placeholder'))['default'] + + beforeEach(async () => { + vi.clearAllMocks() + const mod = await import('../placeholder') + Placeholder = mod.default + }) + + it('should render skeleton rows', () => { + const { container } = render() + + expect(container.querySelectorAll('.gap-2').length).toBeGreaterThanOrEqual(1) + }) + + it('should render group icon placeholder', () => { + render() + + expect(screen.getByTestId('group-icon')).toBeInTheDocument() + }) + + it('should render loading filename when provided', () => { + render() + + expect(screen.getByTestId('title')).toHaveTextContent('test-plugin.zip') + }) + + it('should render skeleton rectangles when no filename', () => { + const { container } = render() + + expect(container.querySelectorAll('.bg-text-quaternary').length).toBeGreaterThanOrEqual(1) + }) +}) + +describe('LoadingPlaceholder', () => { + let LoadingPlaceholder: (typeof import('../placeholder'))['LoadingPlaceholder'] + + beforeEach(async () => { + vi.clearAllMocks() + const mod = await import('../placeholder') + LoadingPlaceholder = mod.LoadingPlaceholder + }) + + it('should render as a simple div with background', () => { + const { container } = render() + + expect(container.firstChild).toBeTruthy() + }) + + it('should accept className prop', () => { + const { container } = render() + + expect(container.firstChild).toBeTruthy() + }) +}) diff --git a/web/app/components/plugins/card/base/__tests__/title.spec.tsx b/web/app/components/plugins/card/base/__tests__/title.spec.tsx new file mode 100644 index 0000000000..61c8936363 --- /dev/null +++ b/web/app/components/plugins/card/base/__tests__/title.spec.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import Title from '../title' + +describe('Title', () => { + it('renders the title text', () => { + render() + expect(screen.getByText('Test Plugin')).toBeInTheDocument() + }) + + it('renders with truncate class for long text', () => { + render(<Title title="A very long title that should be truncated" />) + const el = screen.getByText('A very long title that should be truncated') + expect(el.className).toContain('truncate') + }) + + it('renders empty string without error', () => { + const { container } = render(<Title title="" />) + expect(container.firstChild).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/card/index.spec.tsx b/web/app/components/plugins/card/index.spec.tsx deleted file mode 100644 index 8406d6753d..0000000000 --- a/web/app/components/plugins/card/index.spec.tsx +++ /dev/null @@ -1,1877 +0,0 @@ -import type { Plugin } from '../types' -import { render, screen } from '@testing-library/react' -import * as React from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum } from '../types' - -import Icon from './base/card-icon' -import CornerMark from './base/corner-mark' -import Description from './base/description' -import DownloadCount from './base/download-count' -import OrgInfo from './base/org-info' -import Placeholder, { LoadingPlaceholder } from './base/placeholder' -import Title from './base/title' -import CardMoreInfo from './card-more-info' -// ================================ -// Import Components Under Test -// ================================ -import Card from './index' - -// ================================ -// Mock External Dependencies Only -// ================================ - -// Mock useTheme hook -let mockTheme = 'light' -vi.mock('@/hooks/use-theme', () => ({ - default: () => ({ theme: mockTheme }), -})) - -// Mock i18n-config -vi.mock('@/i18n-config', () => ({ - renderI18nObject: (obj: Record<string, string>, locale: string) => { - return obj?.[locale] || obj?.['en-US'] || '' - }, -})) - -// Mock i18n-config/language -vi.mock('@/i18n-config/language', () => ({ - getLanguage: (locale: string) => locale || 'en-US', -})) - -// Mock useCategories hook -const mockCategoriesMap: Record<string, { label: string }> = { - 'tool': { label: 'Tool' }, - 'model': { label: 'Model' }, - 'extension': { label: 'Extension' }, - 'agent-strategy': { label: 'Agent' }, - 'datasource': { label: 'Datasource' }, - 'trigger': { label: 'Trigger' }, - 'bundle': { label: 'Bundle' }, -} - -vi.mock('../hooks', () => ({ - useCategories: () => ({ - categoriesMap: mockCategoriesMap, - }), -})) - -// Mock formatNumber utility -vi.mock('@/utils/format', () => ({ - formatNumber: (num: number) => num.toLocaleString(), -})) - -// Mock shouldUseMcpIcon utility -vi.mock('@/utils/mcp', () => ({ - shouldUseMcpIcon: (src: unknown) => typeof src === 'object' && src !== null && (src as { content?: string })?.content === '🔗', -})) - -// Mock AppIcon component -vi.mock('@/app/components/base/app-icon', () => ({ - default: ({ icon, background, innerIcon, size, iconType }: { - icon?: string - background?: string - innerIcon?: React.ReactNode - size?: string - iconType?: string - }) => ( - <div - data-testid="app-icon" - data-icon={icon} - data-background={background} - data-size={size} - data-icon-type={iconType} - > - {!!innerIcon && <div data-testid="inner-icon">{innerIcon}</div>} - </div> - ), -})) - -// Mock Mcp icon component -vi.mock('@/app/components/base/icons/src/vender/other', () => ({ - Mcp: ({ className }: { className?: string }) => ( - <div data-testid="mcp-icon" className={className}>MCP</div> - ), - Group: ({ className }: { className?: string }) => ( - <div data-testid="group-icon" className={className}>Group</div> - ), -})) - -// Mock LeftCorner icon component -vi.mock('../../base/icons/src/vender/plugin', () => ({ - LeftCorner: ({ className }: { className?: string }) => ( - <div data-testid="left-corner" className={className}>LeftCorner</div> - ), -})) - -// Mock Partner badge -vi.mock('../base/badges/partner', () => ({ - default: ({ className, text }: { className?: string, text?: string }) => ( - <div data-testid="partner-badge" className={className} title={text}>Partner</div> - ), -})) - -// Mock Verified badge -vi.mock('../base/badges/verified', () => ({ - default: ({ className, text }: { className?: string, text?: string }) => ( - <div data-testid="verified-badge" className={className} title={text}>Verified</div> - ), -})) - -// Mock Skeleton components -vi.mock('@/app/components/base/skeleton', () => ({ - SkeletonContainer: ({ children }: { children: React.ReactNode }) => ( - <div data-testid="skeleton-container">{children}</div> - ), - SkeletonPoint: () => <div data-testid="skeleton-point" />, - SkeletonRectangle: ({ className }: { className?: string }) => ( - <div data-testid="skeleton-rectangle" className={className} /> - ), - SkeletonRow: ({ children, className }: { children: React.ReactNode, className?: string }) => ( - <div data-testid="skeleton-row" className={className}>{children}</div> - ), -})) - -// Mock Remix icons -vi.mock('@remixicon/react', () => ({ - RiCheckLine: ({ className }: { className?: string }) => ( - <span data-testid="ri-check-line" className={className}>✓</span> - ), - RiCloseLine: ({ className }: { className?: string }) => ( - <span data-testid="ri-close-line" className={className}>✕</span> - ), - RiInstallLine: ({ className }: { className?: string }) => ( - <span data-testid="ri-install-line" className={className}>↓</span> - ), - RiAlertFill: ({ className }: { className?: string }) => ( - <span data-testid="ri-alert-fill" className={className}>⚠</span> - ), -})) - -// ================================ -// Test Data Factories -// ================================ - -const createMockPlugin = (overrides?: Partial<Plugin>): Plugin => ({ - type: 'plugin', - org: 'test-org', - name: 'test-plugin', - plugin_id: 'plugin-123', - version: '1.0.0', - latest_version: '1.0.0', - latest_package_identifier: 'test-org/test-plugin:1.0.0', - icon: '/test-icon.png', - verified: false, - label: { 'en-US': 'Test Plugin' }, - brief: { 'en-US': 'Test plugin description' }, - description: { 'en-US': 'Full test plugin description' }, - introduction: 'Test plugin introduction', - repository: 'https://github.com/test/plugin', - category: PluginCategoryEnum.tool, - install_count: 1000, - endpoint: { settings: [] }, - tags: [{ name: 'search' }], - badges: [], - verification: { authorized_category: 'community' }, - from: 'marketplace', - ...overrides, -}) - -// ================================ -// Card Component Tests (index.tsx) -// ================================ -describe('Card', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render without crashing', () => { - const plugin = createMockPlugin() - render(<Card payload={plugin} />) - - expect(document.body).toBeInTheDocument() - }) - - it('should render plugin title from label', () => { - const plugin = createMockPlugin({ - label: { 'en-US': 'My Plugin Title' }, - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('My Plugin Title')).toBeInTheDocument() - }) - - it('should render plugin description from brief', () => { - const plugin = createMockPlugin({ - brief: { 'en-US': 'This is a brief description' }, - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('This is a brief description')).toBeInTheDocument() - }) - - it('should render organization info with org name and package name', () => { - const plugin = createMockPlugin({ - org: 'my-org', - name: 'my-plugin', - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('my-org')).toBeInTheDocument() - expect(screen.getByText('my-plugin')).toBeInTheDocument() - }) - - it('should render plugin icon', () => { - const plugin = createMockPlugin({ - icon: '/custom-icon.png', - }) - - const { container } = render(<Card payload={plugin} />) - - // Check for background image style on icon element - const iconElement = container.querySelector('[style*="background-image"]') - expect(iconElement).toBeInTheDocument() - }) - - it('should use icon_dark when theme is dark and icon_dark is provided', () => { - // Set theme to dark - mockTheme = 'dark' - - const plugin = createMockPlugin({ - icon: '/light-icon.png', - icon_dark: '/dark-icon.png', - }) - - const { container } = render(<Card payload={plugin} />) - - // Check that icon uses dark icon - const iconElement = container.querySelector('[style*="background-image"]') - expect(iconElement).toBeInTheDocument() - expect(iconElement).toHaveStyle({ backgroundImage: 'url(/dark-icon.png)' }) - - // Reset theme - mockTheme = 'light' - }) - - it('should use icon when theme is dark but icon_dark is not provided', () => { - mockTheme = 'dark' - - const plugin = createMockPlugin({ - icon: '/light-icon.png', - }) - - const { container } = render(<Card payload={plugin} />) - - // Should fallback to light icon - const iconElement = container.querySelector('[style*="background-image"]') - expect(iconElement).toBeInTheDocument() - expect(iconElement).toHaveStyle({ backgroundImage: 'url(/light-icon.png)' }) - - mockTheme = 'light' - }) - - it('should render corner mark with category label', () => { - const plugin = createMockPlugin({ - category: PluginCategoryEnum.tool, - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('Tool')).toBeInTheDocument() - }) - }) - - // ================================ - // Props Testing - // ================================ - describe('Props', () => { - it('should apply custom className', () => { - const plugin = createMockPlugin() - const { container } = render( - <Card payload={plugin} className="custom-class" />, - ) - - expect(container.querySelector('.custom-class')).toBeInTheDocument() - }) - - it('should hide corner mark when hideCornerMark is true', () => { - const plugin = createMockPlugin({ - category: PluginCategoryEnum.tool, - }) - - render(<Card payload={plugin} hideCornerMark={true} />) - - expect(screen.queryByTestId('left-corner')).not.toBeInTheDocument() - }) - - it('should show corner mark by default', () => { - const plugin = createMockPlugin() - - render(<Card payload={plugin} />) - - expect(screen.getByTestId('left-corner')).toBeInTheDocument() - }) - - it('should pass installed prop to Icon component', () => { - const plugin = createMockPlugin() - render(<Card payload={plugin} installed={true} />) - - // Check for the check icon that appears when installed - expect(screen.getByTestId('ri-check-line')).toBeInTheDocument() - }) - - it('should pass installFailed prop to Icon component', () => { - const plugin = createMockPlugin() - render(<Card payload={plugin} installFailed={true} />) - - // Check for the close icon that appears when install failed - expect(screen.getByTestId('ri-close-line')).toBeInTheDocument() - }) - - it('should render footer when provided', () => { - const plugin = createMockPlugin() - render( - <Card payload={plugin} footer={<div data-testid="custom-footer">Footer Content</div>} />, - ) - - expect(screen.getByTestId('custom-footer')).toBeInTheDocument() - expect(screen.getByText('Footer Content')).toBeInTheDocument() - }) - - it('should render titleLeft when provided', () => { - const plugin = createMockPlugin() - render( - <Card payload={plugin} titleLeft={<span data-testid="title-left">v1.0</span>} />, - ) - - expect(screen.getByTestId('title-left')).toBeInTheDocument() - }) - - it('should use custom descriptionLineRows', () => { - const plugin = createMockPlugin() - - const { container } = render( - <Card payload={plugin} descriptionLineRows={1} />, - ) - - // Check for h-4 truncate class when descriptionLineRows is 1 - expect(container.querySelector('.h-4.truncate')).toBeInTheDocument() - }) - - it('should use default descriptionLineRows of 2', () => { - const plugin = createMockPlugin() - - const { container } = render(<Card payload={plugin} />) - - // Check for h-8 line-clamp-2 class when descriptionLineRows is 2 (default) - expect(container.querySelector('.h-8.line-clamp-2')).toBeInTheDocument() - }) - }) - - // ================================ - // Loading State Tests - // ================================ - describe('Loading State', () => { - it('should render Placeholder when isLoading is true', () => { - const plugin = createMockPlugin() - - render(<Card payload={plugin} isLoading={true} loadingFileName="loading.txt" />) - - // Should render skeleton elements - expect(screen.getByTestId('skeleton-container')).toBeInTheDocument() - }) - - it('should render loadingFileName in Placeholder', () => { - const plugin = createMockPlugin() - - render(<Card payload={plugin} isLoading={true} loadingFileName="my-plugin.zip" />) - - expect(screen.getByText('my-plugin.zip')).toBeInTheDocument() - }) - - it('should not render card content when loading', () => { - const plugin = createMockPlugin({ - label: { 'en-US': 'Plugin Title' }, - }) - - render(<Card payload={plugin} isLoading={true} loadingFileName="file.txt" />) - - // Plugin content should not be visible during loading - expect(screen.queryByText('Plugin Title')).not.toBeInTheDocument() - }) - - it('should not render loading state by default', () => { - const plugin = createMockPlugin() - - render(<Card payload={plugin} />) - - expect(screen.queryByTestId('skeleton-container')).not.toBeInTheDocument() - }) - }) - - // ================================ - // Badges Tests - // ================================ - describe('Badges', () => { - it('should render Partner badge when badges includes partner', () => { - const plugin = createMockPlugin({ - badges: ['partner'], - }) - - render(<Card payload={plugin} />) - - expect(screen.getByTestId('partner-badge')).toBeInTheDocument() - }) - - it('should render Verified badge when verified is true', () => { - const plugin = createMockPlugin({ - verified: true, - }) - - render(<Card payload={plugin} />) - - expect(screen.getByTestId('verified-badge')).toBeInTheDocument() - }) - - it('should render both Partner and Verified badges', () => { - const plugin = createMockPlugin({ - badges: ['partner'], - verified: true, - }) - - render(<Card payload={plugin} />) - - expect(screen.getByTestId('partner-badge')).toBeInTheDocument() - expect(screen.getByTestId('verified-badge')).toBeInTheDocument() - }) - - it('should not render Partner badge when badges is empty', () => { - const plugin = createMockPlugin({ - badges: [], - }) - - render(<Card payload={plugin} />) - - expect(screen.queryByTestId('partner-badge')).not.toBeInTheDocument() - }) - - it('should not render Verified badge when verified is false', () => { - const plugin = createMockPlugin({ - verified: false, - }) - - render(<Card payload={plugin} />) - - expect(screen.queryByTestId('verified-badge')).not.toBeInTheDocument() - }) - - it('should handle undefined badges gracefully', () => { - const plugin = createMockPlugin() - // @ts-expect-error - Testing undefined badges - plugin.badges = undefined - - render(<Card payload={plugin} />) - - expect(screen.queryByTestId('partner-badge')).not.toBeInTheDocument() - }) - }) - - // ================================ - // Limited Install Warning Tests - // ================================ - describe('Limited Install Warning', () => { - it('should render warning when limitedInstall is true', () => { - const plugin = createMockPlugin() - - render(<Card payload={plugin} limitedInstall={true} />) - - expect(screen.getByTestId('ri-alert-fill')).toBeInTheDocument() - }) - - it('should not render warning by default', () => { - const plugin = createMockPlugin() - - render(<Card payload={plugin} />) - - expect(screen.queryByTestId('ri-alert-fill')).not.toBeInTheDocument() - }) - - it('should apply limited padding when limitedInstall is true', () => { - const plugin = createMockPlugin() - - const { container } = render(<Card payload={plugin} limitedInstall={true} />) - - expect(container.querySelector('.pb-1')).toBeInTheDocument() - }) - }) - - // ================================ - // Category Type Tests - // ================================ - describe('Category Types', () => { - it('should display bundle label for bundle type', () => { - const plugin = createMockPlugin({ - type: 'bundle', - category: PluginCategoryEnum.tool, - }) - - render(<Card payload={plugin} />) - - // For bundle type, should show 'Bundle' instead of category - expect(screen.getByText('Bundle')).toBeInTheDocument() - }) - - it('should display category label for non-bundle types', () => { - const plugin = createMockPlugin({ - type: 'plugin', - category: PluginCategoryEnum.model, - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('Model')).toBeInTheDocument() - }) - }) - - // ================================ - // Memoization Tests - // ================================ - describe('Memoization', () => { - it('should be memoized with React.memo', () => { - // Card is wrapped with React.memo - expect(Card).toBeDefined() - // The component should have the memo display name characteristic - expect(typeof Card).toBe('object') - }) - - it('should not re-render when props are the same', () => { - const plugin = createMockPlugin() - const renderCount = vi.fn() - - const TestWrapper = ({ p }: { p: Plugin }) => { - renderCount() - return <Card payload={p} /> - } - - const { rerender } = render(<TestWrapper p={plugin} />) - expect(renderCount).toHaveBeenCalledTimes(1) - - // Re-render with same plugin reference - rerender(<TestWrapper p={plugin} />) - expect(renderCount).toHaveBeenCalledTimes(2) - }) - }) - - // ================================ - // Edge Cases Tests - // ================================ - describe('Edge Cases', () => { - it('should handle empty label object', () => { - const plugin = createMockPlugin({ - label: {}, - }) - - render(<Card payload={plugin} />) - - // Should render without crashing - expect(document.body).toBeInTheDocument() - }) - - it('should handle empty brief object', () => { - const plugin = createMockPlugin({ - brief: {}, - }) - - render(<Card payload={plugin} />) - - expect(document.body).toBeInTheDocument() - }) - - it('should handle undefined label', () => { - const plugin = createMockPlugin() - // @ts-expect-error - Testing undefined label - plugin.label = undefined - - render(<Card payload={plugin} />) - - expect(document.body).toBeInTheDocument() - }) - - it('should handle special characters in plugin name', () => { - const plugin = createMockPlugin({ - name: 'plugin-with-special-chars!@#$%', - org: 'org<script>alert(1)</script>', - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('plugin-with-special-chars!@#$%')).toBeInTheDocument() - }) - - it('should handle very long title', () => { - const longTitle = 'A'.repeat(500) - const plugin = createMockPlugin({ - label: { 'en-US': longTitle }, - }) - - const { container } = render(<Card payload={plugin} />) - - // Should have truncate class for long text - expect(container.querySelector('.truncate')).toBeInTheDocument() - }) - - it('should handle very long description', () => { - const longDescription = 'B'.repeat(1000) - const plugin = createMockPlugin({ - brief: { 'en-US': longDescription }, - }) - - const { container } = render(<Card payload={plugin} />) - - // Should have line-clamp class for long text - expect(container.querySelector('.line-clamp-2')).toBeInTheDocument() - }) - }) -}) - -// ================================ -// CardMoreInfo Component Tests -// ================================ -describe('CardMoreInfo', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render without crashing', () => { - render(<CardMoreInfo downloadCount={100} tags={['tag1']} />) - - expect(document.body).toBeInTheDocument() - }) - - it('should render download count when provided', () => { - render(<CardMoreInfo downloadCount={1000} tags={[]} />) - - expect(screen.getByText('1,000')).toBeInTheDocument() - }) - - it('should render tags when provided', () => { - render(<CardMoreInfo tags={['search', 'image']} />) - - expect(screen.getByText('search')).toBeInTheDocument() - expect(screen.getByText('image')).toBeInTheDocument() - }) - - it('should render both download count and tags with separator', () => { - render(<CardMoreInfo downloadCount={500} tags={['tag1']} />) - - expect(screen.getByText('500')).toBeInTheDocument() - expect(screen.getByText('·')).toBeInTheDocument() - expect(screen.getByText('tag1')).toBeInTheDocument() - }) - }) - - // ================================ - // Props Testing - // ================================ - describe('Props', () => { - it('should not render download count when undefined', () => { - render(<CardMoreInfo tags={['tag1']} />) - - expect(screen.queryByTestId('ri-install-line')).not.toBeInTheDocument() - }) - - it('should not render separator when download count is undefined', () => { - render(<CardMoreInfo tags={['tag1']} />) - - expect(screen.queryByText('·')).not.toBeInTheDocument() - }) - - it('should not render separator when tags are empty', () => { - render(<CardMoreInfo downloadCount={100} tags={[]} />) - - expect(screen.queryByText('·')).not.toBeInTheDocument() - }) - - it('should render hash symbol before each tag', () => { - render(<CardMoreInfo tags={['search']} />) - - expect(screen.getByText('#')).toBeInTheDocument() - }) - - it('should set title attribute with hash prefix for tags', () => { - render(<CardMoreInfo tags={['search']} />) - - const tagElement = screen.getByTitle('# search') - expect(tagElement).toBeInTheDocument() - }) - }) - - // ================================ - // Memoization Tests - // ================================ - describe('Memoization', () => { - it('should be memoized with React.memo', () => { - expect(CardMoreInfo).toBeDefined() - expect(typeof CardMoreInfo).toBe('object') - }) - }) - - // ================================ - // Edge Cases Tests - // ================================ - describe('Edge Cases', () => { - it('should handle zero download count', () => { - render(<CardMoreInfo downloadCount={0} tags={[]} />) - - // 0 should still render since downloadCount is defined - expect(screen.getByText('0')).toBeInTheDocument() - }) - - it('should handle empty tags array', () => { - render(<CardMoreInfo downloadCount={100} tags={[]} />) - - expect(screen.queryByText('#')).not.toBeInTheDocument() - }) - - it('should handle large download count', () => { - render(<CardMoreInfo downloadCount={1234567890} tags={[]} />) - - expect(screen.getByText('1,234,567,890')).toBeInTheDocument() - }) - - it('should handle many tags', () => { - const tags = Array.from({ length: 10 }, (_, i) => `tag${i}`) - render(<CardMoreInfo downloadCount={100} tags={tags} />) - - expect(screen.getByText('tag0')).toBeInTheDocument() - expect(screen.getByText('tag9')).toBeInTheDocument() - }) - - it('should handle tags with special characters', () => { - render(<CardMoreInfo tags={['tag-with-dash', 'tag_with_underscore']} />) - - expect(screen.getByText('tag-with-dash')).toBeInTheDocument() - expect(screen.getByText('tag_with_underscore')).toBeInTheDocument() - }) - - it('should truncate long tag names', () => { - const longTag = 'a'.repeat(200) - const { container } = render(<CardMoreInfo tags={[longTag]} />) - - expect(container.querySelector('.truncate')).toBeInTheDocument() - }) - }) -}) - -// ================================ -// Icon Component Tests (base/card-icon.tsx) -// ================================ -describe('Icon', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render without crashing with string src', () => { - render(<Icon src="/icon.png" />) - - expect(document.body).toBeInTheDocument() - }) - - it('should render without crashing with object src', () => { - render(<Icon src={{ content: '🎉', background: '#fff' }} />) - - expect(document.body).toBeInTheDocument() - }) - - it('should render background image for string src', () => { - const { container } = render(<Icon src="/test-icon.png" />) - - const iconDiv = container.firstChild as HTMLElement - expect(iconDiv).toHaveStyle({ backgroundImage: 'url(/test-icon.png)' }) - }) - - it('should render AppIcon for object src', () => { - render(<Icon src={{ content: '🎉', background: '#ffffff' }} />) - - expect(screen.getByTestId('app-icon')).toBeInTheDocument() - }) - }) - - // ================================ - // Props Testing - // ================================ - describe('Props', () => { - it('should apply custom className', () => { - const { container } = render(<Icon src="/icon.png" className="custom-icon-class" />) - - expect(container.querySelector('.custom-icon-class')).toBeInTheDocument() - }) - - it('should render check icon when installed is true', () => { - render(<Icon src="/icon.png" installed={true} />) - - expect(screen.getByTestId('ri-check-line')).toBeInTheDocument() - }) - - it('should render close icon when installFailed is true', () => { - render(<Icon src="/icon.png" installFailed={true} />) - - expect(screen.getByTestId('ri-close-line')).toBeInTheDocument() - }) - - it('should not render status icon when neither installed nor failed', () => { - render(<Icon src="/icon.png" />) - - expect(screen.queryByTestId('ri-check-line')).not.toBeInTheDocument() - expect(screen.queryByTestId('ri-close-line')).not.toBeInTheDocument() - }) - - it('should use default size of large', () => { - const { container } = render(<Icon src="/icon.png" />) - - expect(container.querySelector('.w-10.h-10')).toBeInTheDocument() - }) - - it('should apply xs size class', () => { - const { container } = render(<Icon src="/icon.png" size="xs" />) - - expect(container.querySelector('.w-4.h-4')).toBeInTheDocument() - }) - - it('should apply tiny size class', () => { - const { container } = render(<Icon src="/icon.png" size="tiny" />) - - expect(container.querySelector('.w-6.h-6')).toBeInTheDocument() - }) - - it('should apply small size class', () => { - const { container } = render(<Icon src="/icon.png" size="small" />) - - expect(container.querySelector('.w-8.h-8')).toBeInTheDocument() - }) - - it('should apply medium size class', () => { - const { container } = render(<Icon src="/icon.png" size="medium" />) - - expect(container.querySelector('.w-9.h-9')).toBeInTheDocument() - }) - - it('should apply large size class', () => { - const { container } = render(<Icon src="/icon.png" size="large" />) - - expect(container.querySelector('.w-10.h-10')).toBeInTheDocument() - }) - }) - - // ================================ - // MCP Icon Tests - // ================================ - describe('MCP Icon', () => { - it('should render MCP icon when src content is 🔗', () => { - render(<Icon src={{ content: '🔗', background: '#ffffff' }} />) - - expect(screen.getByTestId('mcp-icon')).toBeInTheDocument() - }) - - it('should not render MCP icon for other emoji content', () => { - render(<Icon src={{ content: '🎉', background: '#ffffff' }} />) - - expect(screen.queryByTestId('mcp-icon')).not.toBeInTheDocument() - }) - }) - - // ================================ - // Status Indicator Tests - // ================================ - describe('Status Indicators', () => { - it('should render success indicator with correct styling for installed', () => { - const { container } = render(<Icon src="/icon.png" installed={true} />) - - expect(container.querySelector('.bg-state-success-solid')).toBeInTheDocument() - }) - - it('should render destructive indicator with correct styling for failed', () => { - const { container } = render(<Icon src="/icon.png" installFailed={true} />) - - expect(container.querySelector('.bg-state-destructive-solid')).toBeInTheDocument() - }) - - it('should prioritize installed over installFailed', () => { - // When both are true, installed takes precedence (rendered first in code) - render(<Icon src="/icon.png" installed={true} installFailed={true} />) - - expect(screen.getByTestId('ri-check-line')).toBeInTheDocument() - }) - }) - - // ================================ - // Object src Tests - // ================================ - describe('Object src', () => { - it('should render AppIcon with correct icon prop', () => { - render(<Icon src={{ content: '🎉', background: '#ffffff' }} />) - - const appIcon = screen.getByTestId('app-icon') - expect(appIcon).toHaveAttribute('data-icon', '🎉') - }) - - it('should render AppIcon with correct background prop', () => { - render(<Icon src={{ content: '🔥', background: '#ff0000' }} />) - - const appIcon = screen.getByTestId('app-icon') - expect(appIcon).toHaveAttribute('data-background', '#ff0000') - }) - - it('should render AppIcon with emoji iconType', () => { - render(<Icon src={{ content: '⭐', background: '#ffff00' }} />) - - const appIcon = screen.getByTestId('app-icon') - expect(appIcon).toHaveAttribute('data-icon-type', 'emoji') - }) - - it('should render AppIcon with correct size', () => { - render(<Icon src={{ content: '📦', background: '#0000ff' }} size="small" />) - - const appIcon = screen.getByTestId('app-icon') - expect(appIcon).toHaveAttribute('data-size', 'small') - }) - - it('should apply className to wrapper div for object src', () => { - const { container } = render( - <Icon src={{ content: '🎨', background: '#00ff00' }} className="custom-class" />, - ) - - expect(container.querySelector('.relative.custom-class')).toBeInTheDocument() - }) - - it('should render with all size options for object src', () => { - const sizes = ['xs', 'tiny', 'small', 'medium', 'large'] as const - sizes.forEach((size) => { - const { unmount } = render( - <Icon src={{ content: '📱', background: '#ffffff' }} size={size} />, - ) - expect(screen.getByTestId('app-icon')).toHaveAttribute('data-size', size) - unmount() - }) - }) - }) - - // ================================ - // Edge Cases Tests - // ================================ - describe('Edge Cases', () => { - it('should handle empty string src', () => { - const { container } = render(<Icon src="" />) - - expect(container.firstChild).toBeInTheDocument() - }) - - it('should handle special characters in URL', () => { - const { container } = render(<Icon src="/icon?name=test&size=large" />) - - const iconDiv = container.firstChild as HTMLElement - expect(iconDiv).toHaveStyle({ backgroundImage: 'url(/icon?name=test&size=large)' }) - }) - - it('should handle object src with special emoji', () => { - render(<Icon src={{ content: '👨‍💻', background: '#123456' }} />) - - expect(screen.getByTestId('app-icon')).toBeInTheDocument() - }) - - it('should handle object src with empty content', () => { - render(<Icon src={{ content: '', background: '#ffffff' }} />) - - expect(screen.getByTestId('app-icon')).toBeInTheDocument() - }) - - it('should not render status indicators when src is object with installed=true', () => { - render(<Icon src={{ content: '🎉', background: '#fff' }} installed={true} />) - - // Status indicators should not render for object src - expect(screen.queryByTestId('ri-check-line')).not.toBeInTheDocument() - }) - - it('should not render status indicators when src is object with installFailed=true', () => { - render(<Icon src={{ content: '🎉', background: '#fff' }} installFailed={true} />) - - // Status indicators should not render for object src - expect(screen.queryByTestId('ri-close-line')).not.toBeInTheDocument() - }) - - it('should render object src with all size variants', () => { - const sizes: Array<'xs' | 'tiny' | 'small' | 'medium' | 'large'> = ['xs', 'tiny', 'small', 'medium', 'large'] - - sizes.forEach((size) => { - const { unmount } = render(<Icon src={{ content: '🔗', background: '#fff' }} size={size} />) - expect(screen.getByTestId('app-icon')).toHaveAttribute('data-size', size) - unmount() - }) - }) - - it('should render object src with custom className', () => { - const { container } = render( - <Icon src={{ content: '🎉', background: '#fff' }} className="custom-object-icon" />, - ) - - expect(container.querySelector('.custom-object-icon')).toBeInTheDocument() - }) - - it('should pass correct props to AppIcon for object src', () => { - render(<Icon src={{ content: '😀', background: '#123456' }} />) - - const appIcon = screen.getByTestId('app-icon') - expect(appIcon).toHaveAttribute('data-icon', '😀') - expect(appIcon).toHaveAttribute('data-background', '#123456') - expect(appIcon).toHaveAttribute('data-icon-type', 'emoji') - }) - - it('should render inner icon only when shouldUseMcpIcon returns true', () => { - // Test with MCP icon content - const { unmount } = render(<Icon src={{ content: '🔗', background: '#fff' }} />) - expect(screen.getByTestId('inner-icon')).toBeInTheDocument() - unmount() - - // Test without MCP icon content - render(<Icon src={{ content: '🎉', background: '#fff' }} />) - expect(screen.queryByTestId('inner-icon')).not.toBeInTheDocument() - }) - }) - - // ================================ - // CornerMark Component Tests - // ================================ - describe('CornerMark', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render without crashing', () => { - render(<CornerMark text="Tool" />) - - expect(document.body).toBeInTheDocument() - }) - - it('should render text content', () => { - render(<CornerMark text="Tool" />) - - expect(screen.getByText('Tool')).toBeInTheDocument() - }) - - it('should render LeftCorner icon', () => { - render(<CornerMark text="Model" />) - - expect(screen.getByTestId('left-corner')).toBeInTheDocument() - }) - }) - - // ================================ - // Props Testing - // ================================ - describe('Props', () => { - it('should display different category text', () => { - const { rerender } = render(<CornerMark text="Tool" />) - expect(screen.getByText('Tool')).toBeInTheDocument() - - rerender(<CornerMark text="Model" />) - expect(screen.getByText('Model')).toBeInTheDocument() - - rerender(<CornerMark text="Extension" />) - expect(screen.getByText('Extension')).toBeInTheDocument() - }) - }) - - // ================================ - // Edge Cases Tests - // ================================ - describe('Edge Cases', () => { - it('should handle empty text', () => { - render(<CornerMark text="" />) - - expect(document.body).toBeInTheDocument() - }) - - it('should handle long text', () => { - const longText = 'Very Long Category Name' - render(<CornerMark text={longText} />) - - expect(screen.getByText(longText)).toBeInTheDocument() - }) - - it('should handle special characters in text', () => { - render(<CornerMark text="Test & Demo" />) - - expect(screen.getByText('Test & Demo')).toBeInTheDocument() - }) - }) - }) - - // ================================ - // Description Component Tests - // ================================ - describe('Description', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render without crashing', () => { - render(<Description text="Test description" descriptionLineRows={2} />) - - expect(document.body).toBeInTheDocument() - }) - - it('should render text content', () => { - render(<Description text="This is a description" descriptionLineRows={2} />) - - expect(screen.getByText('This is a description')).toBeInTheDocument() - }) - }) - - // ================================ - // Props Testing - // ================================ - describe('Props', () => { - it('should apply custom className', () => { - const { container } = render( - <Description text="Test" descriptionLineRows={2} className="custom-desc-class" />, - ) - - expect(container.querySelector('.custom-desc-class')).toBeInTheDocument() - }) - - it('should apply h-4 truncate for 1 line row', () => { - const { container } = render( - <Description text="Test" descriptionLineRows={1} />, - ) - - expect(container.querySelector('.h-4.truncate')).toBeInTheDocument() - }) - - it('should apply h-8 line-clamp-2 for 2 line rows', () => { - const { container } = render( - <Description text="Test" descriptionLineRows={2} />, - ) - - expect(container.querySelector('.h-8.line-clamp-2')).toBeInTheDocument() - }) - - it('should apply h-12 line-clamp-3 for 3+ line rows', () => { - const { container } = render( - <Description text="Test" descriptionLineRows={3} />, - ) - - expect(container.querySelector('.h-12.line-clamp-3')).toBeInTheDocument() - }) - - it('should apply h-12 line-clamp-3 for values greater than 3', () => { - const { container } = render( - <Description text="Test" descriptionLineRows={5} />, - ) - - expect(container.querySelector('.h-12.line-clamp-3')).toBeInTheDocument() - }) - - it('should apply h-12 line-clamp-3 for descriptionLineRows of 4', () => { - const { container } = render( - <Description text="Test" descriptionLineRows={4} />, - ) - - expect(container.querySelector('.h-12.line-clamp-3')).toBeInTheDocument() - }) - - it('should apply h-12 line-clamp-3 for descriptionLineRows of 10', () => { - const { container } = render( - <Description text="Test" descriptionLineRows={10} />, - ) - - expect(container.querySelector('.h-12.line-clamp-3')).toBeInTheDocument() - }) - - it('should apply h-12 line-clamp-3 for descriptionLineRows of 0', () => { - const { container } = render( - <Description text="Test" descriptionLineRows={0} />, - ) - - // 0 is neither 1 nor 2, so it should use the else branch - expect(container.querySelector('.h-12.line-clamp-3')).toBeInTheDocument() - }) - - it('should apply h-12 line-clamp-3 for negative descriptionLineRows', () => { - const { container } = render( - <Description text="Test" descriptionLineRows={-1} />, - ) - - // negative is neither 1 nor 2, so it should use the else branch - expect(container.querySelector('.h-12.line-clamp-3')).toBeInTheDocument() - }) - }) - - // ================================ - // Memoization Tests - // ================================ - describe('Memoization', () => { - it('should memoize lineClassName based on descriptionLineRows', () => { - const { container, rerender } = render( - <Description text="Test" descriptionLineRows={2} />, - ) - - expect(container.querySelector('.line-clamp-2')).toBeInTheDocument() - - // Re-render with same descriptionLineRows - rerender(<Description text="Different text" descriptionLineRows={2} />) - - // Should still have same class (memoized) - expect(container.querySelector('.line-clamp-2')).toBeInTheDocument() - }) - }) - - // ================================ - // Edge Cases Tests - // ================================ - describe('Edge Cases', () => { - it('should handle empty text', () => { - render(<Description text="" descriptionLineRows={2} />) - - expect(document.body).toBeInTheDocument() - }) - - it('should handle very long text', () => { - const longText = 'A'.repeat(1000) - const { container } = render( - <Description text={longText} descriptionLineRows={2} />, - ) - - expect(container.querySelector('.line-clamp-2')).toBeInTheDocument() - }) - - it('should handle text with HTML entities', () => { - render(<Description text="<script>alert('xss')</script>" descriptionLineRows={2} />) - - // Text should be escaped - expect(screen.getByText('<script>alert(\'xss\')</script>')).toBeInTheDocument() - }) - }) - }) - - // ================================ - // DownloadCount Component Tests - // ================================ - describe('DownloadCount', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render without crashing', () => { - render(<DownloadCount downloadCount={100} />) - - expect(document.body).toBeInTheDocument() - }) - - it('should render download count with formatted number', () => { - render(<DownloadCount downloadCount={1234567} />) - - expect(screen.getByText('1,234,567')).toBeInTheDocument() - }) - - it('should render install icon', () => { - render(<DownloadCount downloadCount={100} />) - - expect(screen.getByTestId('ri-install-line')).toBeInTheDocument() - }) - }) - - // ================================ - // Props Testing - // ================================ - describe('Props', () => { - it('should display small download count', () => { - render(<DownloadCount downloadCount={5} />) - - expect(screen.getByText('5')).toBeInTheDocument() - }) - - it('should display large download count', () => { - render(<DownloadCount downloadCount={999999999} />) - - expect(screen.getByText('999,999,999')).toBeInTheDocument() - }) - }) - - // ================================ - // Memoization Tests - // ================================ - describe('Memoization', () => { - it('should be memoized with React.memo', () => { - expect(DownloadCount).toBeDefined() - expect(typeof DownloadCount).toBe('object') - }) - }) - - // ================================ - // Edge Cases Tests - // ================================ - describe('Edge Cases', () => { - it('should handle zero download count', () => { - render(<DownloadCount downloadCount={0} />) - - // 0 should still render with install icon - expect(screen.getByText('0')).toBeInTheDocument() - expect(screen.getByTestId('ri-install-line')).toBeInTheDocument() - }) - - it('should handle negative download count', () => { - render(<DownloadCount downloadCount={-100} />) - - expect(screen.getByText('-100')).toBeInTheDocument() - }) - }) - }) - - // ================================ - // OrgInfo Component Tests - // ================================ - describe('OrgInfo', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render without crashing', () => { - render(<OrgInfo packageName="test-plugin" />) - - expect(document.body).toBeInTheDocument() - }) - - it('should render package name', () => { - render(<OrgInfo packageName="my-plugin" />) - - expect(screen.getByText('my-plugin')).toBeInTheDocument() - }) - - it('should render org name and separator when provided', () => { - render(<OrgInfo orgName="my-org" packageName="my-plugin" />) - - expect(screen.getByText('my-org')).toBeInTheDocument() - expect(screen.getByText('/')).toBeInTheDocument() - expect(screen.getByText('my-plugin')).toBeInTheDocument() - }) - }) - - // ================================ - // Props Testing - // ================================ - describe('Props', () => { - it('should apply custom className', () => { - const { container } = render( - <OrgInfo packageName="test" className="custom-org-class" />, - ) - - expect(container.querySelector('.custom-org-class')).toBeInTheDocument() - }) - - it('should apply packageNameClassName', () => { - const { container } = render( - <OrgInfo packageName="test" packageNameClassName="custom-package-class" />, - ) - - expect(container.querySelector('.custom-package-class')).toBeInTheDocument() - }) - - it('should not render org name section when orgName is undefined', () => { - render(<OrgInfo packageName="test" />) - - expect(screen.queryByText('/')).not.toBeInTheDocument() - }) - - it('should not render org name section when orgName is empty', () => { - render(<OrgInfo orgName="" packageName="test" />) - - expect(screen.queryByText('/')).not.toBeInTheDocument() - }) - }) - - // ================================ - // Edge Cases Tests - // ================================ - describe('Edge Cases', () => { - it('should handle special characters in org name', () => { - render(<OrgInfo orgName="my-org_123" packageName="test" />) - - expect(screen.getByText('my-org_123')).toBeInTheDocument() - }) - - it('should handle special characters in package name', () => { - render(<OrgInfo packageName="plugin@v1.0.0" />) - - expect(screen.getByText('plugin@v1.0.0')).toBeInTheDocument() - }) - - it('should truncate long package name', () => { - const longName = 'a'.repeat(100) - const { container } = render(<OrgInfo packageName={longName} />) - - expect(container.querySelector('.truncate')).toBeInTheDocument() - }) - }) - }) - - // ================================ - // Placeholder Component Tests - // ================================ - describe('Placeholder', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render without crashing', () => { - render(<Placeholder wrapClassName="test-class" />) - - expect(document.body).toBeInTheDocument() - }) - - it('should render with wrapClassName', () => { - const { container } = render( - <Placeholder wrapClassName="custom-wrapper" />, - ) - - expect(container.querySelector('.custom-wrapper')).toBeInTheDocument() - }) - - it('should render skeleton elements', () => { - render(<Placeholder wrapClassName="test" />) - - expect(screen.getByTestId('skeleton-container')).toBeInTheDocument() - expect(screen.getAllByTestId('skeleton-rectangle').length).toBeGreaterThan(0) - }) - - it('should render Group icon', () => { - render(<Placeholder wrapClassName="test" />) - - expect(screen.getByTestId('group-icon')).toBeInTheDocument() - }) - }) - - // ================================ - // Props Testing - // ================================ - describe('Props', () => { - it('should render Title when loadingFileName is provided', () => { - render(<Placeholder wrapClassName="test" loadingFileName="my-file.zip" />) - - expect(screen.getByText('my-file.zip')).toBeInTheDocument() - }) - - it('should render SkeletonRectangle when loadingFileName is not provided', () => { - render(<Placeholder wrapClassName="test" />) - - // Should have skeleton rectangle for title area - const rectangles = screen.getAllByTestId('skeleton-rectangle') - expect(rectangles.length).toBeGreaterThan(0) - }) - - it('should render SkeletonRow for org info', () => { - render(<Placeholder wrapClassName="test" />) - - // There are multiple skeleton rows in the component - const skeletonRows = screen.getAllByTestId('skeleton-row') - expect(skeletonRows.length).toBeGreaterThan(0) - }) - }) - - // ================================ - // Edge Cases Tests - // ================================ - describe('Edge Cases', () => { - it('should handle empty wrapClassName', () => { - const { container } = render(<Placeholder wrapClassName="" />) - - expect(container.firstChild).toBeInTheDocument() - }) - - it('should handle undefined loadingFileName', () => { - render(<Placeholder wrapClassName="test" loadingFileName={undefined} />) - - // Should show skeleton instead of title - const rectangles = screen.getAllByTestId('skeleton-rectangle') - expect(rectangles.length).toBeGreaterThan(0) - }) - - it('should handle long loadingFileName', () => { - const longFileName = 'very-long-file-name-that-goes-on-forever.zip' - render(<Placeholder wrapClassName="test" loadingFileName={longFileName} />) - - expect(screen.getByText(longFileName)).toBeInTheDocument() - }) - }) - }) - - // ================================ - // LoadingPlaceholder Component Tests - // ================================ - describe('LoadingPlaceholder', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render without crashing', () => { - render(<LoadingPlaceholder />) - - expect(document.body).toBeInTheDocument() - }) - - it('should have correct base classes', () => { - const { container } = render(<LoadingPlaceholder />) - - expect(container.querySelector('.h-2.rounded-sm')).toBeInTheDocument() - }) - }) - - // ================================ - // Props Testing - // ================================ - describe('Props', () => { - it('should apply custom className', () => { - const { container } = render(<LoadingPlaceholder className="custom-loading" />) - - expect(container.querySelector('.custom-loading')).toBeInTheDocument() - }) - - it('should merge className with base classes', () => { - const { container } = render(<LoadingPlaceholder className="w-full" />) - - expect(container.querySelector('.h-2.rounded-sm.w-full')).toBeInTheDocument() - }) - }) - }) - - // ================================ - // Title Component Tests - // ================================ - describe('Title', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render without crashing', () => { - render(<Title title="Test Title" />) - - expect(document.body).toBeInTheDocument() - }) - - it('should render title text', () => { - render(<Title title="My Plugin Title" />) - - expect(screen.getByText('My Plugin Title')).toBeInTheDocument() - }) - - it('should have truncate class', () => { - const { container } = render(<Title title="Test" />) - - expect(container.querySelector('.truncate')).toBeInTheDocument() - }) - - it('should have correct text styling', () => { - const { container } = render(<Title title="Test" />) - - expect(container.querySelector('.system-md-semibold')).toBeInTheDocument() - expect(container.querySelector('.text-text-secondary')).toBeInTheDocument() - }) - }) - - // ================================ - // Props Testing - // ================================ - describe('Props', () => { - it('should display different titles', () => { - const { rerender } = render(<Title title="First Title" />) - expect(screen.getByText('First Title')).toBeInTheDocument() - - rerender(<Title title="Second Title" />) - expect(screen.getByText('Second Title')).toBeInTheDocument() - }) - }) - - // ================================ - // Edge Cases Tests - // ================================ - describe('Edge Cases', () => { - it('should handle empty title', () => { - render(<Title title="" />) - - expect(document.body).toBeInTheDocument() - }) - - it('should handle very long title', () => { - const longTitle = 'A'.repeat(500) - const { container } = render(<Title title={longTitle} />) - - // Should have truncate for long text - expect(container.querySelector('.truncate')).toBeInTheDocument() - }) - - it('should handle special characters in title', () => { - render(<Title title={'Title with <special> & "chars"'} />) - - expect(screen.getByText('Title with <special> & "chars"')).toBeInTheDocument() - }) - - it('should handle unicode characters', () => { - render(<Title title="标题 🎉 タイトル" />) - - expect(screen.getByText('标题 🎉 タイトル')).toBeInTheDocument() - }) - }) - }) - - // ================================ - // Integration Tests - // ================================ - describe('Card Integration', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Complete Card Rendering', () => { - it('should render a complete card with all elements', () => { - const plugin = createMockPlugin({ - label: { 'en-US': 'Complete Plugin' }, - brief: { 'en-US': 'A complete plugin description' }, - org: 'complete-org', - name: 'complete-plugin', - category: PluginCategoryEnum.tool, - verified: true, - badges: ['partner'], - }) - - render( - <Card - payload={plugin} - footer={<CardMoreInfo downloadCount={5000} tags={['search', 'api']} />} - />, - ) - - // Verify all elements are rendered - expect(screen.getByText('Complete Plugin')).toBeInTheDocument() - expect(screen.getByText('A complete plugin description')).toBeInTheDocument() - expect(screen.getByText('complete-org')).toBeInTheDocument() - expect(screen.getByText('complete-plugin')).toBeInTheDocument() - expect(screen.getByText('Tool')).toBeInTheDocument() - expect(screen.getByTestId('partner-badge')).toBeInTheDocument() - expect(screen.getByTestId('verified-badge')).toBeInTheDocument() - expect(screen.getByText('5,000')).toBeInTheDocument() - expect(screen.getByText('search')).toBeInTheDocument() - expect(screen.getByText('api')).toBeInTheDocument() - }) - - it('should render loading state correctly', () => { - const plugin = createMockPlugin() - - render( - <Card - payload={plugin} - isLoading={true} - loadingFileName="loading-plugin.zip" - />, - ) - - expect(screen.getByTestId('skeleton-container')).toBeInTheDocument() - expect(screen.getByText('loading-plugin.zip')).toBeInTheDocument() - expect(screen.queryByTestId('partner-badge')).not.toBeInTheDocument() - }) - - it('should handle installed state with footer', () => { - const plugin = createMockPlugin() - - render( - <Card - payload={plugin} - installed={true} - footer={<CardMoreInfo downloadCount={100} tags={['tag1']} />} - />, - ) - - expect(screen.getByTestId('ri-check-line')).toBeInTheDocument() - expect(screen.getByText('100')).toBeInTheDocument() - }) - }) - - describe('Component Hierarchy', () => { - it('should render Icon inside Card', () => { - const plugin = createMockPlugin({ - icon: '/test-icon.png', - }) - - const { container } = render(<Card payload={plugin} />) - - // Icon should be rendered with background image - const iconElement = container.querySelector('[style*="background-image"]') - expect(iconElement).toBeInTheDocument() - }) - - it('should render Title inside Card', () => { - const plugin = createMockPlugin({ - label: { 'en-US': 'Test Title' }, - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('Test Title')).toBeInTheDocument() - }) - - it('should render Description inside Card', () => { - const plugin = createMockPlugin({ - brief: { 'en-US': 'Test Description' }, - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('Test Description')).toBeInTheDocument() - }) - - it('should render OrgInfo inside Card', () => { - const plugin = createMockPlugin({ - org: 'test-org', - name: 'test-name', - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('test-org')).toBeInTheDocument() - expect(screen.getByText('/')).toBeInTheDocument() - expect(screen.getByText('test-name')).toBeInTheDocument() - }) - - it('should render CornerMark inside Card', () => { - const plugin = createMockPlugin({ - category: PluginCategoryEnum.model, - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('Model')).toBeInTheDocument() - expect(screen.getByTestId('left-corner')).toBeInTheDocument() - }) - }) - }) - - // ================================ - // Accessibility Tests - // ================================ - describe('Accessibility', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should have accessible text content', () => { - const plugin = createMockPlugin({ - label: { 'en-US': 'Accessible Plugin' }, - brief: { 'en-US': 'This plugin is accessible' }, - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('Accessible Plugin')).toBeInTheDocument() - expect(screen.getByText('This plugin is accessible')).toBeInTheDocument() - }) - - it('should have title attribute on tags', () => { - render(<CardMoreInfo downloadCount={100} tags={['search']} />) - - expect(screen.getByTitle('# search')).toBeInTheDocument() - }) - - it('should have semantic structure', () => { - const plugin = createMockPlugin() - const { container } = render(<Card payload={plugin} />) - - // Card should have proper container structure - expect(container.firstChild).toHaveClass('rounded-xl') - }) - }) - - // ================================ - // Performance Tests - // ================================ - describe('Performance', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should render multiple cards efficiently', () => { - const plugins = Array.from({ length: 50 }, (_, i) => - createMockPlugin({ - name: `plugin-${i}`, - label: { 'en-US': `Plugin ${i}` }, - })) - - const startTime = performance.now() - const { container } = render( - <div> - {plugins.map(plugin => ( - <Card key={plugin.name} payload={plugin} /> - ))} - </div>, - ) - const endTime = performance.now() - - // Should render all cards - const cards = container.querySelectorAll('.rounded-xl') - expect(cards.length).toBe(50) - - // Should render within reasonable time (less than 1 second) - expect(endTime - startTime).toBeLessThan(1000) - }) - - it('should handle CardMoreInfo with many tags', () => { - const tags = Array.from({ length: 20 }, (_, i) => `tag-${i}`) - - const startTime = performance.now() - render(<CardMoreInfo downloadCount={1000} tags={tags} />) - const endTime = performance.now() - - expect(endTime - startTime).toBeLessThan(100) - }) - }) -}) diff --git a/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts b/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts new file mode 100644 index 0000000000..b0e3ec5832 --- /dev/null +++ b/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts @@ -0,0 +1,166 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useGitHubReleases, useGitHubUpload } from '../hooks' + +const mockNotify = vi.fn() +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: (...args: unknown[]) => mockNotify(...args) }, +})) + +vi.mock('@/config', () => ({ + GITHUB_ACCESS_TOKEN: '', +})) + +const mockUploadGitHub = vi.fn() +vi.mock('@/service/plugins', () => ({ + uploadGitHub: (...args: unknown[]) => mockUploadGitHub(...args), +})) + +vi.mock('@/utils/semver', () => ({ + compareVersion: (a: string, b: string) => { + const parseVersion = (v: string) => v.replace(/^v/, '').split('.').map(Number) + const va = parseVersion(a) + const vb = parseVersion(b) + for (let i = 0; i < Math.max(va.length, vb.length); i++) { + const diff = (va[i] || 0) - (vb[i] || 0) + if (diff > 0) + return 1 + if (diff < 0) + return -1 + } + return 0 + }, + getLatestVersion: (versions: string[]) => { + return versions.sort((a, b) => { + const pa = a.replace(/^v/, '').split('.').map(Number) + const pb = b.replace(/^v/, '').split('.').map(Number) + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const diff = (pa[i] || 0) - (pb[i] || 0) + if (diff !== 0) + return diff + } + return 0 + }).pop()! + }, +})) + +const mockFetch = vi.fn() +globalThis.fetch = mockFetch + +describe('install-plugin/hooks', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('useGitHubReleases', () => { + describe('fetchReleases', () => { + it('fetches releases from GitHub API and formats them', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([ + { + tag_name: 'v1.0.0', + assets: [{ browser_download_url: 'https://example.com/v1.zip', name: 'plugin.zip' }], + body: 'Release notes', + }, + ]), + }) + + const { result } = renderHook(() => useGitHubReleases()) + const releases = await result.current.fetchReleases('owner', 'repo') + + expect(releases).toHaveLength(1) + expect(releases[0].tag_name).toBe('v1.0.0') + expect(releases[0].assets[0].name).toBe('plugin.zip') + expect(releases[0]).not.toHaveProperty('body') + }) + + it('returns empty array and shows toast on fetch error', async () => { + mockFetch.mockResolvedValue({ + ok: false, + }) + + const { result } = renderHook(() => useGitHubReleases()) + const releases = await result.current.fetchReleases('owner', 'repo') + + expect(releases).toEqual([]) + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + }) + + describe('checkForUpdates', () => { + it('detects newer version available', () => { + const { result } = renderHook(() => useGitHubReleases()) + const releases = [ + { tag_name: 'v1.0.0', assets: [] }, + { tag_name: 'v2.0.0', assets: [] }, + ] + const { needUpdate, toastProps } = result.current.checkForUpdates(releases, 'v1.0.0') + expect(needUpdate).toBe(true) + expect(toastProps.message).toContain('v2.0.0') + }) + + it('returns no update when current is latest', () => { + const { result } = renderHook(() => useGitHubReleases()) + const releases = [ + { tag_name: 'v1.0.0', assets: [] }, + ] + const { needUpdate, toastProps } = result.current.checkForUpdates(releases, 'v1.0.0') + expect(needUpdate).toBe(false) + expect(toastProps.type).toBe('info') + }) + + it('returns error for empty releases', () => { + const { result } = renderHook(() => useGitHubReleases()) + const { needUpdate, toastProps } = result.current.checkForUpdates([], 'v1.0.0') + expect(needUpdate).toBe(false) + expect(toastProps.type).toBe('error') + expect(toastProps.message).toContain('empty') + }) + }) + }) + + describe('useGitHubUpload', () => { + it('uploads successfully and calls onSuccess', async () => { + const mockManifest = { name: 'test-plugin' } + mockUploadGitHub.mockResolvedValue({ + manifest: mockManifest, + unique_identifier: 'uid-123', + }) + const onSuccess = vi.fn() + + const { result } = renderHook(() => useGitHubUpload()) + const pkg = await result.current.handleUpload( + 'https://github.com/owner/repo', + 'v1.0.0', + 'plugin.difypkg', + onSuccess, + ) + + expect(mockUploadGitHub).toHaveBeenCalledWith( + 'https://github.com/owner/repo', + 'v1.0.0', + 'plugin.difypkg', + ) + expect(onSuccess).toHaveBeenCalledWith({ + manifest: mockManifest, + unique_identifier: 'uid-123', + }) + expect(pkg.unique_identifier).toBe('uid-123') + }) + + it('shows toast on upload error', async () => { + mockUploadGitHub.mockRejectedValue(new Error('Upload failed')) + + const { result } = renderHook(() => useGitHubUpload()) + await expect( + result.current.handleUpload('url', 'v1', 'pkg'), + ).rejects.toThrow('Upload failed') + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error', message: 'Error uploading package' }), + ) + }) + }) +}) diff --git a/web/app/components/plugins/install-plugin/utils.spec.ts b/web/app/components/plugins/install-plugin/__tests__/utils.spec.ts similarity index 99% rename from web/app/components/plugins/install-plugin/utils.spec.ts rename to web/app/components/plugins/install-plugin/__tests__/utils.spec.ts index 9a759b8026..b13ebffe2f 100644 --- a/web/app/components/plugins/install-plugin/utils.spec.ts +++ b/web/app/components/plugins/install-plugin/__tests__/utils.spec.ts @@ -1,12 +1,12 @@ -import type { PluginDeclaration, PluginManifestInMarket } from '../types' +import type { PluginDeclaration, PluginManifestInMarket } from '../../types' import { describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum } from '../types' +import { PluginCategoryEnum } from '../../types' import { convertRepoToUrl, parseGitHubUrl, pluginManifestInMarketToPluginProps, pluginManifestToCardPluginProps, -} from './utils' +} from '../utils' // Mock es-toolkit/compat vi.mock('es-toolkit/compat', () => ({ diff --git a/web/app/components/plugins/install-plugin/base/__tests__/check-task-status.spec.ts b/web/app/components/plugins/install-plugin/base/__tests__/check-task-status.spec.ts new file mode 100644 index 0000000000..2fd46a07cd --- /dev/null +++ b/web/app/components/plugins/install-plugin/base/__tests__/check-task-status.spec.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TaskStatus } from '../../../types' +import checkTaskStatus from '../check-task-status' + +const mockCheckTaskStatus = vi.fn() +vi.mock('@/service/plugins', () => ({ + checkTaskStatus: (...args: unknown[]) => mockCheckTaskStatus(...args), +})) + +// Mock sleep to avoid actual waiting in tests +vi.mock('@/utils', () => ({ + sleep: vi.fn().mockResolvedValue(undefined), +})) + +describe('checkTaskStatus', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns success when plugin status is success', async () => { + mockCheckTaskStatus.mockResolvedValue({ + task: { + plugins: [ + { plugin_unique_identifier: 'test-plugin', status: TaskStatus.success, message: '' }, + ], + }, + }) + + const { check } = checkTaskStatus() + const result = await check({ taskId: 'task-1', pluginUniqueIdentifier: 'test-plugin' }) + expect(result.status).toBe(TaskStatus.success) + }) + + it('returns failed when plugin status is failed', async () => { + mockCheckTaskStatus.mockResolvedValue({ + task: { + plugins: [ + { plugin_unique_identifier: 'test-plugin', status: TaskStatus.failed, message: 'Install failed' }, + ], + }, + }) + + const { check } = checkTaskStatus() + const result = await check({ taskId: 'task-1', pluginUniqueIdentifier: 'test-plugin' }) + expect(result.status).toBe(TaskStatus.failed) + expect(result.error).toBe('Install failed') + }) + + it('returns failed when plugin is not found in task', async () => { + mockCheckTaskStatus.mockResolvedValue({ + task: { + plugins: [ + { plugin_unique_identifier: 'other-plugin', status: TaskStatus.success, message: '' }, + ], + }, + }) + + const { check } = checkTaskStatus() + const result = await check({ taskId: 'task-1', pluginUniqueIdentifier: 'test-plugin' }) + expect(result.status).toBe(TaskStatus.failed) + expect(result.error).toBe('Plugin package not found') + }) + + it('polls recursively when status is running, then resolves on success', async () => { + let callCount = 0 + mockCheckTaskStatus.mockImplementation(() => { + callCount++ + if (callCount < 3) { + return Promise.resolve({ + task: { + plugins: [ + { plugin_unique_identifier: 'test-plugin', status: TaskStatus.running, message: '' }, + ], + }, + }) + } + return Promise.resolve({ + task: { + plugins: [ + { plugin_unique_identifier: 'test-plugin', status: TaskStatus.success, message: '' }, + ], + }, + }) + }) + + const { check } = checkTaskStatus() + const result = await check({ taskId: 'task-1', pluginUniqueIdentifier: 'test-plugin' }) + expect(result.status).toBe(TaskStatus.success) + expect(mockCheckTaskStatus).toHaveBeenCalledTimes(3) + }) + + it('stop() causes early return with success', async () => { + const { check, stop } = checkTaskStatus() + stop() + const result = await check({ taskId: 'task-1', pluginUniqueIdentifier: 'test-plugin' }) + expect(result.status).toBe(TaskStatus.success) + expect(mockCheckTaskStatus).not.toHaveBeenCalled() + }) + + it('returns different instances with independent state', async () => { + const checker1 = checkTaskStatus() + const checker2 = checkTaskStatus() + + checker1.stop() + + mockCheckTaskStatus.mockResolvedValue({ + task: { + plugins: [ + { plugin_unique_identifier: 'test-plugin', status: TaskStatus.success, message: '' }, + ], + }, + }) + + const result1 = await checker1.check({ taskId: 'task-1', pluginUniqueIdentifier: 'test-plugin' }) + const result2 = await checker2.check({ taskId: 'task-2', pluginUniqueIdentifier: 'test-plugin' }) + + expect(result1.status).toBe(TaskStatus.success) + expect(result2.status).toBe(TaskStatus.success) + expect(mockCheckTaskStatus).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/plugins/install-plugin/base/__tests__/installed.spec.tsx b/web/app/components/plugins/install-plugin/base/__tests__/installed.spec.tsx new file mode 100644 index 0000000000..a1d6e9ebb1 --- /dev/null +++ b/web/app/components/plugins/install-plugin/base/__tests__/installed.spec.tsx @@ -0,0 +1,81 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../../card', () => ({ + default: ({ installed, installFailed, titleLeft }: { installed: boolean, installFailed: boolean, titleLeft?: React.ReactNode }) => ( + <div data-testid="card" data-installed={installed} data-failed={installFailed}>{titleLeft}</div> + ), +})) + +vi.mock('../../utils', () => ({ + pluginManifestInMarketToPluginProps: (p: unknown) => p, + pluginManifestToCardPluginProps: (p: unknown) => p, +})) + +describe('Installed', () => { + let Installed: (typeof import('../installed'))['default'] + + beforeEach(async () => { + vi.clearAllMocks() + const mod = await import('../installed') + Installed = mod.default + }) + + it('should render success message when not failed', () => { + render(<Installed isFailed={false} onCancel={vi.fn()} />) + + expect(screen.getByText('plugin.installModal.installedSuccessfullyDesc')).toBeInTheDocument() + }) + + it('should render failure message when failed', () => { + render(<Installed isFailed={true} onCancel={vi.fn()} />) + + expect(screen.getByText('plugin.installModal.installFailedDesc')).toBeInTheDocument() + }) + + it('should render custom error message when provided', () => { + render(<Installed isFailed={true} errMsg="Custom error" onCancel={vi.fn()} />) + + expect(screen.getByText('Custom error')).toBeInTheDocument() + }) + + it('should render card with payload', () => { + const payload = { version: '1.0.0', name: 'test-plugin' } as never + render(<Installed payload={payload} isFailed={false} onCancel={vi.fn()} />) + + const card = screen.getByTestId('card') + expect(card).toHaveAttribute('data-installed', 'true') + expect(card).toHaveAttribute('data-failed', 'false') + }) + + it('should render card as failed when isFailed', () => { + const payload = { version: '1.0.0', name: 'test-plugin' } as never + render(<Installed payload={payload} isFailed={true} onCancel={vi.fn()} />) + + const card = screen.getByTestId('card') + expect(card).toHaveAttribute('data-installed', 'false') + expect(card).toHaveAttribute('data-failed', 'true') + }) + + it('should call onCancel when close button clicked', () => { + const mockOnCancel = vi.fn() + render(<Installed isFailed={false} onCancel={mockOnCancel} />) + + fireEvent.click(screen.getByText('common.operation.close')) + expect(mockOnCancel).toHaveBeenCalled() + }) + + it('should show version badge in card', () => { + const payload = { version: '1.0.0', name: 'test-plugin' } as never + render(<Installed payload={payload} isFailed={false} onCancel={vi.fn()} />) + + expect(screen.getByText('1.0.0')).toBeInTheDocument() + }) + + it('should not render card when no payload', () => { + render(<Installed isFailed={false} onCancel={vi.fn()} />) + + expect(screen.queryByTestId('card')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/install-plugin/base/__tests__/loading-error.spec.tsx b/web/app/components/plugins/install-plugin/base/__tests__/loading-error.spec.tsx new file mode 100644 index 0000000000..cfb548c602 --- /dev/null +++ b/web/app/components/plugins/install-plugin/base/__tests__/loading-error.spec.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/app/components/plugins/card/base/placeholder', () => ({ + LoadingPlaceholder: () => <div data-testid="loading-placeholder" />, +})) + +vi.mock('../../../../base/icons/src/vender/other', () => ({ + Group: ({ className }: { className: string }) => <span data-testid="group-icon" className={className} />, +})) + +describe('LoadingError', () => { + let LoadingError: React.FC + + beforeEach(async () => { + vi.clearAllMocks() + const mod = await import('../loading-error') + LoadingError = mod.default + }) + + it('should render error message', () => { + render(<LoadingError />) + + expect(screen.getByText('plugin.installModal.pluginLoadError')).toBeInTheDocument() + expect(screen.getByText('plugin.installModal.pluginLoadErrorDesc')).toBeInTheDocument() + }) + + it('should render disabled checkbox', () => { + render(<LoadingError />) + + expect(screen.getByTestId('checkbox-undefined')).toBeInTheDocument() + }) + + it('should render error icon with close indicator', () => { + render(<LoadingError />) + + expect(screen.getByTestId('group-icon')).toBeInTheDocument() + }) + + it('should render loading placeholder', () => { + render(<LoadingError />) + + expect(screen.getByTestId('loading-placeholder')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/install-plugin/base/__tests__/loading.spec.tsx b/web/app/components/plugins/install-plugin/base/__tests__/loading.spec.tsx new file mode 100644 index 0000000000..aea928f099 --- /dev/null +++ b/web/app/components/plugins/install-plugin/base/__tests__/loading.spec.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../../card/base/placeholder', () => ({ + default: () => <div data-testid="placeholder" />, +})) + +describe('Loading', () => { + let Loading: React.FC + + beforeEach(async () => { + vi.clearAllMocks() + const mod = await import('../loading') + Loading = mod.default + }) + + it('should render disabled unchecked checkbox', () => { + render(<Loading />) + + expect(screen.getByTestId('checkbox-undefined')).toBeInTheDocument() + }) + + it('should render placeholder', () => { + render(<Loading />) + + expect(screen.getByTestId('placeholder')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/install-plugin/base/__tests__/version.spec.tsx b/web/app/components/plugins/install-plugin/base/__tests__/version.spec.tsx new file mode 100644 index 0000000000..bc61d66091 --- /dev/null +++ b/web/app/components/plugins/install-plugin/base/__tests__/version.spec.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('Version', () => { + let Version: (typeof import('../version'))['default'] + + beforeEach(async () => { + vi.clearAllMocks() + const mod = await import('../version') + Version = mod.default + }) + + it('should show simple version badge for new install', () => { + render(<Version hasInstalled={false} toInstallVersion="1.0.0" />) + + expect(screen.getByText('1.0.0')).toBeInTheDocument() + }) + + it('should show upgrade version badge for existing install', () => { + render( + <Version + hasInstalled={true} + installedVersion="1.0.0" + toInstallVersion="2.0.0" + />, + ) + + expect(screen.getByText('1.0.0 -> 2.0.0')).toBeInTheDocument() + }) + + it('should handle downgrade version display', () => { + render( + <Version + hasInstalled={true} + installedVersion="2.0.0" + toInstallVersion="1.0.0" + />, + ) + + expect(screen.getByText('2.0.0 -> 1.0.0')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-check-installed.spec.tsx b/web/app/components/plugins/install-plugin/hooks/__tests__/use-check-installed.spec.tsx new file mode 100644 index 0000000000..232856e651 --- /dev/null +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-check-installed.spec.tsx @@ -0,0 +1,79 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import useCheckInstalled from '../use-check-installed' + +const mockPlugins = [ + { + plugin_id: 'plugin-1', + id: 'installed-1', + declaration: { version: '1.0.0' }, + plugin_unique_identifier: 'org/plugin-1', + }, + { + plugin_id: 'plugin-2', + id: 'installed-2', + declaration: { version: '2.0.0' }, + plugin_unique_identifier: 'org/plugin-2', + }, +] + +vi.mock('@/service/use-plugins', () => ({ + useCheckInstalled: ({ pluginIds, enabled }: { pluginIds: string[], enabled: boolean }) => ({ + data: enabled && pluginIds.length > 0 ? { plugins: mockPlugins } : undefined, + isLoading: false, + error: null, + }), +})) + +describe('useCheckInstalled', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return installed info when enabled and has plugin IDs', () => { + const { result } = renderHook(() => useCheckInstalled({ + pluginIds: ['plugin-1', 'plugin-2'], + enabled: true, + })) + + expect(result.current.installedInfo).toBeDefined() + expect(result.current.installedInfo?.['plugin-1']).toEqual({ + installedId: 'installed-1', + installedVersion: '1.0.0', + uniqueIdentifier: 'org/plugin-1', + }) + expect(result.current.installedInfo?.['plugin-2']).toEqual({ + installedId: 'installed-2', + installedVersion: '2.0.0', + uniqueIdentifier: 'org/plugin-2', + }) + }) + + it('should return undefined installedInfo when disabled', () => { + const { result } = renderHook(() => useCheckInstalled({ + pluginIds: ['plugin-1'], + enabled: false, + })) + + expect(result.current.installedInfo).toBeUndefined() + }) + + it('should return undefined installedInfo with empty plugin IDs', () => { + const { result } = renderHook(() => useCheckInstalled({ + pluginIds: [], + enabled: true, + })) + + expect(result.current.installedInfo).toBeUndefined() + }) + + it('should return isLoading and error states', () => { + const { result } = renderHook(() => useCheckInstalled({ + pluginIds: ['plugin-1'], + enabled: true, + })) + + expect(result.current.isLoading).toBe(false) + expect(result.current.error).toBeNull() + }) +}) diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-hide-logic.spec.ts b/web/app/components/plugins/install-plugin/hooks/__tests__/use-hide-logic.spec.ts new file mode 100644 index 0000000000..5cbf117c6e --- /dev/null +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-hide-logic.spec.ts @@ -0,0 +1,76 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import useHideLogic from '../use-hide-logic' + +const mockFoldAnimInto = vi.fn() +const mockClearCountDown = vi.fn() +const mockCountDownFoldIntoAnim = vi.fn() + +vi.mock('../use-fold-anim-into', () => ({ + default: () => ({ + modalClassName: 'test-modal-class', + foldIntoAnim: mockFoldAnimInto, + clearCountDown: mockClearCountDown, + countDownFoldIntoAnim: mockCountDownFoldIntoAnim, + }), +})) + +describe('useHideLogic', () => { + const mockOnClose = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return initial state with modalClassName', () => { + const { result } = renderHook(() => useHideLogic(mockOnClose)) + + expect(result.current.modalClassName).toBe('test-modal-class') + }) + + it('should call onClose directly when not installing', () => { + const { result } = renderHook(() => useHideLogic(mockOnClose)) + + act(() => { + result.current.foldAnimInto() + }) + + expect(mockOnClose).toHaveBeenCalled() + expect(mockFoldAnimInto).not.toHaveBeenCalled() + }) + + it('should call doFoldAnimInto when installing', () => { + const { result } = renderHook(() => useHideLogic(mockOnClose)) + + act(() => { + result.current.handleStartToInstall() + }) + + act(() => { + result.current.foldAnimInto() + }) + + expect(mockFoldAnimInto).toHaveBeenCalled() + expect(mockOnClose).not.toHaveBeenCalled() + }) + + it('should set installing and start countdown on handleStartToInstall', () => { + const { result } = renderHook(() => useHideLogic(mockOnClose)) + + act(() => { + result.current.handleStartToInstall() + }) + + expect(mockCountDownFoldIntoAnim).toHaveBeenCalled() + }) + + it('should clear countdown when setIsInstalling to false', () => { + const { result } = renderHook(() => useHideLogic(mockOnClose)) + + act(() => { + result.current.setIsInstalling(false) + }) + + expect(mockClearCountDown).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts b/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts new file mode 100644 index 0000000000..fa01b63b5a --- /dev/null +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts @@ -0,0 +1,149 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { InstallationScope } from '@/types/feature' +import { pluginInstallLimit } from '../use-install-plugin-limit' + +const mockSystemFeatures = { + plugin_installation_permission: { + restrict_to_marketplace_only: false, + plugin_installation_scope: InstallationScope.ALL, + }, +} + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: { systemFeatures: typeof mockSystemFeatures }) => unknown) => + selector({ systemFeatures: mockSystemFeatures }), +})) + +const basePlugin = { + from: 'marketplace' as const, + verification: { authorized_category: 'langgenius' }, +} + +describe('pluginInstallLimit', () => { + it('should allow all plugins when scope is ALL', () => { + const features = { + plugin_installation_permission: { + restrict_to_marketplace_only: false, + plugin_installation_scope: InstallationScope.ALL, + }, + } + + expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true) + }) + + it('should deny all plugins when scope is NONE', () => { + const features = { + plugin_installation_permission: { + restrict_to_marketplace_only: false, + plugin_installation_scope: InstallationScope.NONE, + }, + } + + expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(false) + }) + + it('should allow langgenius plugins when scope is OFFICIAL_ONLY', () => { + const features = { + plugin_installation_permission: { + restrict_to_marketplace_only: false, + plugin_installation_scope: InstallationScope.OFFICIAL_ONLY, + }, + } + + expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true) + }) + + it('should deny non-official plugins when scope is OFFICIAL_ONLY', () => { + const features = { + plugin_installation_permission: { + restrict_to_marketplace_only: false, + plugin_installation_scope: InstallationScope.OFFICIAL_ONLY, + }, + } + const plugin = { ...basePlugin, verification: { authorized_category: 'community' } } + + expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(false) + }) + + it('should allow partner plugins when scope is OFFICIAL_AND_PARTNER', () => { + const features = { + plugin_installation_permission: { + restrict_to_marketplace_only: false, + plugin_installation_scope: InstallationScope.OFFICIAL_AND_PARTNER, + }, + } + const plugin = { ...basePlugin, verification: { authorized_category: 'partner' } } + + expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(true) + }) + + it('should deny github plugins when restrict_to_marketplace_only is true', () => { + const features = { + plugin_installation_permission: { + restrict_to_marketplace_only: true, + plugin_installation_scope: InstallationScope.ALL, + }, + } + const plugin = { ...basePlugin, from: 'github' as const } + + expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(false) + }) + + it('should deny package plugins when restrict_to_marketplace_only is true', () => { + const features = { + plugin_installation_permission: { + restrict_to_marketplace_only: true, + plugin_installation_scope: InstallationScope.ALL, + }, + } + const plugin = { ...basePlugin, from: 'package' as const } + + expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(false) + }) + + it('should allow marketplace plugins even when restrict_to_marketplace_only is true', () => { + const features = { + plugin_installation_permission: { + restrict_to_marketplace_only: true, + plugin_installation_scope: InstallationScope.ALL, + }, + } + + expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true) + }) + + it('should default to langgenius when no verification info', () => { + const features = { + plugin_installation_permission: { + restrict_to_marketplace_only: false, + plugin_installation_scope: InstallationScope.OFFICIAL_ONLY, + }, + } + const plugin = { from: 'marketplace' as const } + + expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(true) + }) + + it('should fallback to canInstall true for unrecognized scope', () => { + const features = { + plugin_installation_permission: { + restrict_to_marketplace_only: false, + plugin_installation_scope: 'unknown-scope' as InstallationScope, + }, + } + + expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true) + }) +}) + +describe('usePluginInstallLimit', () => { + it('should return canInstall from pluginInstallLimit using global store', async () => { + const { default: usePluginInstallLimit } = await import('../use-install-plugin-limit') + const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' } } + + const { result } = renderHook(() => usePluginInstallLimit(plugin as never)) + + expect(result.current.canInstall).toBe(true) + }) +}) diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts b/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts new file mode 100644 index 0000000000..ce228d923f --- /dev/null +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts @@ -0,0 +1,168 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum } from '../../../types' + +// Mock invalidation / refresh functions +const mockInvalidateInstalledPluginList = vi.fn() +const mockRefetchLLMModelList = vi.fn() +const mockRefetchEmbeddingModelList = vi.fn() +const mockRefetchRerankModelList = vi.fn() +const mockRefreshModelProviders = vi.fn() +const mockInvalidateAllToolProviders = vi.fn() +const mockInvalidateAllBuiltInTools = vi.fn() +const mockInvalidateAllDataSources = vi.fn() +const mockInvalidateDataSourceListAuth = vi.fn() +const mockInvalidateStrategyProviders = vi.fn() +const mockInvalidateAllTriggerPlugins = vi.fn() +const mockInvalidateRAGRecommendedPlugins = vi.fn() + +vi.mock('@/service/use-plugins', () => ({ + useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({ + ModelTypeEnum: { textGeneration: 'text-generation', textEmbedding: 'text-embedding', rerank: 'rerank' }, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelList: (type: string) => { + const map: Record<string, { mutate: ReturnType<typeof vi.fn> }> = { + 'text-generation': { mutate: mockRefetchLLMModelList }, + 'text-embedding': { mutate: mockRefetchEmbeddingModelList }, + 'rerank': { mutate: mockRefetchRerankModelList }, + } + return map[type] ?? { mutate: vi.fn() } + }, +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ refreshModelProviders: mockRefreshModelProviders }), +})) + +vi.mock('@/service/use-tools', () => ({ + useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders, + useInvalidateAllBuiltInTools: () => mockInvalidateAllBuiltInTools, + useInvalidateRAGRecommendedPlugins: () => mockInvalidateRAGRecommendedPlugins, +})) + +vi.mock('@/service/use-pipeline', () => ({ + useInvalidDataSourceList: () => mockInvalidateAllDataSources, +})) + +vi.mock('@/service/use-datasource', () => ({ + useInvalidDataSourceListAuth: () => mockInvalidateDataSourceListAuth, +})) + +vi.mock('@/service/use-strategy', () => ({ + useInvalidateStrategyProviders: () => mockInvalidateStrategyProviders, +})) + +vi.mock('@/service/use-triggers', () => ({ + useInvalidateAllTriggerPlugins: () => mockInvalidateAllTriggerPlugins, +})) + +const { default: useRefreshPluginList } = await import('../use-refresh-plugin-list') + +describe('useRefreshPluginList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should always invalidate installed plugin list', () => { + const { result } = renderHook(() => useRefreshPluginList()) + + result.current.refreshPluginList() + + expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1) + }) + + it('should refresh tool providers for tool category manifest', () => { + const { result } = renderHook(() => useRefreshPluginList()) + + result.current.refreshPluginList({ category: PluginCategoryEnum.tool } as never) + + expect(mockInvalidateAllToolProviders).toHaveBeenCalledTimes(1) + expect(mockInvalidateAllBuiltInTools).toHaveBeenCalledTimes(1) + expect(mockInvalidateRAGRecommendedPlugins).toHaveBeenCalledWith('tool') + }) + + it('should refresh model lists for model category manifest', () => { + const { result } = renderHook(() => useRefreshPluginList()) + + result.current.refreshPluginList({ category: PluginCategoryEnum.model } as never) + + expect(mockRefreshModelProviders).toHaveBeenCalledTimes(1) + expect(mockRefetchLLMModelList).toHaveBeenCalledTimes(1) + expect(mockRefetchEmbeddingModelList).toHaveBeenCalledTimes(1) + expect(mockRefetchRerankModelList).toHaveBeenCalledTimes(1) + }) + + it('should refresh datasource lists for datasource category manifest', () => { + const { result } = renderHook(() => useRefreshPluginList()) + + result.current.refreshPluginList({ category: PluginCategoryEnum.datasource } as never) + + expect(mockInvalidateAllDataSources).toHaveBeenCalledTimes(1) + expect(mockInvalidateDataSourceListAuth).toHaveBeenCalledTimes(1) + }) + + it('should refresh trigger plugins for trigger category manifest', () => { + const { result } = renderHook(() => useRefreshPluginList()) + + result.current.refreshPluginList({ category: PluginCategoryEnum.trigger } as never) + + expect(mockInvalidateAllTriggerPlugins).toHaveBeenCalledTimes(1) + }) + + it('should refresh strategy providers for agent category manifest', () => { + const { result } = renderHook(() => useRefreshPluginList()) + + result.current.refreshPluginList({ category: PluginCategoryEnum.agent } as never) + + expect(mockInvalidateStrategyProviders).toHaveBeenCalledTimes(1) + }) + + it('should refresh all types when refreshAllType is true', () => { + const { result } = renderHook(() => useRefreshPluginList()) + + result.current.refreshPluginList(undefined, true) + + expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1) + expect(mockInvalidateAllToolProviders).toHaveBeenCalledTimes(1) + expect(mockInvalidateAllBuiltInTools).toHaveBeenCalledTimes(1) + expect(mockInvalidateRAGRecommendedPlugins).toHaveBeenCalledWith('tool') + expect(mockInvalidateAllTriggerPlugins).toHaveBeenCalledTimes(1) + expect(mockInvalidateAllDataSources).toHaveBeenCalledTimes(1) + expect(mockInvalidateDataSourceListAuth).toHaveBeenCalledTimes(1) + expect(mockRefreshModelProviders).toHaveBeenCalledTimes(1) + expect(mockRefetchLLMModelList).toHaveBeenCalledTimes(1) + expect(mockRefetchEmbeddingModelList).toHaveBeenCalledTimes(1) + expect(mockRefetchRerankModelList).toHaveBeenCalledTimes(1) + expect(mockInvalidateStrategyProviders).toHaveBeenCalledTimes(1) + }) + + it('should not refresh category-specific lists when manifest is null', () => { + const { result } = renderHook(() => useRefreshPluginList()) + + result.current.refreshPluginList(null) + + expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1) + expect(mockInvalidateAllToolProviders).not.toHaveBeenCalled() + expect(mockRefreshModelProviders).not.toHaveBeenCalled() + expect(mockInvalidateAllDataSources).not.toHaveBeenCalled() + expect(mockInvalidateAllTriggerPlugins).not.toHaveBeenCalled() + expect(mockInvalidateStrategyProviders).not.toHaveBeenCalled() + }) + + it('should not refresh unrelated categories for a specific manifest', () => { + const { result } = renderHook(() => useRefreshPluginList()) + + result.current.refreshPluginList({ category: PluginCategoryEnum.tool } as never) + + expect(mockInvalidateAllToolProviders).toHaveBeenCalledTimes(1) + expect(mockRefreshModelProviders).not.toHaveBeenCalled() + expect(mockInvalidateAllDataSources).not.toHaveBeenCalled() + expect(mockInvalidateAllTriggerPlugins).not.toHaveBeenCalled() + expect(mockInvalidateStrategyProviders).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-bundle/index.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/plugins/install-plugin/install-bundle/index.spec.tsx rename to web/app/components/plugins/install-plugin/install-bundle/__tests__/index.spec.tsx index 1b70cfb5c7..777a5174c6 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/index.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/__tests__/index.spec.tsx @@ -1,14 +1,14 @@ -import type { Dependency, GitHubItemAndMarketPlaceDependency, InstallStatus, PackageDependency, Plugin, PluginDeclaration, VersionProps } from '../../types' +import type { Dependency, GitHubItemAndMarketPlaceDependency, InstallStatus, PackageDependency, Plugin, PluginDeclaration, VersionProps } from '../../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { InstallStep, PluginCategoryEnum } from '../../types' -import InstallBundle, { InstallType } from './index' -import GithubItem from './item/github-item' -import LoadedItem from './item/loaded-item' -import MarketplaceItem from './item/marketplace-item' -import PackageItem from './item/package-item' -import ReadyToInstall from './ready-to-install' -import Installed from './steps/installed' +import { InstallStep, PluginCategoryEnum } from '../../../types' +import InstallBundle, { InstallType } from '../index' +import GithubItem from '../item/github-item' +import LoadedItem from '../item/loaded-item' +import MarketplaceItem from '../item/marketplace-item' +import PackageItem from '../item/package-item' +import ReadyToInstall from '../ready-to-install' +import Installed from '../steps/installed' // Factory functions for test data const createMockPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({ @@ -143,19 +143,19 @@ let mockHideLogicState = { setIsInstalling: vi.fn(), handleStartToInstall: vi.fn(), } -vi.mock('../hooks/use-hide-logic', () => ({ +vi.mock('../../hooks/use-hide-logic', () => ({ default: () => mockHideLogicState, })) // Mock useGetIcon hook -vi.mock('../base/use-get-icon', () => ({ +vi.mock('../../base/use-get-icon', () => ({ default: () => ({ getIconUrl: (icon: string) => icon || 'default-icon.png', }), })) // Mock usePluginInstallLimit hook -vi.mock('../hooks/use-install-plugin-limit', () => ({ +vi.mock('../../hooks/use-install-plugin-limit', () => ({ default: () => ({ canInstall: true }), pluginInstallLimit: () => ({ canInstall: true }), })) @@ -190,22 +190,22 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({ })) // Mock checkTaskStatus -vi.mock('../base/check-task-status', () => ({ +vi.mock('../../base/check-task-status', () => ({ default: () => ({ check: vi.fn(), stop: vi.fn() }), })) // Mock useRefreshPluginList -vi.mock('../hooks/use-refresh-plugin-list', () => ({ +vi.mock('../../hooks/use-refresh-plugin-list', () => ({ default: () => ({ refreshPluginList: vi.fn() }), })) // Mock useCheckInstalled -vi.mock('../hooks/use-check-installed', () => ({ +vi.mock('../../hooks/use-check-installed', () => ({ default: () => ({ installedInfo: {} }), })) // Mock ReadyToInstall child component to test InstallBundle in isolation -vi.mock('./ready-to-install', () => ({ +vi.mock('../ready-to-install', () => ({ default: ({ step, onStepChange, diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/install-multi.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install-multi.spec.tsx similarity index 98% rename from web/app/components/plugins/install-plugin/install-bundle/steps/install-multi.spec.tsx rename to web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install-multi.spec.tsx index 48f0703a4b..cdaa471496 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/install-multi.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install-multi.spec.tsx @@ -1,9 +1,9 @@ -import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '../../../types' +import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '../../../../types' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum } from '../../../types' -import InstallMulti from './install-multi' +import { PluginCategoryEnum } from '../../../../types' +import InstallMulti from '../install-multi' // ==================== Mock Setup ==================== @@ -62,12 +62,12 @@ vi.mock('@/context/global-public-context', () => ({ })) // Mock pluginInstallLimit -vi.mock('../../hooks/use-install-plugin-limit', () => ({ +vi.mock('../../../hooks/use-install-plugin-limit', () => ({ pluginInstallLimit: () => ({ canInstall: true }), })) // Mock child components -vi.mock('../item/github-item', () => ({ +vi.mock('../../item/github-item', () => ({ default: vi.fn().mockImplementation(({ checked, onCheckedChange, @@ -120,7 +120,7 @@ vi.mock('../item/github-item', () => ({ }), })) -vi.mock('../item/marketplace-item', () => ({ +vi.mock('../../item/marketplace-item', () => ({ default: vi.fn().mockImplementation(({ checked, onCheckedChange, @@ -142,7 +142,7 @@ vi.mock('../item/marketplace-item', () => ({ )), })) -vi.mock('../item/package-item', () => ({ +vi.mock('../../item/package-item', () => ({ default: vi.fn().mockImplementation(({ checked, onCheckedChange, @@ -163,7 +163,7 @@ vi.mock('../item/package-item', () => ({ )), })) -vi.mock('../../base/loading-error', () => ({ +vi.mock('../../../base/loading-error', () => ({ default: () => <div data-testid="loading-error">Loading Error</div>, })) diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/install.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install.spec.tsx similarity index 98% rename from web/app/components/plugins/install-plugin/install-bundle/steps/install.spec.tsx rename to web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install.spec.tsx index 435d475553..3e848b35f4 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/install.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install.spec.tsx @@ -1,8 +1,8 @@ -import type { Dependency, InstallStatusResponse, PackageDependency } from '../../../types' +import type { Dependency, InstallStatusResponse, PackageDependency } from '../../../../types' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum, TaskStatus } from '../../../types' -import Install from './install' +import { PluginCategoryEnum, TaskStatus } from '../../../../types' +import Install from '../install' // ==================== Mock Setup ==================== @@ -42,7 +42,7 @@ vi.mock('@/service/use-plugins', () => ({ // Mock checkTaskStatus const mockCheck = vi.fn() const mockStop = vi.fn() -vi.mock('../../base/check-task-status', () => ({ +vi.mock('../../../base/check-task-status', () => ({ default: () => ({ check: mockCheck, stop: mockStop, @@ -51,7 +51,7 @@ vi.mock('../../base/check-task-status', () => ({ // Mock useRefreshPluginList const mockRefreshPluginList = vi.fn() -vi.mock('../../hooks/use-refresh-plugin-list', () => ({ +vi.mock('../../../hooks/use-refresh-plugin-list', () => ({ default: () => ({ refreshPluginList: mockRefreshPluginList, }), @@ -69,7 +69,7 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({ })) // Mock InstallMulti component with forwardRef support -vi.mock('./install-multi', async () => { +vi.mock('../install-multi', async () => { const React = await import('react') const createPlugin = (index: number) => ({ @@ -838,7 +838,7 @@ describe('Install Component', () => { // ==================== Memoization Test ==================== describe('Memoization', () => { it('should be memoized', async () => { - const InstallModule = await import('./install') + const InstallModule = await import('../install') // memo returns an object with $$typeof expect(typeof InstallModule.default).toBe('object') }) diff --git a/web/app/components/plugins/install-plugin/install-from-github/index.spec.tsx b/web/app/components/plugins/install-plugin/install-from-github/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/install-plugin/install-from-github/index.spec.tsx rename to web/app/components/plugins/install-plugin/install-from-github/__tests__/index.spec.tsx index 5266f810f1..0fe6b88ed8 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/index.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/__tests__/index.spec.tsx @@ -1,9 +1,9 @@ -import type { GitHubRepoReleaseResponse, PluginDeclaration, PluginManifestInMarket, UpdateFromGitHubPayload } from '../../types' +import type { GitHubRepoReleaseResponse, PluginDeclaration, PluginManifestInMarket, UpdateFromGitHubPayload } from '../../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum } from '../../types' -import { convertRepoToUrl, parseGitHubUrl, pluginManifestInMarketToPluginProps, pluginManifestToCardPluginProps } from '../utils' -import InstallFromGitHub from './index' +import { PluginCategoryEnum } from '../../../types' +import { convertRepoToUrl, parseGitHubUrl, pluginManifestInMarketToPluginProps, pluginManifestToCardPluginProps } from '../../utils' +import InstallFromGitHub from '../index' // Factory functions for test data (defined before mocks that use them) const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({ @@ -69,12 +69,12 @@ vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({ })) const mockFetchReleases = vi.fn() -vi.mock('../hooks', () => ({ +vi.mock('../../hooks', () => ({ useGitHubReleases: () => ({ fetchReleases: mockFetchReleases }), })) const mockRefreshPluginList = vi.fn() -vi.mock('../hooks/use-refresh-plugin-list', () => ({ +vi.mock('../../hooks/use-refresh-plugin-list', () => ({ default: () => ({ refreshPluginList: mockRefreshPluginList }), })) @@ -84,12 +84,12 @@ let mockHideLogicState = { setIsInstalling: vi.fn(), handleStartToInstall: vi.fn(), } -vi.mock('../hooks/use-hide-logic', () => ({ +vi.mock('../../hooks/use-hide-logic', () => ({ default: () => mockHideLogicState, })) // Mock child components -vi.mock('./steps/setURL', () => ({ +vi.mock('../steps/setURL', () => ({ default: ({ repoUrl, onChange, onNext, onCancel }: { repoUrl: string onChange: (value: string) => void @@ -108,7 +108,7 @@ vi.mock('./steps/setURL', () => ({ ), })) -vi.mock('./steps/selectPackage', () => ({ +vi.mock('../steps/selectPackage', () => ({ default: ({ repoUrl, selectedVersion, @@ -170,7 +170,7 @@ vi.mock('./steps/selectPackage', () => ({ ), })) -vi.mock('./steps/loaded', () => ({ +vi.mock('../steps/loaded', () => ({ default: ({ uniqueIdentifier, payload, @@ -208,7 +208,7 @@ vi.mock('./steps/loaded', () => ({ ), })) -vi.mock('../base/installed', () => ({ +vi.mock('../../base/installed', () => ({ default: ({ payload, isFailed, errMsg, onCancel }: { payload: PluginDeclaration | null isFailed: boolean diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.spec.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/loaded.spec.tsx similarity index 98% rename from web/app/components/plugins/install-plugin/install-from-github/steps/loaded.spec.tsx rename to web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/loaded.spec.tsx index 3c70c35dc7..82eedad219 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/loaded.spec.tsx @@ -1,8 +1,8 @@ -import type { Plugin, PluginDeclaration, UpdateFromGitHubPayload } from '../../../types' +import type { Plugin, PluginDeclaration, UpdateFromGitHubPayload } from '../../../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum, TaskStatus } from '../../../types' -import Loaded from './loaded' +import { PluginCategoryEnum, TaskStatus } from '../../../../types' +import Loaded from '../loaded' // Mock dependencies const mockUseCheckInstalled = vi.fn() @@ -23,12 +23,12 @@ vi.mock('@/service/use-plugins', () => ({ })) const mockCheck = vi.fn() -vi.mock('../../base/check-task-status', () => ({ +vi.mock('../../../base/check-task-status', () => ({ default: () => ({ check: mockCheck }), })) // Mock Card component -vi.mock('../../../card', () => ({ +vi.mock('../../../../card', () => ({ default: ({ payload, titleLeft }: { payload: Plugin, titleLeft?: React.ReactNode }) => ( <div data-testid="plugin-card"> <span data-testid="card-name">{payload.name}</span> @@ -38,7 +38,7 @@ vi.mock('../../../card', () => ({ })) // Mock Version component -vi.mock('../../base/version', () => ({ +vi.mock('../../../base/version', () => ({ default: ({ hasInstalled, installedVersion, toInstallVersion }: { hasInstalled: boolean installedVersion?: string diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.spec.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/selectPackage.spec.tsx similarity index 99% rename from web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.spec.tsx rename to web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/selectPackage.spec.tsx index 71f0e5e497..060a5c92a1 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/selectPackage.spec.tsx @@ -1,13 +1,13 @@ -import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../types' +import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../../types' import type { Item } from '@/app/components/base/select' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum } from '../../../types' -import SelectPackage from './selectPackage' +import { PluginCategoryEnum } from '../../../../types' +import SelectPackage from '../selectPackage' // Mock the useGitHubUpload hook const mockHandleUpload = vi.fn() -vi.mock('../../hooks', () => ({ +vi.mock('../../../hooks', () => ({ useGitHubUpload: () => ({ handleUpload: mockHandleUpload }), })) diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/setURL.spec.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/setURL.spec.tsx similarity index 99% rename from web/app/components/plugins/install-plugin/install-from-github/steps/setURL.spec.tsx rename to web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/setURL.spec.tsx index 11fa3057e3..fca64ac096 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/steps/setURL.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/setURL.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import SetURL from './setURL' +import SetURL from '../setURL' describe('SetURL', () => { const defaultProps = { diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/index.spec.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/install-plugin/install-from-local-package/index.spec.tsx rename to web/app/components/plugins/install-plugin/install-from-local-package/__tests__/index.spec.tsx index 18225dd48d..cac6250550 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/index.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/__tests__/index.spec.tsx @@ -1,8 +1,8 @@ -import type { Dependency, PluginDeclaration } from '../../types' +import type { Dependency, PluginDeclaration } from '../../../types' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { InstallStep, PluginCategoryEnum } from '../../types' -import InstallFromLocalPackage from './index' +import { InstallStep, PluginCategoryEnum } from '../../../types' +import InstallFromLocalPackage from '../index' // Factory functions for test data const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({ @@ -64,7 +64,7 @@ let mockHideLogicState = { setIsInstalling: vi.fn(), handleStartToInstall: vi.fn(), } -vi.mock('../hooks/use-hide-logic', () => ({ +vi.mock('../../hooks/use-hide-logic', () => ({ default: () => mockHideLogicState, })) @@ -73,7 +73,7 @@ let uploadingOnPackageUploaded: ((result: { uniqueIdentifier: string, manifest: let uploadingOnBundleUploaded: ((result: Dependency[]) => void) | null = null let _uploadingOnFailed: ((errorMsg: string) => void) | null = null -vi.mock('./steps/uploading', () => ({ +vi.mock('../steps/uploading', () => ({ default: ({ isBundle, file, @@ -127,7 +127,7 @@ let _packageStepChangeCallback: ((step: InstallStep) => void) | null = null let _packageSetIsInstallingCallback: ((isInstalling: boolean) => void) | null = null let _packageOnErrorCallback: ((errorMsg: string) => void) | null = null -vi.mock('./ready-to-install', () => ({ +vi.mock('../ready-to-install', () => ({ default: ({ step, onStepChange, @@ -192,7 +192,7 @@ vi.mock('./ready-to-install', () => ({ let _bundleStepChangeCallback: ((step: InstallStep) => void) | null = null let _bundleSetIsInstallingCallback: ((isInstalling: boolean) => void) | null = null -vi.mock('../install-bundle/ready-to-install', () => ({ +vi.mock('../../install-bundle/ready-to-install', () => ({ default: ({ step, onStepChange, diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/ready-to-install.spec.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/__tests__/ready-to-install.spec.tsx similarity index 98% rename from web/app/components/plugins/install-plugin/install-from-local-package/ready-to-install.spec.tsx rename to web/app/components/plugins/install-plugin/install-from-local-package/__tests__/ready-to-install.spec.tsx index 6597cccd9b..05b7625d02 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/ready-to-install.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/__tests__/ready-to-install.spec.tsx @@ -1,8 +1,8 @@ -import type { PluginDeclaration } from '../../types' +import type { PluginDeclaration } from '../../../types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { InstallStep, PluginCategoryEnum } from '../../types' -import ReadyToInstall from './ready-to-install' +import { InstallStep, PluginCategoryEnum } from '../../../types' +import ReadyToInstall from '../ready-to-install' // Factory function for test data const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({ @@ -29,7 +29,7 @@ const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginD // Mock external dependencies const mockRefreshPluginList = vi.fn() -vi.mock('../hooks/use-refresh-plugin-list', () => ({ +vi.mock('../../hooks/use-refresh-plugin-list', () => ({ default: () => ({ refreshPluginList: mockRefreshPluginList, }), @@ -41,7 +41,7 @@ let _installOnFailed: ((message?: string) => void) | null = null let _installOnCancel: (() => void) | null = null let _installOnStartToInstall: (() => void) | null = null -vi.mock('./steps/install', () => ({ +vi.mock('../steps/install', () => ({ default: ({ uniqueIdentifier, payload, @@ -87,7 +87,7 @@ vi.mock('./steps/install', () => ({ })) // Mock Installed component -vi.mock('../base/installed', () => ({ +vi.mock('../../base/installed', () => ({ default: ({ payload, isFailed, diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.spec.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/install.spec.tsx similarity index 95% rename from web/app/components/plugins/install-plugin/install-from-local-package/steps/install.spec.tsx rename to web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/install.spec.tsx index 7f95eb0b35..8fa27a4c97 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/install.spec.tsx @@ -1,8 +1,8 @@ -import type { PluginDeclaration } from '../../../types' +import type { PluginDeclaration } from '../../../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum, TaskStatus } from '../../../types' -import Install from './install' +import { PluginCategoryEnum, TaskStatus } from '../../../../types' +import Install from '../install' // Factory function for test data const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({ @@ -50,7 +50,7 @@ vi.mock('@/service/plugins', () => ({ const mockCheck = vi.fn() const mockStop = vi.fn() -vi.mock('../../base/check-task-status', () => ({ +vi.mock('../../../base/check-task-status', () => ({ default: () => ({ check: mockCheck, stop: mockStop, @@ -64,22 +64,7 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('react-i18next', async (importOriginal) => { - const actual = await importOriginal<typeof import('react-i18next')>() - const { createReactI18nextMock } = await import('@/test/i18n-mock') - return { - ...actual, - ...createReactI18nextMock(), - Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record<string, React.ReactNode> }) => ( - <span data-testid="trans"> - {i18nKey} - {components?.trustSource} - </span> - ), - } -}) - -vi.mock('../../../card', () => ({ +vi.mock('../../../../card', () => ({ default: ({ payload, titleLeft }: { payload: Record<string, unknown> titleLeft?: React.ReactNode @@ -91,7 +76,7 @@ vi.mock('../../../card', () => ({ ), })) -vi.mock('../../base/version', () => ({ +vi.mock('../../../base/version', () => ({ default: ({ hasInstalled, installedVersion, toInstallVersion }: { hasInstalled: boolean installedVersion?: string @@ -105,7 +90,7 @@ vi.mock('../../base/version', () => ({ ), })) -vi.mock('../../utils', () => ({ +vi.mock('../../../utils', () => ({ pluginManifestToCardPluginProps: (manifest: PluginDeclaration) => ({ name: manifest.name, author: manifest.author, @@ -148,7 +133,7 @@ describe('Install', () => { it('should render trust source message', () => { render(<Install {...defaultProps} />) - expect(screen.getByTestId('trans')).toBeInTheDocument() + expect(screen.getByText('installModal.fromTrustSource')).toBeInTheDocument() }) it('should render plugin card', () => { diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.spec.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/uploading.spec.tsx similarity index 98% rename from web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.spec.tsx rename to web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/uploading.spec.tsx index 35256b6633..aace5dcbe9 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/uploading.spec.tsx @@ -1,9 +1,9 @@ -import type { Dependency, PluginDeclaration } from '../../../types' +import type { Dependency, PluginDeclaration } from '../../../../types' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum } from '../../../types' -import Uploading from './uploading' +import { PluginCategoryEnum } from '../../../../types' +import Uploading from '../uploading' // Factory function for test data const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({ @@ -48,7 +48,7 @@ vi.mock('@/service/plugins', () => ({ uploadFile: (...args: unknown[]) => mockUploadFile(...args), })) -vi.mock('../../../card', () => ({ +vi.mock('../../../../card', () => ({ default: ({ payload, isLoading, loadingFileName }: { payload: { name: string } isLoading?: boolean diff --git a/web/app/components/plugins/install-plugin/install-from-marketplace/index.spec.tsx b/web/app/components/plugins/install-plugin/install-from-marketplace/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/plugins/install-plugin/install-from-marketplace/index.spec.tsx rename to web/app/components/plugins/install-plugin/install-from-marketplace/__tests__/index.spec.tsx index b844c14147..18fa634202 100644 --- a/web/app/components/plugins/install-plugin/install-from-marketplace/index.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-marketplace/__tests__/index.spec.tsx @@ -1,8 +1,8 @@ -import type { Dependency, Plugin, PluginManifestInMarket } from '../../types' +import type { Dependency, Plugin, PluginManifestInMarket } from '../../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { InstallStep, PluginCategoryEnum } from '../../types' -import InstallFromMarketplace from './index' +import { InstallStep, PluginCategoryEnum } from '../../../types' +import InstallFromMarketplace from '../index' // Factory functions for test data // Use type casting to avoid strict locale requirements in tests @@ -69,7 +69,7 @@ const createMockDependencies = (): Dependency[] => [ // Mock external dependencies const mockRefreshPluginList = vi.fn() -vi.mock('../hooks/use-refresh-plugin-list', () => ({ +vi.mock('../../hooks/use-refresh-plugin-list', () => ({ default: () => ({ refreshPluginList: mockRefreshPluginList }), })) @@ -79,12 +79,12 @@ let mockHideLogicState = { setIsInstalling: vi.fn(), handleStartToInstall: vi.fn(), } -vi.mock('../hooks/use-hide-logic', () => ({ +vi.mock('../../hooks/use-hide-logic', () => ({ default: () => mockHideLogicState, })) // Mock child components -vi.mock('./steps/install', () => ({ +vi.mock('../steps/install', () => ({ default: ({ uniqueIdentifier, payload, @@ -113,7 +113,7 @@ vi.mock('./steps/install', () => ({ ), })) -vi.mock('../install-bundle/ready-to-install', () => ({ +vi.mock('../../install-bundle/ready-to-install', () => ({ default: ({ step, onStepChange, @@ -145,7 +145,7 @@ vi.mock('../install-bundle/ready-to-install', () => ({ ), })) -vi.mock('../base/installed', () => ({ +vi.mock('../../base/installed', () => ({ default: ({ payload, isMarketPayload, diff --git a/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.spec.tsx b/web/app/components/plugins/install-plugin/install-from-marketplace/steps/__tests__/install.spec.tsx similarity index 96% rename from web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.spec.tsx rename to web/app/components/plugins/install-plugin/install-from-marketplace/steps/__tests__/install.spec.tsx index b283f0ebe8..93da618486 100644 --- a/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-marketplace/steps/__tests__/install.spec.tsx @@ -1,9 +1,9 @@ -import type { Plugin, PluginManifestInMarket } from '../../../types' +import type { Plugin, PluginManifestInMarket } from '../../../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { act } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum, TaskStatus } from '../../../types' -import Install from './install' +import { PluginCategoryEnum, TaskStatus } from '../../../../types' +import Install from '../install' // Factory functions for test data const createMockManifest = (overrides: Partial<PluginManifestInMarket> = {}): PluginManifestInMarket => ({ @@ -64,7 +64,7 @@ let mockLangGeniusVersionInfo = { current_version: '1.0.0' } // Mock useCheckInstalled vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({ - default: ({ pluginIds }: { pluginIds: string[], enabled: boolean }) => ({ + default: ({ pluginIds: _pluginIds }: { pluginIds: string[], enabled: boolean }) => ({ installedInfo: mockInstalledInfo, isLoading: mockIsLoading, error: null, @@ -88,7 +88,7 @@ vi.mock('@/service/use-plugins', () => ({ })) // Mock checkTaskStatus -vi.mock('../../base/check-task-status', () => ({ +vi.mock('../../../base/check-task-status', () => ({ default: () => ({ check: mockCheckTaskStatus, stop: mockStopTaskStatus, @@ -103,20 +103,20 @@ vi.mock('@/context/app-context', () => ({ })) // Mock useInstallPluginLimit -vi.mock('../../hooks/use-install-plugin-limit', () => ({ +vi.mock('../../../hooks/use-install-plugin-limit', () => ({ default: () => ({ canInstall: mockCanInstall }), })) // Mock Card component -vi.mock('../../../card', () => ({ - default: ({ payload, titleLeft, className, limitedInstall }: { - payload: any +vi.mock('../../../../card', () => ({ + default: ({ payload, titleLeft, className: _className, limitedInstall }: { + payload: Record<string, unknown> titleLeft?: React.ReactNode className?: string limitedInstall?: boolean }) => ( <div data-testid="plugin-card"> - <span data-testid="card-payload-name">{payload?.name}</span> + <span data-testid="card-payload-name">{String(payload?.name ?? '')}</span> <span data-testid="card-limited-install">{limitedInstall ? 'true' : 'false'}</span> {!!titleLeft && <div data-testid="card-title-left">{titleLeft}</div>} </div> @@ -124,7 +124,7 @@ vi.mock('../../../card', () => ({ })) // Mock Version component -vi.mock('../../base/version', () => ({ +vi.mock('../../../base/version', () => ({ default: ({ hasInstalled, installedVersion, toInstallVersion }: { hasInstalled: boolean installedVersion?: string @@ -139,7 +139,7 @@ vi.mock('../../base/version', () => ({ })) // Mock utils -vi.mock('../../utils', () => ({ +vi.mock('../../../utils', () => ({ pluginManifestInMarketToPluginProps: (payload: PluginManifestInMarket) => ({ name: payload.name, icon: payload.icon, @@ -255,7 +255,7 @@ describe('Install Component (steps/install.tsx)', () => { }) it('should fallback to latest_version when version is undefined', () => { - const manifest = createMockManifest({ version: undefined as any, latest_version: '3.0.0' }) + const manifest = createMockManifest({ version: undefined as unknown as string, latest_version: '3.0.0' }) render(<Install {...defaultProps} payload={manifest} />) expect(screen.getByTestId('to-install-version')).toHaveTextContent('3.0.0') @@ -701,7 +701,7 @@ describe('Install Component (steps/install.tsx)', () => { }) it('should handle null current_version in langGeniusVersionInfo', () => { - mockLangGeniusVersionInfo = { current_version: null as any } + mockLangGeniusVersionInfo = { current_version: null as unknown as string } mockPluginDeclaration = { manifest: { meta: { minimum_dify_version: '1.0.0' } }, } diff --git a/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx b/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx new file mode 100644 index 0000000000..ddbef3542a --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx @@ -0,0 +1,601 @@ +import { render, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// ================================ +// Mock External Dependencies +// ================================ + +vi.mock('@/i18n-config/i18next-config', () => ({ + default: { + getFixedT: () => (key: string) => key, + }, +})) + +const mockSetUrlFilters = vi.fn() +vi.mock('@/hooks/use-query-params', () => ({ + useMarketplaceFilters: () => [ + { q: '', tags: [], category: '' }, + mockSetUrlFilters, + ], +})) + +vi.mock('@/service/use-plugins', () => ({ + useInstalledPluginList: () => ({ + data: { plugins: [] }, + isSuccess: true, + }), +})) + +const mockFetchNextPage = vi.fn() +const mockHasNextPage = false +let mockInfiniteQueryData: { pages: Array<{ plugins: unknown[], total: number, page: number, page_size: number }> } | undefined +let capturedInfiniteQueryFn: ((ctx: { pageParam: number, signal: AbortSignal }) => Promise<unknown>) | null = null +let capturedQueryFn: ((ctx: { signal: AbortSignal }) => Promise<unknown>) | null = null +let capturedGetNextPageParam: ((lastPage: { page: number, page_size: number, total: number }) => number | undefined) | null = null + +vi.mock('@tanstack/react-query', () => ({ + useQuery: vi.fn(({ queryFn, enabled }: { queryFn: (ctx: { signal: AbortSignal }) => Promise<unknown>, enabled: boolean }) => { + capturedQueryFn = queryFn + if (queryFn) { + const controller = new AbortController() + queryFn({ signal: controller.signal }).catch(() => {}) + } + return { + data: enabled ? { marketplaceCollections: [], marketplaceCollectionPluginsMap: {} } : undefined, + isFetching: false, + isPending: false, + isSuccess: enabled, + } + }), + useInfiniteQuery: vi.fn(({ queryFn, getNextPageParam }: { + queryFn: (ctx: { pageParam: number, signal: AbortSignal }) => Promise<unknown> + getNextPageParam: (lastPage: { page: number, page_size: number, total: number }) => number | undefined + enabled: boolean + }) => { + capturedInfiniteQueryFn = queryFn + capturedGetNextPageParam = getNextPageParam + if (queryFn) { + const controller = new AbortController() + queryFn({ pageParam: 1, signal: controller.signal }).catch(() => {}) + } + if (getNextPageParam) { + getNextPageParam({ page: 1, page_size: 40, total: 100 }) + getNextPageParam({ page: 3, page_size: 40, total: 100 }) + } + return { + data: mockInfiniteQueryData, + isPending: false, + isFetching: false, + isFetchingNextPage: false, + hasNextPage: mockHasNextPage, + fetchNextPage: mockFetchNextPage, + } + }), + useQueryClient: vi.fn(() => ({ + removeQueries: vi.fn(), + })), +})) + +vi.mock('ahooks', () => ({ + useDebounceFn: (fn: (...args: unknown[]) => void) => ({ + run: fn, + cancel: vi.fn(), + }), +})) + +let mockPostMarketplaceShouldFail = false +const mockPostMarketplaceResponse = { + data: { + plugins: [ + { type: 'plugin', org: 'test', name: 'plugin1', tags: [] }, + { type: 'plugin', org: 'test', name: 'plugin2', tags: [] }, + ], + bundles: [] as Array<{ type: string, org: string, name: string, tags: unknown[] }>, + total: 2, + }, +} + +vi.mock('@/service/base', () => ({ + postMarketplace: vi.fn(() => { + if (mockPostMarketplaceShouldFail) + return Promise.reject(new Error('Mock API error')) + return Promise.resolve(mockPostMarketplaceResponse) + }), +})) + +vi.mock('@/config', () => ({ + API_PREFIX: '/api', + APP_VERSION: '1.0.0', + IS_MARKETPLACE: false, + MARKETPLACE_API_PREFIX: 'https://marketplace.dify.ai/api/v1', +})) + +vi.mock('@/utils/var', () => ({ + getMarketplaceUrl: (path: string) => `https://marketplace.dify.ai${path}`, +})) + +vi.mock('@/service/client', () => ({ + marketplaceClient: { + collections: vi.fn(async () => ({ + data: { + collections: [ + { + name: 'collection-1', + label: { 'en-US': 'Collection 1' }, + description: { 'en-US': 'Desc' }, + rule: '', + created_at: '2024-01-01', + updated_at: '2024-01-01', + searchable: true, + search_params: { query: '', sort_by: 'install_count', sort_order: 'DESC' }, + }, + ], + }, + })), + collectionPlugins: vi.fn(async () => ({ + data: { + plugins: [ + { type: 'plugin', org: 'test', name: 'plugin1', tags: [] }, + ], + }, + })), + searchAdvanced: vi.fn(async () => ({ + data: { + plugins: [ + { type: 'plugin', org: 'test', name: 'plugin1', tags: [] }, + ], + total: 1, + }, + })), + }, +})) + +// ================================ +// useMarketplaceCollectionsAndPlugins Tests +// ================================ +describe('useMarketplaceCollectionsAndPlugins', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return initial state correctly', async () => { + const { useMarketplaceCollectionsAndPlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + + expect(result.current.isLoading).toBe(false) + expect(result.current.isSuccess).toBe(false) + expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined() + expect(result.current.setMarketplaceCollections).toBeDefined() + expect(result.current.setMarketplaceCollectionPluginsMap).toBeDefined() + }) + + it('should provide queryMarketplaceCollectionsAndPlugins function', async () => { + const { useMarketplaceCollectionsAndPlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + expect(typeof result.current.queryMarketplaceCollectionsAndPlugins).toBe('function') + }) + + it('should provide setMarketplaceCollections function', async () => { + const { useMarketplaceCollectionsAndPlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + expect(typeof result.current.setMarketplaceCollections).toBe('function') + }) + + it('should provide setMarketplaceCollectionPluginsMap function', async () => { + const { useMarketplaceCollectionsAndPlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + expect(typeof result.current.setMarketplaceCollectionPluginsMap).toBe('function') + }) + + it('should return marketplaceCollections from data or override', async () => { + const { useMarketplaceCollectionsAndPlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + expect(result.current.marketplaceCollections).toBeUndefined() + }) + + it('should return marketplaceCollectionPluginsMap from data or override', async () => { + const { useMarketplaceCollectionsAndPlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + expect(result.current.marketplaceCollectionPluginsMap).toBeUndefined() + }) +}) + +// ================================ +// useMarketplacePluginsByCollectionId Tests +// ================================ +describe('useMarketplacePluginsByCollectionId', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return initial state when collectionId is undefined', async () => { + const { useMarketplacePluginsByCollectionId } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePluginsByCollectionId(undefined)) + expect(result.current.plugins).toEqual([]) + expect(result.current.isLoading).toBe(false) + expect(result.current.isSuccess).toBe(false) + }) + + it('should return isLoading false when collectionId is provided and query completes', async () => { + const { useMarketplacePluginsByCollectionId } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePluginsByCollectionId('test-collection')) + expect(result.current.isLoading).toBe(false) + }) + + it('should accept query parameter', async () => { + const { useMarketplacePluginsByCollectionId } = await import('../hooks') + const { result } = renderHook(() => + useMarketplacePluginsByCollectionId('test-collection', { + category: 'tool', + type: 'plugin', + })) + expect(result.current.plugins).toBeDefined() + }) + + it('should return plugins property from hook', async () => { + const { useMarketplacePluginsByCollectionId } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePluginsByCollectionId('collection-1')) + expect(result.current.plugins).toBeDefined() + }) +}) + +// ================================ +// useMarketplacePlugins Tests +// ================================ +describe('useMarketplacePlugins', () => { + beforeEach(() => { + vi.clearAllMocks() + mockInfiniteQueryData = undefined + }) + + it('should return initial state correctly', async () => { + const { useMarketplacePlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(result.current.plugins).toBeUndefined() + expect(result.current.total).toBeUndefined() + expect(result.current.isLoading).toBe(false) + expect(result.current.isFetchingNextPage).toBe(false) + expect(result.current.hasNextPage).toBe(false) + expect(result.current.page).toBe(0) + }) + + it('should provide queryPlugins function', async () => { + const { useMarketplacePlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(typeof result.current.queryPlugins).toBe('function') + }) + + it('should provide queryPluginsWithDebounced function', async () => { + const { useMarketplacePlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(typeof result.current.queryPluginsWithDebounced).toBe('function') + }) + + it('should provide cancelQueryPluginsWithDebounced function', async () => { + const { useMarketplacePlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(typeof result.current.cancelQueryPluginsWithDebounced).toBe('function') + }) + + it('should provide resetPlugins function', async () => { + const { useMarketplacePlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(typeof result.current.resetPlugins).toBe('function') + }) + + it('should provide fetchNextPage function', async () => { + const { useMarketplacePlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(typeof result.current.fetchNextPage).toBe('function') + }) + + it('should handle queryPlugins call without errors', async () => { + const { useMarketplacePlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(() => { + result.current.queryPlugins({ + query: 'test', + sort_by: 'install_count', + sort_order: 'DESC', + category: 'tool', + page_size: 20, + }) + }).not.toThrow() + }) + + it('should handle queryPlugins with bundle type', async () => { + const { useMarketplacePlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(() => { + result.current.queryPlugins({ + query: 'test', + type: 'bundle', + page_size: 40, + }) + }).not.toThrow() + }) + + it('should handle resetPlugins call', async () => { + const { useMarketplacePlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(() => { + result.current.resetPlugins() + }).not.toThrow() + }) + + it('should handle queryPluginsWithDebounced call', async () => { + const { useMarketplacePlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(() => { + result.current.queryPluginsWithDebounced({ + query: 'debounced search', + category: 'all', + }) + }).not.toThrow() + }) + + it('should handle cancelQueryPluginsWithDebounced call', async () => { + const { useMarketplacePlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(() => { + result.current.cancelQueryPluginsWithDebounced() + }).not.toThrow() + }) + + it('should return correct page number', async () => { + const { useMarketplacePlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(result.current.page).toBe(0) + }) + + it('should handle queryPlugins with tags', async () => { + const { useMarketplacePlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(() => { + result.current.queryPlugins({ + query: 'test', + tags: ['search', 'image'], + exclude: ['excluded-plugin'], + }) + }).not.toThrow() + }) +}) + +// ================================ +// Hooks queryFn Coverage Tests +// ================================ +describe('Hooks queryFn Coverage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockInfiniteQueryData = undefined + mockPostMarketplaceShouldFail = false + capturedInfiniteQueryFn = null + capturedQueryFn = null + }) + + it('should cover queryFn with pages data', async () => { + mockInfiniteQueryData = { + pages: [ + { plugins: [{ name: 'plugin1' }], total: 10, page: 1, page_size: 40 }, + ], + } + + const { useMarketplacePlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + + result.current.queryPlugins({ + query: 'test', + category: 'tool', + }) + + expect(result.current).toBeDefined() + }) + + it('should expose page and total from infinite query data', async () => { + mockInfiniteQueryData = { + pages: [ + { plugins: [{ name: 'plugin1' }, { name: 'plugin2' }], total: 20, page: 1, page_size: 40 }, + { plugins: [{ name: 'plugin3' }], total: 20, page: 2, page_size: 40 }, + ], + } + + const { useMarketplacePlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + + result.current.queryPlugins({ query: 'search' }) + expect(result.current.page).toBe(2) + }) + + it('should return undefined total when no query is set', async () => { + const { useMarketplacePlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(result.current.total).toBeUndefined() + }) + + it('should directly test queryFn execution', async () => { + const { useMarketplacePlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + + result.current.queryPlugins({ + query: 'direct test', + category: 'tool', + sort_by: 'install_count', + sort_order: 'DESC', + page_size: 40, + }) + + if (capturedInfiniteQueryFn) { + const controller = new AbortController() + const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal }) + expect(response).toBeDefined() + } + }) + + it('should test queryFn with bundle type', async () => { + const { useMarketplacePlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + + result.current.queryPlugins({ + type: 'bundle', + query: 'bundle test', + }) + + if (capturedInfiniteQueryFn) { + const controller = new AbortController() + const response = await capturedInfiniteQueryFn({ pageParam: 2, signal: controller.signal }) + expect(response).toBeDefined() + } + }) + + it('should test queryFn error handling', async () => { + mockPostMarketplaceShouldFail = true + + const { useMarketplacePlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + + result.current.queryPlugins({ query: 'test that will fail' }) + + if (capturedInfiniteQueryFn) { + const controller = new AbortController() + const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal }) + expect(response).toBeDefined() + expect(response).toHaveProperty('plugins') + } + + mockPostMarketplaceShouldFail = false + }) + + it('should test useMarketplaceCollectionsAndPlugins queryFn', async () => { + const { useMarketplaceCollectionsAndPlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + + result.current.queryMarketplaceCollectionsAndPlugins({ + condition: 'category=tool', + }) + + if (capturedQueryFn) { + const controller = new AbortController() + const response = await capturedQueryFn({ signal: controller.signal }) + expect(response).toBeDefined() + } + }) + + it('should test getNextPageParam directly', async () => { + const { useMarketplacePlugins } = await import('../hooks') + renderHook(() => useMarketplacePlugins()) + + if (capturedGetNextPageParam) { + const nextPage = capturedGetNextPageParam({ page: 1, page_size: 40, total: 100 }) + expect(nextPage).toBe(2) + + const noMorePages = capturedGetNextPageParam({ page: 3, page_size: 40, total: 100 }) + expect(noMorePages).toBeUndefined() + + const atBoundary = capturedGetNextPageParam({ page: 2, page_size: 50, total: 100 }) + expect(atBoundary).toBeUndefined() + } + }) +}) + +// ================================ +// useMarketplaceContainerScroll Tests +// ================================ +describe('useMarketplaceContainerScroll', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should attach scroll event listener to container', async () => { + const mockCallback = vi.fn() + const mockContainer = document.createElement('div') + mockContainer.id = 'marketplace-container' + document.body.appendChild(mockContainer) + + const addEventListenerSpy = vi.spyOn(mockContainer, 'addEventListener') + const { useMarketplaceContainerScroll } = await import('../hooks') + + const TestComponent = () => { + useMarketplaceContainerScroll(mockCallback) + return null + } + + render(<TestComponent />) + expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)) + document.body.removeChild(mockContainer) + }) + + it('should call callback when scrolled to bottom', async () => { + const mockCallback = vi.fn() + const mockContainer = document.createElement('div') + mockContainer.id = 'scroll-test-container-hooks' + document.body.appendChild(mockContainer) + + Object.defineProperty(mockContainer, 'scrollTop', { value: 900, writable: true }) + Object.defineProperty(mockContainer, 'scrollHeight', { value: 1000, writable: true }) + Object.defineProperty(mockContainer, 'clientHeight', { value: 100, writable: true }) + + const { useMarketplaceContainerScroll } = await import('../hooks') + + const TestComponent = () => { + useMarketplaceContainerScroll(mockCallback, 'scroll-test-container-hooks') + return null + } + + render(<TestComponent />) + + const scrollEvent = new Event('scroll') + Object.defineProperty(scrollEvent, 'target', { value: mockContainer }) + mockContainer.dispatchEvent(scrollEvent) + + expect(mockCallback).toHaveBeenCalled() + document.body.removeChild(mockContainer) + }) + + it('should not call callback when scrollTop is 0', async () => { + const mockCallback = vi.fn() + const mockContainer = document.createElement('div') + mockContainer.id = 'scroll-test-container-hooks-2' + document.body.appendChild(mockContainer) + + Object.defineProperty(mockContainer, 'scrollTop', { value: 0, writable: true }) + Object.defineProperty(mockContainer, 'scrollHeight', { value: 1000, writable: true }) + Object.defineProperty(mockContainer, 'clientHeight', { value: 100, writable: true }) + + const { useMarketplaceContainerScroll } = await import('../hooks') + + const TestComponent = () => { + useMarketplaceContainerScroll(mockCallback, 'scroll-test-container-hooks-2') + return null + } + + render(<TestComponent />) + + const scrollEvent = new Event('scroll') + Object.defineProperty(scrollEvent, 'target', { value: mockContainer }) + mockContainer.dispatchEvent(scrollEvent) + + expect(mockCallback).not.toHaveBeenCalled() + document.body.removeChild(mockContainer) + }) + + it('should remove event listener on unmount', async () => { + const mockCallback = vi.fn() + const mockContainer = document.createElement('div') + mockContainer.id = 'scroll-unmount-container-hooks' + document.body.appendChild(mockContainer) + + const removeEventListenerSpy = vi.spyOn(mockContainer, 'removeEventListener') + const { useMarketplaceContainerScroll } = await import('../hooks') + + const TestComponent = () => { + useMarketplaceContainerScroll(mockCallback, 'scroll-unmount-container-hooks') + return null + } + + const { unmount } = render(<TestComponent />) + unmount() + + expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)) + document.body.removeChild(mockContainer) + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/__tests__/index.spec.tsx new file mode 100644 index 0000000000..458d444370 --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/index.spec.tsx @@ -0,0 +1,15 @@ +import { describe, it } from 'vitest' + +// The Marketplace index component is an async Server Component +// that cannot be unit tested in jsdom. It is covered by integration tests. +// +// All sub-module tests have been moved to dedicated spec files: +// - constants.spec.ts (DEFAULT_SORT, SCROLL_BOTTOM_THRESHOLD, PLUGIN_TYPE_SEARCH_MAP) +// - utils.spec.ts (getPluginIconInMarketplace, getFormattedPlugin, getPluginLinkInMarketplace, etc.) +// - hooks.spec.tsx (useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, useMarketplaceContainerScroll) + +describe('Marketplace index', () => { + it('should be covered by dedicated sub-module specs', () => { + // Placeholder to document the split + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/utils.spec.ts b/web/app/components/plugins/marketplace/__tests__/utils.spec.ts new file mode 100644 index 0000000000..91beed2630 --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/utils.spec.ts @@ -0,0 +1,317 @@ +import type { Plugin } from '@/app/components/plugins/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum } from '@/app/components/plugins/types' +import { PLUGIN_TYPE_SEARCH_MAP } from '../constants' + +// Mock config +vi.mock('@/config', () => ({ + API_PREFIX: '/api', + APP_VERSION: '1.0.0', + IS_MARKETPLACE: false, + MARKETPLACE_API_PREFIX: 'https://marketplace.dify.ai/api/v1', +})) + +// Mock var utils +vi.mock('@/utils/var', () => ({ + getMarketplaceUrl: (path: string) => `https://marketplace.dify.ai${path}`, +})) + +// Mock marketplace client +const mockCollectionPlugins = vi.fn() +const mockCollections = vi.fn() +const mockSearchAdvanced = vi.fn() + +vi.mock('@/service/client', () => ({ + marketplaceClient: { + collections: (...args: unknown[]) => mockCollections(...args), + collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args), + searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args), + }, +})) + +// Factory for creating mock plugins +const createMockPlugin = (overrides?: Partial<Plugin>): Plugin => ({ + type: 'plugin', + org: 'test-org', + name: 'test-plugin', + plugin_id: 'plugin-1', + version: '1.0.0', + latest_version: '1.0.0', + latest_package_identifier: 'test-org/test-plugin:1.0.0', + icon: '/icon.png', + verified: true, + label: { 'en-US': 'Test Plugin' }, + brief: { 'en-US': 'Test plugin brief' }, + description: { 'en-US': 'Test plugin description' }, + introduction: 'Test plugin introduction', + repository: 'https://github.com/test/plugin', + category: PluginCategoryEnum.tool, + install_count: 1000, + endpoint: { settings: [] }, + tags: [{ name: 'search' }], + badges: [], + verification: { authorized_category: 'community' }, + from: 'marketplace', + ...overrides, +}) + +describe('getPluginIconInMarketplace', () => { + it('should return correct icon URL for regular plugin', async () => { + const { getPluginIconInMarketplace } = await import('../utils') + const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' }) + const iconUrl = getPluginIconInMarketplace(plugin) + expect(iconUrl).toBe('https://marketplace.dify.ai/api/v1/plugins/test-org/test-plugin/icon') + }) + + it('should return correct icon URL for bundle', async () => { + const { getPluginIconInMarketplace } = await import('../utils') + const bundle = createMockPlugin({ org: 'test-org', name: 'test-bundle', type: 'bundle' }) + const iconUrl = getPluginIconInMarketplace(bundle) + expect(iconUrl).toBe('https://marketplace.dify.ai/api/v1/bundles/test-org/test-bundle/icon') + }) +}) + +describe('getFormattedPlugin', () => { + it('should format plugin with icon URL', async () => { + const { getFormattedPlugin } = await import('../utils') + const rawPlugin = { + type: 'plugin', + org: 'test-org', + name: 'test-plugin', + tags: [{ name: 'search' }], + } as unknown as Plugin + + const formatted = getFormattedPlugin(rawPlugin) + expect(formatted.icon).toBe('https://marketplace.dify.ai/api/v1/plugins/test-org/test-plugin/icon') + }) + + it('should format bundle with additional properties', async () => { + const { getFormattedPlugin } = await import('../utils') + const rawBundle = { + type: 'bundle', + org: 'test-org', + name: 'test-bundle', + description: 'Bundle description', + labels: { 'en-US': 'Test Bundle' }, + } as unknown as Plugin + + const formatted = getFormattedPlugin(rawBundle) + expect(formatted.icon).toBe('https://marketplace.dify.ai/api/v1/bundles/test-org/test-bundle/icon') + expect(formatted.brief).toBe('Bundle description') + expect(formatted.label).toEqual({ 'en-US': 'Test Bundle' }) + }) +}) + +describe('getPluginLinkInMarketplace', () => { + it('should return correct link for regular plugin', async () => { + const { getPluginLinkInMarketplace } = await import('../utils') + const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' }) + const link = getPluginLinkInMarketplace(plugin) + expect(link).toBe('https://marketplace.dify.ai/plugins/test-org/test-plugin') + }) + + it('should return correct link for bundle', async () => { + const { getPluginLinkInMarketplace } = await import('../utils') + const bundle = createMockPlugin({ org: 'test-org', name: 'test-bundle', type: 'bundle' }) + const link = getPluginLinkInMarketplace(bundle) + expect(link).toBe('https://marketplace.dify.ai/bundles/test-org/test-bundle') + }) +}) + +describe('getPluginDetailLinkInMarketplace', () => { + it('should return correct detail link for regular plugin', async () => { + const { getPluginDetailLinkInMarketplace } = await import('../utils') + const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' }) + const link = getPluginDetailLinkInMarketplace(plugin) + expect(link).toBe('/plugins/test-org/test-plugin') + }) + + it('should return correct detail link for bundle', async () => { + const { getPluginDetailLinkInMarketplace } = await import('../utils') + const bundle = createMockPlugin({ org: 'test-org', name: 'test-bundle', type: 'bundle' }) + const link = getPluginDetailLinkInMarketplace(bundle) + expect(link).toBe('/bundles/test-org/test-bundle') + }) +}) + +describe('getMarketplaceListCondition', () => { + it('should return category condition for tool', async () => { + const { getMarketplaceListCondition } = await import('../utils') + expect(getMarketplaceListCondition(PluginCategoryEnum.tool)).toBe('category=tool') + }) + + it('should return category condition for model', async () => { + const { getMarketplaceListCondition } = await import('../utils') + expect(getMarketplaceListCondition(PluginCategoryEnum.model)).toBe('category=model') + }) + + it('should return category condition for agent', async () => { + const { getMarketplaceListCondition } = await import('../utils') + expect(getMarketplaceListCondition(PluginCategoryEnum.agent)).toBe('category=agent-strategy') + }) + + it('should return category condition for datasource', async () => { + const { getMarketplaceListCondition } = await import('../utils') + expect(getMarketplaceListCondition(PluginCategoryEnum.datasource)).toBe('category=datasource') + }) + + it('should return category condition for trigger', async () => { + const { getMarketplaceListCondition } = await import('../utils') + expect(getMarketplaceListCondition(PluginCategoryEnum.trigger)).toBe('category=trigger') + }) + + it('should return endpoint category for extension', async () => { + const { getMarketplaceListCondition } = await import('../utils') + expect(getMarketplaceListCondition(PluginCategoryEnum.extension)).toBe('category=endpoint') + }) + + it('should return type condition for bundle', async () => { + const { getMarketplaceListCondition } = await import('../utils') + expect(getMarketplaceListCondition('bundle')).toBe('type=bundle') + }) + + it('should return empty string for all', async () => { + const { getMarketplaceListCondition } = await import('../utils') + expect(getMarketplaceListCondition('all')).toBe('') + }) + + it('should return empty string for unknown type', async () => { + const { getMarketplaceListCondition } = await import('../utils') + expect(getMarketplaceListCondition('unknown')).toBe('') + }) +}) + +describe('getMarketplaceListFilterType', () => { + it('should return undefined for all', async () => { + const { getMarketplaceListFilterType } = await import('../utils') + expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.all)).toBeUndefined() + }) + + it('should return bundle for bundle', async () => { + const { getMarketplaceListFilterType } = await import('../utils') + expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.bundle)).toBe('bundle') + }) + + it('should return plugin for other categories', async () => { + const { getMarketplaceListFilterType } = await import('../utils') + expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe('plugin') + expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.model)).toBe('plugin') + expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.agent)).toBe('plugin') + }) +}) + +describe('getMarketplacePluginsByCollectionId', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should fetch plugins by collection id successfully', async () => { + const mockPlugins = [ + { type: 'plugin', org: 'test', name: 'plugin1', tags: [] }, + { type: 'plugin', org: 'test', name: 'plugin2', tags: [] }, + ] + mockCollectionPlugins.mockResolvedValueOnce({ + data: { plugins: mockPlugins }, + }) + + const { getMarketplacePluginsByCollectionId } = await import('../utils') + const result = await getMarketplacePluginsByCollectionId('test-collection', { + category: 'tool', + exclude: ['excluded-plugin'], + type: 'plugin', + }) + + expect(mockCollectionPlugins).toHaveBeenCalled() + expect(result).toHaveLength(2) + }) + + it('should handle fetch error and return empty array', async () => { + mockCollectionPlugins.mockRejectedValueOnce(new Error('Network error')) + + const { getMarketplacePluginsByCollectionId } = await import('../utils') + const result = await getMarketplacePluginsByCollectionId('test-collection') + + expect(result).toEqual([]) + }) + + it('should pass abort signal when provided', async () => { + const mockPlugins = [{ type: 'plugin', org: 'test', name: 'plugin1' }] + mockCollectionPlugins.mockResolvedValueOnce({ + data: { plugins: mockPlugins }, + }) + + const controller = new AbortController() + const { getMarketplacePluginsByCollectionId } = await import('../utils') + await getMarketplacePluginsByCollectionId('test-collection', {}, { signal: controller.signal }) + + expect(mockCollectionPlugins).toHaveBeenCalled() + const call = mockCollectionPlugins.mock.calls[0] + expect(call[1]).toMatchObject({ signal: controller.signal }) + }) +}) + +describe('getMarketplaceCollectionsAndPlugins', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should fetch collections and plugins successfully', async () => { + const mockCollectionData = [ + { name: 'collection1', label: {}, description: {}, rule: '', created_at: '', updated_at: '' }, + ] + const mockPluginData = [{ type: 'plugin', org: 'test', name: 'plugin1' }] + + mockCollections.mockResolvedValueOnce({ data: { collections: mockCollectionData } }) + mockCollectionPlugins.mockResolvedValue({ data: { plugins: mockPluginData } }) + + const { getMarketplaceCollectionsAndPlugins } = await import('../utils') + const result = await getMarketplaceCollectionsAndPlugins({ + condition: 'category=tool', + type: 'plugin', + }) + + expect(result.marketplaceCollections).toBeDefined() + expect(result.marketplaceCollectionPluginsMap).toBeDefined() + }) + + it('should handle fetch error and return empty data', async () => { + mockCollections.mockRejectedValueOnce(new Error('Network error')) + + const { getMarketplaceCollectionsAndPlugins } = await import('../utils') + const result = await getMarketplaceCollectionsAndPlugins() + + expect(result.marketplaceCollections).toEqual([]) + expect(result.marketplaceCollectionPluginsMap).toEqual({}) + }) + + it('should append condition and type to URL when provided', async () => { + mockCollections.mockResolvedValueOnce({ data: { collections: [] } }) + + const { getMarketplaceCollectionsAndPlugins } = await import('../utils') + await getMarketplaceCollectionsAndPlugins({ + condition: 'category=tool', + type: 'bundle', + }) + + expect(mockCollections).toHaveBeenCalled() + const call = mockCollections.mock.calls[0] + expect(call[0]).toMatchObject({ query: expect.objectContaining({ condition: 'category=tool', type: 'bundle' }) }) + }) +}) + +describe('getCollectionsParams', () => { + it('should return empty object for all category', async () => { + const { getCollectionsParams } = await import('../utils') + expect(getCollectionsParams(PLUGIN_TYPE_SEARCH_MAP.all)).toEqual({}) + }) + + it('should return category, condition, and type for tool category', async () => { + const { getCollectionsParams } = await import('../utils') + const result = getCollectionsParams(PLUGIN_TYPE_SEARCH_MAP.tool) + expect(result).toEqual({ + category: PluginCategoryEnum.tool, + condition: 'category=tool', + type: 'plugin', + }) + }) +}) diff --git a/web/app/components/plugins/marketplace/description/index.spec.tsx b/web/app/components/plugins/marketplace/description/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/marketplace/description/index.spec.tsx rename to web/app/components/plugins/marketplace/description/__tests__/index.spec.tsx index 054949ee1f..8d7cb6f435 100644 --- a/web/app/components/plugins/marketplace/description/index.spec.tsx +++ b/web/app/components/plugins/marketplace/description/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Description from './index' +import Description from '../index' // ================================ // Mock external dependencies diff --git a/web/app/components/plugins/marketplace/empty/index.spec.tsx b/web/app/components/plugins/marketplace/empty/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/marketplace/empty/index.spec.tsx rename to web/app/components/plugins/marketplace/empty/__tests__/index.spec.tsx index bc8e701dfc..7202907b50 100644 --- a/web/app/components/plugins/marketplace/empty/index.spec.tsx +++ b/web/app/components/plugins/marketplace/empty/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Empty from './index' -import Line from './line' +import Empty from '../index' +import Line from '../line' // ================================ // Mock external dependencies only diff --git a/web/app/components/plugins/marketplace/hooks.spec.tsx b/web/app/components/plugins/marketplace/hooks.spec.tsx new file mode 100644 index 0000000000..89abbe5025 --- /dev/null +++ b/web/app/components/plugins/marketplace/hooks.spec.tsx @@ -0,0 +1,597 @@ +import { render, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n-config/i18next-config', () => ({ + default: { + getFixedT: () => (key: string) => key, + }, +})) + +const mockSetUrlFilters = vi.fn() +vi.mock('@/hooks/use-query-params', () => ({ + useMarketplaceFilters: () => [ + { q: '', tags: [], category: '' }, + mockSetUrlFilters, + ], +})) + +vi.mock('@/service/use-plugins', () => ({ + useInstalledPluginList: () => ({ + data: { plugins: [] }, + isSuccess: true, + }), +})) + +const mockFetchNextPage = vi.fn() +const mockHasNextPage = false +let mockInfiniteQueryData: { pages: Array<{ plugins: unknown[], total: number, page: number, page_size: number }> } | undefined +let capturedInfiniteQueryFn: ((ctx: { pageParam: number, signal: AbortSignal }) => Promise<unknown>) | null = null +let capturedQueryFn: ((ctx: { signal: AbortSignal }) => Promise<unknown>) | null = null +let capturedGetNextPageParam: ((lastPage: { page: number, page_size: number, total: number }) => number | undefined) | null = null + +vi.mock('@tanstack/react-query', () => ({ + useQuery: vi.fn(({ queryFn, enabled }: { queryFn: (ctx: { signal: AbortSignal }) => Promise<unknown>, enabled: boolean }) => { + capturedQueryFn = queryFn + if (queryFn) { + const controller = new AbortController() + queryFn({ signal: controller.signal }).catch(() => {}) + } + return { + data: enabled ? { marketplaceCollections: [], marketplaceCollectionPluginsMap: {} } : undefined, + isFetching: false, + isPending: false, + isSuccess: enabled, + } + }), + useInfiniteQuery: vi.fn(({ queryFn, getNextPageParam }: { + queryFn: (ctx: { pageParam: number, signal: AbortSignal }) => Promise<unknown> + getNextPageParam: (lastPage: { page: number, page_size: number, total: number }) => number | undefined + enabled: boolean + }) => { + capturedInfiniteQueryFn = queryFn + capturedGetNextPageParam = getNextPageParam + if (queryFn) { + const controller = new AbortController() + queryFn({ pageParam: 1, signal: controller.signal }).catch(() => {}) + } + if (getNextPageParam) { + getNextPageParam({ page: 1, page_size: 40, total: 100 }) + getNextPageParam({ page: 3, page_size: 40, total: 100 }) + } + return { + data: mockInfiniteQueryData, + isPending: false, + isFetching: false, + isFetchingNextPage: false, + hasNextPage: mockHasNextPage, + fetchNextPage: mockFetchNextPage, + } + }), + useQueryClient: vi.fn(() => ({ + removeQueries: vi.fn(), + })), +})) + +vi.mock('ahooks', () => ({ + useDebounceFn: (fn: (...args: unknown[]) => void) => ({ + run: fn, + cancel: vi.fn(), + }), +})) + +let mockPostMarketplaceShouldFail = false +const mockPostMarketplaceResponse = { + data: { + plugins: [ + { type: 'plugin', org: 'test', name: 'plugin1', tags: [] }, + { type: 'plugin', org: 'test', name: 'plugin2', tags: [] }, + ], + bundles: [] as Array<{ type: string, org: string, name: string, tags: unknown[] }>, + total: 2, + }, +} + +vi.mock('@/service/base', () => ({ + postMarketplace: vi.fn(() => { + if (mockPostMarketplaceShouldFail) + return Promise.reject(new Error('Mock API error')) + return Promise.resolve(mockPostMarketplaceResponse) + }), +})) + +vi.mock('@/config', () => ({ + API_PREFIX: '/api', + APP_VERSION: '1.0.0', + IS_MARKETPLACE: false, + MARKETPLACE_API_PREFIX: 'https://marketplace.dify.ai/api/v1', +})) + +vi.mock('@/utils/var', () => ({ + getMarketplaceUrl: (path: string) => `https://marketplace.dify.ai${path}`, +})) + +vi.mock('@/service/client', () => ({ + marketplaceClient: { + collections: vi.fn(async () => ({ + data: { + collections: [ + { + name: 'collection-1', + label: { 'en-US': 'Collection 1' }, + description: { 'en-US': 'Desc' }, + rule: '', + created_at: '2024-01-01', + updated_at: '2024-01-01', + searchable: true, + search_params: { query: '', sort_by: 'install_count', sort_order: 'DESC' }, + }, + ], + }, + })), + collectionPlugins: vi.fn(async () => ({ + data: { + plugins: [ + { type: 'plugin', org: 'test', name: 'plugin1', tags: [] }, + ], + }, + })), + searchAdvanced: vi.fn(async () => ({ + data: { + plugins: [ + { type: 'plugin', org: 'test', name: 'plugin1', tags: [] }, + ], + total: 1, + }, + })), + }, +})) + +// ================================ +// useMarketplaceCollectionsAndPlugins Tests +// ================================ +describe('useMarketplaceCollectionsAndPlugins', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return initial state correctly', async () => { + const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + + expect(result.current.isLoading).toBe(false) + expect(result.current.isSuccess).toBe(false) + expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined() + expect(result.current.setMarketplaceCollections).toBeDefined() + expect(result.current.setMarketplaceCollectionPluginsMap).toBeDefined() + }) + + it('should provide queryMarketplaceCollectionsAndPlugins function', async () => { + const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + expect(typeof result.current.queryMarketplaceCollectionsAndPlugins).toBe('function') + }) + + it('should provide setMarketplaceCollections function', async () => { + const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + expect(typeof result.current.setMarketplaceCollections).toBe('function') + }) + + it('should provide setMarketplaceCollectionPluginsMap function', async () => { + const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + expect(typeof result.current.setMarketplaceCollectionPluginsMap).toBe('function') + }) + + it('should return marketplaceCollections from data or override', async () => { + const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + expect(result.current.marketplaceCollections).toBeUndefined() + }) + + it('should return marketplaceCollectionPluginsMap from data or override', async () => { + const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + expect(result.current.marketplaceCollectionPluginsMap).toBeUndefined() + }) +}) + +// ================================ +// useMarketplacePluginsByCollectionId Tests +// ================================ +describe('useMarketplacePluginsByCollectionId', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return initial state when collectionId is undefined', async () => { + const { useMarketplacePluginsByCollectionId } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePluginsByCollectionId(undefined)) + expect(result.current.plugins).toEqual([]) + expect(result.current.isLoading).toBe(false) + expect(result.current.isSuccess).toBe(false) + }) + + it('should return isLoading false when collectionId is provided and query completes', async () => { + const { useMarketplacePluginsByCollectionId } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePluginsByCollectionId('test-collection')) + expect(result.current.isLoading).toBe(false) + }) + + it('should accept query parameter', async () => { + const { useMarketplacePluginsByCollectionId } = await import('./hooks') + const { result } = renderHook(() => + useMarketplacePluginsByCollectionId('test-collection', { + category: 'tool', + type: 'plugin', + })) + expect(result.current.plugins).toBeDefined() + }) + + it('should return plugins property from hook', async () => { + const { useMarketplacePluginsByCollectionId } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePluginsByCollectionId('collection-1')) + expect(result.current.plugins).toBeDefined() + }) +}) + +// ================================ +// useMarketplacePlugins Tests +// ================================ +describe('useMarketplacePlugins', () => { + beforeEach(() => { + vi.clearAllMocks() + mockInfiniteQueryData = undefined + }) + + it('should return initial state correctly', async () => { + const { useMarketplacePlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(result.current.plugins).toBeUndefined() + expect(result.current.total).toBeUndefined() + expect(result.current.isLoading).toBe(false) + expect(result.current.isFetchingNextPage).toBe(false) + expect(result.current.hasNextPage).toBe(false) + expect(result.current.page).toBe(0) + }) + + it('should provide queryPlugins function', async () => { + const { useMarketplacePlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(typeof result.current.queryPlugins).toBe('function') + }) + + it('should provide queryPluginsWithDebounced function', async () => { + const { useMarketplacePlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(typeof result.current.queryPluginsWithDebounced).toBe('function') + }) + + it('should provide cancelQueryPluginsWithDebounced function', async () => { + const { useMarketplacePlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(typeof result.current.cancelQueryPluginsWithDebounced).toBe('function') + }) + + it('should provide resetPlugins function', async () => { + const { useMarketplacePlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(typeof result.current.resetPlugins).toBe('function') + }) + + it('should provide fetchNextPage function', async () => { + const { useMarketplacePlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(typeof result.current.fetchNextPage).toBe('function') + }) + + it('should handle queryPlugins call without errors', async () => { + const { useMarketplacePlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(() => { + result.current.queryPlugins({ + query: 'test', + sort_by: 'install_count', + sort_order: 'DESC', + category: 'tool', + page_size: 20, + }) + }).not.toThrow() + }) + + it('should handle queryPlugins with bundle type', async () => { + const { useMarketplacePlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(() => { + result.current.queryPlugins({ + query: 'test', + type: 'bundle', + page_size: 40, + }) + }).not.toThrow() + }) + + it('should handle resetPlugins call', async () => { + const { useMarketplacePlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(() => { + result.current.resetPlugins() + }).not.toThrow() + }) + + it('should handle queryPluginsWithDebounced call', async () => { + const { useMarketplacePlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(() => { + result.current.queryPluginsWithDebounced({ + query: 'debounced search', + category: 'all', + }) + }).not.toThrow() + }) + + it('should handle cancelQueryPluginsWithDebounced call', async () => { + const { useMarketplacePlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(() => { + result.current.cancelQueryPluginsWithDebounced() + }).not.toThrow() + }) + + it('should return correct page number', async () => { + const { useMarketplacePlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(result.current.page).toBe(0) + }) + + it('should handle queryPlugins with tags', async () => { + const { useMarketplacePlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(() => { + result.current.queryPlugins({ + query: 'test', + tags: ['search', 'image'], + exclude: ['excluded-plugin'], + }) + }).not.toThrow() + }) +}) + +// ================================ +// Hooks queryFn Coverage Tests +// ================================ +describe('Hooks queryFn Coverage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockInfiniteQueryData = undefined + mockPostMarketplaceShouldFail = false + capturedInfiniteQueryFn = null + capturedQueryFn = null + }) + + it('should cover queryFn with pages data', async () => { + mockInfiniteQueryData = { + pages: [ + { plugins: [{ name: 'plugin1' }], total: 10, page: 1, page_size: 40 }, + ], + } + + const { useMarketplacePlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + + result.current.queryPlugins({ + query: 'test', + category: 'tool', + }) + + expect(result.current).toBeDefined() + }) + + it('should expose page and total from infinite query data', async () => { + mockInfiniteQueryData = { + pages: [ + { plugins: [{ name: 'plugin1' }, { name: 'plugin2' }], total: 20, page: 1, page_size: 40 }, + { plugins: [{ name: 'plugin3' }], total: 20, page: 2, page_size: 40 }, + ], + } + + const { useMarketplacePlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + + result.current.queryPlugins({ query: 'search' }) + expect(result.current.page).toBe(2) + }) + + it('should return undefined total when no query is set', async () => { + const { useMarketplacePlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + expect(result.current.total).toBeUndefined() + }) + + it('should directly test queryFn execution', async () => { + const { useMarketplacePlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + + result.current.queryPlugins({ + query: 'direct test', + category: 'tool', + sort_by: 'install_count', + sort_order: 'DESC', + page_size: 40, + }) + + if (capturedInfiniteQueryFn) { + const controller = new AbortController() + const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal }) + expect(response).toBeDefined() + } + }) + + it('should test queryFn with bundle type', async () => { + const { useMarketplacePlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + + result.current.queryPlugins({ + type: 'bundle', + query: 'bundle test', + }) + + if (capturedInfiniteQueryFn) { + const controller = new AbortController() + const response = await capturedInfiniteQueryFn({ pageParam: 2, signal: controller.signal }) + expect(response).toBeDefined() + } + }) + + it('should test queryFn error handling', async () => { + mockPostMarketplaceShouldFail = true + + const { useMarketplacePlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplacePlugins()) + + result.current.queryPlugins({ query: 'test that will fail' }) + + if (capturedInfiniteQueryFn) { + const controller = new AbortController() + const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal }) + expect(response).toBeDefined() + expect(response).toHaveProperty('plugins') + } + + mockPostMarketplaceShouldFail = false + }) + + it('should test useMarketplaceCollectionsAndPlugins queryFn', async () => { + const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + + result.current.queryMarketplaceCollectionsAndPlugins({ + condition: 'category=tool', + }) + + if (capturedQueryFn) { + const controller = new AbortController() + const response = await capturedQueryFn({ signal: controller.signal }) + expect(response).toBeDefined() + } + }) + + it('should test getNextPageParam directly', async () => { + const { useMarketplacePlugins } = await import('./hooks') + renderHook(() => useMarketplacePlugins()) + + if (capturedGetNextPageParam) { + const nextPage = capturedGetNextPageParam({ page: 1, page_size: 40, total: 100 }) + expect(nextPage).toBe(2) + + const noMorePages = capturedGetNextPageParam({ page: 3, page_size: 40, total: 100 }) + expect(noMorePages).toBeUndefined() + + const atBoundary = capturedGetNextPageParam({ page: 2, page_size: 50, total: 100 }) + expect(atBoundary).toBeUndefined() + } + }) +}) + +// ================================ +// useMarketplaceContainerScroll Tests +// ================================ +describe('useMarketplaceContainerScroll', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should attach scroll event listener to container', async () => { + const mockCallback = vi.fn() + const mockContainer = document.createElement('div') + mockContainer.id = 'marketplace-container' + document.body.appendChild(mockContainer) + + const addEventListenerSpy = vi.spyOn(mockContainer, 'addEventListener') + const { useMarketplaceContainerScroll } = await import('./hooks') + + const TestComponent = () => { + useMarketplaceContainerScroll(mockCallback) + return null + } + + render(<TestComponent />) + expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)) + document.body.removeChild(mockContainer) + }) + + it('should call callback when scrolled to bottom', async () => { + const mockCallback = vi.fn() + const mockContainer = document.createElement('div') + mockContainer.id = 'scroll-test-container-hooks' + document.body.appendChild(mockContainer) + + Object.defineProperty(mockContainer, 'scrollTop', { value: 900, writable: true }) + Object.defineProperty(mockContainer, 'scrollHeight', { value: 1000, writable: true }) + Object.defineProperty(mockContainer, 'clientHeight', { value: 100, writable: true }) + + const { useMarketplaceContainerScroll } = await import('./hooks') + + const TestComponent = () => { + useMarketplaceContainerScroll(mockCallback, 'scroll-test-container-hooks') + return null + } + + render(<TestComponent />) + + const scrollEvent = new Event('scroll') + Object.defineProperty(scrollEvent, 'target', { value: mockContainer }) + mockContainer.dispatchEvent(scrollEvent) + + expect(mockCallback).toHaveBeenCalled() + document.body.removeChild(mockContainer) + }) + + it('should not call callback when scrollTop is 0', async () => { + const mockCallback = vi.fn() + const mockContainer = document.createElement('div') + mockContainer.id = 'scroll-test-container-hooks-2' + document.body.appendChild(mockContainer) + + Object.defineProperty(mockContainer, 'scrollTop', { value: 0, writable: true }) + Object.defineProperty(mockContainer, 'scrollHeight', { value: 1000, writable: true }) + Object.defineProperty(mockContainer, 'clientHeight', { value: 100, writable: true }) + + const { useMarketplaceContainerScroll } = await import('./hooks') + + const TestComponent = () => { + useMarketplaceContainerScroll(mockCallback, 'scroll-test-container-hooks-2') + return null + } + + render(<TestComponent />) + + const scrollEvent = new Event('scroll') + Object.defineProperty(scrollEvent, 'target', { value: mockContainer }) + mockContainer.dispatchEvent(scrollEvent) + + expect(mockCallback).not.toHaveBeenCalled() + document.body.removeChild(mockContainer) + }) + + it('should remove event listener on unmount', async () => { + const mockCallback = vi.fn() + const mockContainer = document.createElement('div') + mockContainer.id = 'scroll-unmount-container-hooks' + document.body.appendChild(mockContainer) + + const removeEventListenerSpy = vi.spyOn(mockContainer, 'removeEventListener') + const { useMarketplaceContainerScroll } = await import('./hooks') + + const TestComponent = () => { + useMarketplaceContainerScroll(mockCallback, 'scroll-unmount-container-hooks') + return null + } + + const { unmount } = render(<TestComponent />) + unmount() + + expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)) + document.body.removeChild(mockContainer) + }) +}) diff --git a/web/app/components/plugins/marketplace/index.spec.tsx b/web/app/components/plugins/marketplace/index.spec.tsx deleted file mode 100644 index 1c0c700177..0000000000 --- a/web/app/components/plugins/marketplace/index.spec.tsx +++ /dev/null @@ -1,1828 +0,0 @@ -import type { MarketplaceCollection } from './types' -import type { Plugin } from '@/app/components/plugins/types' -import { act, render, renderHook } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum } from '@/app/components/plugins/types' - -// ================================ -// Import Components After Mocks -// ================================ - -// Note: Import after mocks are set up -import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants' -import { - getFormattedPlugin, - getMarketplaceListCondition, - getMarketplaceListFilterType, - getPluginDetailLinkInMarketplace, - getPluginIconInMarketplace, - getPluginLinkInMarketplace, -} from './utils' - -// ================================ -// Mock External Dependencies Only -// ================================ - -// Mock i18next-config -vi.mock('@/i18n-config/i18next-config', () => ({ - default: { - getFixedT: (_locale: string) => (key: string, options?: Record<string, unknown>) => { - if (options && options.ns) { - return `${options.ns}.${key}` - } - else { - return key - } - }, - }, -})) - -// Mock use-query-params hook -const mockSetUrlFilters = vi.fn() -vi.mock('@/hooks/use-query-params', () => ({ - useMarketplaceFilters: () => [ - { q: '', tags: [], category: '' }, - mockSetUrlFilters, - ], -})) - -// Mock use-plugins service -const mockInstalledPluginListData = { - plugins: [], -} -vi.mock('@/service/use-plugins', () => ({ - useInstalledPluginList: (_enabled: boolean) => ({ - data: mockInstalledPluginListData, - isSuccess: true, - }), -})) - -// Mock tanstack query -const mockFetchNextPage = vi.fn() -const mockHasNextPage = false -let mockInfiniteQueryData: { pages: Array<{ plugins: unknown[], total: number, page: number, page_size: number }> } | undefined -let capturedInfiniteQueryFn: ((ctx: { pageParam: number, signal: AbortSignal }) => Promise<unknown>) | null = null -let capturedQueryFn: ((ctx: { signal: AbortSignal }) => Promise<unknown>) | null = null -let capturedGetNextPageParam: ((lastPage: { page: number, page_size: number, total: number }) => number | undefined) | null = null - -vi.mock('@tanstack/react-query', () => ({ - useQuery: vi.fn(({ queryFn, enabled }: { queryFn: (ctx: { signal: AbortSignal }) => Promise<unknown>, enabled: boolean }) => { - // Capture queryFn for later testing - capturedQueryFn = queryFn - // Always call queryFn to increase coverage (including when enabled is false) - if (queryFn) { - const controller = new AbortController() - queryFn({ signal: controller.signal }).catch(() => {}) - } - return { - data: enabled ? { marketplaceCollections: [], marketplaceCollectionPluginsMap: {} } : undefined, - isFetching: false, - isPending: false, - isSuccess: enabled, - } - }), - useInfiniteQuery: vi.fn(({ queryFn, getNextPageParam, enabled: _enabled }: { - queryFn: (ctx: { pageParam: number, signal: AbortSignal }) => Promise<unknown> - getNextPageParam: (lastPage: { page: number, page_size: number, total: number }) => number | undefined - enabled: boolean - }) => { - // Capture queryFn and getNextPageParam for later testing - capturedInfiniteQueryFn = queryFn - capturedGetNextPageParam = getNextPageParam - // Always call queryFn to increase coverage (including when enabled is false for edge cases) - if (queryFn) { - const controller = new AbortController() - queryFn({ pageParam: 1, signal: controller.signal }).catch(() => {}) - } - // Call getNextPageParam to increase coverage - if (getNextPageParam) { - // Test with more data available - getNextPageParam({ page: 1, page_size: 40, total: 100 }) - // Test with no more data - getNextPageParam({ page: 3, page_size: 40, total: 100 }) - } - return { - data: mockInfiniteQueryData, - isPending: false, - isFetching: false, - isFetchingNextPage: false, - hasNextPage: mockHasNextPage, - fetchNextPage: mockFetchNextPage, - } - }), - useQueryClient: vi.fn(() => ({ - removeQueries: vi.fn(), - })), -})) - -// Mock ahooks -vi.mock('ahooks', () => ({ - useDebounceFn: (fn: (...args: unknown[]) => void) => ({ - run: fn, - cancel: vi.fn(), - }), -})) - -// Mock marketplace service -let mockPostMarketplaceShouldFail = false -const mockPostMarketplaceResponse: { - data: { - plugins: Array<{ type: string, org: string, name: string, tags: unknown[] }> - bundles: Array<{ type: string, org: string, name: string, tags: unknown[] }> - total: number - } -} = { - data: { - plugins: [ - { type: 'plugin', org: 'test', name: 'plugin1', tags: [] }, - { type: 'plugin', org: 'test', name: 'plugin2', tags: [] }, - ], - bundles: [], - total: 2, - }, -} -vi.mock('@/service/base', () => ({ - postMarketplace: vi.fn(() => { - if (mockPostMarketplaceShouldFail) - return Promise.reject(new Error('Mock API error')) - return Promise.resolve(mockPostMarketplaceResponse) - }), -})) - -// Mock config -vi.mock('@/config', () => ({ - API_PREFIX: '/api', - APP_VERSION: '1.0.0', - IS_MARKETPLACE: false, - MARKETPLACE_API_PREFIX: 'https://marketplace.dify.ai/api/v1', -})) - -// Mock var utils -vi.mock('@/utils/var', () => ({ - getMarketplaceUrl: (path: string, _params?: Record<string, string | undefined>) => `https://marketplace.dify.ai${path}`, -})) - -// Mock marketplace client used by marketplace utils -vi.mock('@/service/client', () => ({ - marketplaceClient: { - collections: vi.fn(async (_args?: unknown, _opts?: { signal?: AbortSignal }) => ({ - data: { - collections: [ - { - name: 'collection-1', - label: { 'en-US': 'Collection 1' }, - description: { 'en-US': 'Desc' }, - rule: '', - created_at: '2024-01-01', - updated_at: '2024-01-01', - searchable: true, - search_params: { query: '', sort_by: 'install_count', sort_order: 'DESC' }, - }, - ], - }, - })), - collectionPlugins: vi.fn(async (_args?: unknown, _opts?: { signal?: AbortSignal }) => ({ - data: { - plugins: [ - { type: 'plugin', org: 'test', name: 'plugin1', tags: [] }, - ], - }, - })), - // Some utils paths may call searchAdvanced; provide a minimal stub - searchAdvanced: vi.fn(async (_args?: unknown, _opts?: { signal?: AbortSignal }) => ({ - data: { - plugins: [ - { type: 'plugin', org: 'test', name: 'plugin1', tags: [] }, - ], - total: 1, - }, - })), - }, -})) - -// Mock context/query-client -vi.mock('@/context/query-client', () => ({ - TanstackQueryInitializer: ({ children }: { children: React.ReactNode }) => <div data-testid="query-initializer">{children}</div>, -})) - -// Mock i18n-config/server -vi.mock('@/i18n-config/server', () => ({ - getLocaleOnServer: vi.fn(() => Promise.resolve('en-US')), - getTranslation: vi.fn(() => Promise.resolve({ t: (key: string) => key })), -})) - -// Mock useTheme hook -const mockTheme = 'light' -vi.mock('@/hooks/use-theme', () => ({ - default: () => ({ - theme: mockTheme, - }), -})) - -// Mock next-themes -vi.mock('next-themes', () => ({ - useTheme: () => ({ - theme: mockTheme, - }), -})) - -// Mock useLocale context -vi.mock('@/context/i18n', () => ({ - useLocale: () => 'en-US', -})) - -// Mock i18n-config/language -vi.mock('@/i18n-config/language', () => ({ - getLanguage: (locale: string) => locale || 'en-US', -})) - -// Mock global fetch for utils testing -const originalFetch = globalThis.fetch - -// Mock useTags hook -const mockTags = [ - { name: 'search', label: 'Search' }, - { name: 'image', label: 'Image' }, - { name: 'agent', label: 'Agent' }, -] - -const mockTagsMap = mockTags.reduce((acc, tag) => { - acc[tag.name] = tag - return acc -}, {} as Record<string, { name: string, label: string }>) - -vi.mock('@/app/components/plugins/hooks', () => ({ - useTags: () => ({ - tags: mockTags, - tagsMap: mockTagsMap, - getTagLabel: (name: string) => { - const tag = mockTags.find(t => t.name === name) - return tag?.label || name - }, - }), -})) - -// Mock plugins utils -vi.mock('../utils', () => ({ - getValidCategoryKeys: (category: string | undefined) => category || '', - getValidTagKeys: (tags: string[] | string | undefined) => { - if (Array.isArray(tags)) - return tags - if (typeof tags === 'string') - return tags.split(',').filter(Boolean) - return [] - }, -})) - -// Mock portal-to-follow-elem with shared open state -let mockPortalOpenState = false - -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open }: { - children: React.ReactNode - open: boolean - }) => { - mockPortalOpenState = open - return ( - <div data-testid="portal-elem" data-open={open}> - {children} - </div> - ) - }, - PortalToFollowElemTrigger: ({ children, onClick, className }: { - children: React.ReactNode - onClick: () => void - className?: string - }) => ( - <div data-testid="portal-trigger" onClick={onClick} className={className}> - {children} - </div> - ), - PortalToFollowElemContent: ({ children, className }: { - children: React.ReactNode - className?: string - }) => { - if (!mockPortalOpenState) - return null - return ( - <div data-testid="portal-content" className={className}> - {children} - </div> - ) - }, -})) - -// Mock Card component -vi.mock('@/app/components/plugins/card', () => ({ - default: ({ payload, footer }: { payload: Plugin, footer?: React.ReactNode }) => ( - <div data-testid={`card-${payload.name}`}> - <div data-testid="card-name">{payload.name}</div> - {!!footer && <div data-testid="card-footer">{footer}</div>} - </div> - ), -})) - -// Mock CardMoreInfo component -vi.mock('@/app/components/plugins/card/card-more-info', () => ({ - default: ({ downloadCount, tags }: { downloadCount: number, tags: string[] }) => ( - <div data-testid="card-more-info"> - <span data-testid="download-count">{downloadCount}</span> - <span data-testid="tags">{tags.join(',')}</span> - </div> - ), -})) - -// Mock InstallFromMarketplace component -vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({ - default: ({ onClose }: { onClose: () => void }) => ( - <div data-testid="install-from-marketplace"> - <button onClick={onClose} data-testid="close-install-modal">Close</button> - </div> - ), -})) - -// Mock base icons -vi.mock('@/app/components/base/icons/src/vender/other', () => ({ - Group: ({ className }: { className?: string }) => <span data-testid="group-icon" className={className} />, -})) - -vi.mock('@/app/components/base/icons/src/vender/plugin', () => ({ - Trigger: ({ className }: { className?: string }) => <span data-testid="trigger-icon" className={className} />, -})) - -// ================================ -// Test Data Factories -// ================================ - -const createMockPlugin = (overrides?: Partial<Plugin>): Plugin => ({ - type: 'plugin', - org: 'test-org', - name: `test-plugin-${Math.random().toString(36).substring(7)}`, - plugin_id: `plugin-${Math.random().toString(36).substring(7)}`, - version: '1.0.0', - latest_version: '1.0.0', - latest_package_identifier: 'test-org/test-plugin:1.0.0', - icon: '/icon.png', - verified: true, - label: { 'en-US': 'Test Plugin' }, - brief: { 'en-US': 'Test plugin brief description' }, - description: { 'en-US': 'Test plugin full description' }, - introduction: 'Test plugin introduction', - repository: 'https://github.com/test/plugin', - category: PluginCategoryEnum.tool, - install_count: 1000, - endpoint: { settings: [] }, - tags: [{ name: 'search' }], - badges: [], - verification: { authorized_category: 'community' }, - from: 'marketplace', - ...overrides, -}) - -const createMockPluginList = (count: number): Plugin[] => - Array.from({ length: count }, (_, i) => - createMockPlugin({ - name: `plugin-${i}`, - plugin_id: `plugin-id-${i}`, - install_count: 1000 - i * 10, - })) - -const createMockCollection = (overrides?: Partial<MarketplaceCollection>): MarketplaceCollection => ({ - name: 'test-collection', - label: { 'en-US': 'Test Collection' }, - description: { 'en-US': 'Test collection description' }, - rule: 'test-rule', - created_at: '2024-01-01', - updated_at: '2024-01-01', - searchable: true, - search_params: { - query: '', - sort_by: 'install_count', - sort_order: 'DESC', - }, - ...overrides, -}) - -// ================================ -// Constants Tests -// ================================ -describe('constants', () => { - describe('DEFAULT_SORT', () => { - it('should have correct default sort values', () => { - expect(DEFAULT_SORT).toEqual({ - sortBy: 'install_count', - sortOrder: 'DESC', - }) - }) - - it('should be immutable at runtime', () => { - const originalSortBy = DEFAULT_SORT.sortBy - const originalSortOrder = DEFAULT_SORT.sortOrder - - expect(DEFAULT_SORT.sortBy).toBe(originalSortBy) - expect(DEFAULT_SORT.sortOrder).toBe(originalSortOrder) - }) - }) - - describe('SCROLL_BOTTOM_THRESHOLD', () => { - it('should be 100 pixels', () => { - expect(SCROLL_BOTTOM_THRESHOLD).toBe(100) - }) - }) -}) - -// ================================ -// PLUGIN_TYPE_SEARCH_MAP Tests -// ================================ -describe('PLUGIN_TYPE_SEARCH_MAP', () => { - it('should contain all expected keys', () => { - expect(PLUGIN_TYPE_SEARCH_MAP).toHaveProperty('all') - expect(PLUGIN_TYPE_SEARCH_MAP).toHaveProperty('model') - expect(PLUGIN_TYPE_SEARCH_MAP).toHaveProperty('tool') - expect(PLUGIN_TYPE_SEARCH_MAP).toHaveProperty('agent') - expect(PLUGIN_TYPE_SEARCH_MAP).toHaveProperty('extension') - expect(PLUGIN_TYPE_SEARCH_MAP).toHaveProperty('datasource') - expect(PLUGIN_TYPE_SEARCH_MAP).toHaveProperty('trigger') - expect(PLUGIN_TYPE_SEARCH_MAP).toHaveProperty('bundle') - }) - - it('should map to correct category enum values', () => { - expect(PLUGIN_TYPE_SEARCH_MAP.all).toBe('all') - expect(PLUGIN_TYPE_SEARCH_MAP.model).toBe(PluginCategoryEnum.model) - expect(PLUGIN_TYPE_SEARCH_MAP.tool).toBe(PluginCategoryEnum.tool) - expect(PLUGIN_TYPE_SEARCH_MAP.agent).toBe(PluginCategoryEnum.agent) - expect(PLUGIN_TYPE_SEARCH_MAP.extension).toBe(PluginCategoryEnum.extension) - expect(PLUGIN_TYPE_SEARCH_MAP.datasource).toBe(PluginCategoryEnum.datasource) - expect(PLUGIN_TYPE_SEARCH_MAP.trigger).toBe(PluginCategoryEnum.trigger) - expect(PLUGIN_TYPE_SEARCH_MAP.bundle).toBe('bundle') - }) -}) - -// ================================ -// Utils Tests -// ================================ -describe('utils', () => { - describe('getPluginIconInMarketplace', () => { - it('should return correct icon URL for regular plugin', () => { - const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' }) - const iconUrl = getPluginIconInMarketplace(plugin) - - expect(iconUrl).toBe('https://marketplace.dify.ai/api/v1/plugins/test-org/test-plugin/icon') - }) - - it('should return correct icon URL for bundle', () => { - const bundle = createMockPlugin({ org: 'test-org', name: 'test-bundle', type: 'bundle' }) - const iconUrl = getPluginIconInMarketplace(bundle) - - expect(iconUrl).toBe('https://marketplace.dify.ai/api/v1/bundles/test-org/test-bundle/icon') - }) - }) - - describe('getFormattedPlugin', () => { - it('should format plugin with icon URL', () => { - const rawPlugin = { - type: 'plugin', - org: 'test-org', - name: 'test-plugin', - tags: [{ name: 'search' }], - } as unknown as Plugin - - const formatted = getFormattedPlugin(rawPlugin) - - expect(formatted.icon).toBe('https://marketplace.dify.ai/api/v1/plugins/test-org/test-plugin/icon') - }) - - it('should format bundle with additional properties', () => { - const rawBundle = { - type: 'bundle', - org: 'test-org', - name: 'test-bundle', - description: 'Bundle description', - labels: { 'en-US': 'Test Bundle' }, - } as unknown as Plugin - - const formatted = getFormattedPlugin(rawBundle) - - expect(formatted.icon).toBe('https://marketplace.dify.ai/api/v1/bundles/test-org/test-bundle/icon') - expect(formatted.brief).toBe('Bundle description') - expect(formatted.label).toEqual({ 'en-US': 'Test Bundle' }) - }) - }) - - describe('getPluginLinkInMarketplace', () => { - it('should return correct link for regular plugin', () => { - const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' }) - const link = getPluginLinkInMarketplace(plugin) - - expect(link).toBe('https://marketplace.dify.ai/plugins/test-org/test-plugin') - }) - - it('should return correct link for bundle', () => { - const bundle = createMockPlugin({ org: 'test-org', name: 'test-bundle', type: 'bundle' }) - const link = getPluginLinkInMarketplace(bundle) - - expect(link).toBe('https://marketplace.dify.ai/bundles/test-org/test-bundle') - }) - }) - - describe('getPluginDetailLinkInMarketplace', () => { - it('should return correct detail link for regular plugin', () => { - const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' }) - const link = getPluginDetailLinkInMarketplace(plugin) - - expect(link).toBe('/plugins/test-org/test-plugin') - }) - - it('should return correct detail link for bundle', () => { - const bundle = createMockPlugin({ org: 'test-org', name: 'test-bundle', type: 'bundle' }) - const link = getPluginDetailLinkInMarketplace(bundle) - - expect(link).toBe('/bundles/test-org/test-bundle') - }) - }) - - describe('getMarketplaceListCondition', () => { - it('should return category condition for tool', () => { - expect(getMarketplaceListCondition(PluginCategoryEnum.tool)).toBe('category=tool') - }) - - it('should return category condition for model', () => { - expect(getMarketplaceListCondition(PluginCategoryEnum.model)).toBe('category=model') - }) - - it('should return category condition for agent', () => { - expect(getMarketplaceListCondition(PluginCategoryEnum.agent)).toBe('category=agent-strategy') - }) - - it('should return category condition for datasource', () => { - expect(getMarketplaceListCondition(PluginCategoryEnum.datasource)).toBe('category=datasource') - }) - - it('should return category condition for trigger', () => { - expect(getMarketplaceListCondition(PluginCategoryEnum.trigger)).toBe('category=trigger') - }) - - it('should return endpoint category for extension', () => { - expect(getMarketplaceListCondition(PluginCategoryEnum.extension)).toBe('category=endpoint') - }) - - it('should return type condition for bundle', () => { - expect(getMarketplaceListCondition('bundle')).toBe('type=bundle') - }) - - it('should return empty string for all', () => { - expect(getMarketplaceListCondition('all')).toBe('') - }) - - it('should return empty string for unknown type', () => { - expect(getMarketplaceListCondition('unknown')).toBe('') - }) - }) - - describe('getMarketplaceListFilterType', () => { - it('should return undefined for all', () => { - expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.all)).toBeUndefined() - }) - - it('should return bundle for bundle', () => { - expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.bundle)).toBe('bundle') - }) - - it('should return plugin for other categories', () => { - expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe('plugin') - expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.model)).toBe('plugin') - expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.agent)).toBe('plugin') - }) - }) -}) - -// ================================ -// useMarketplaceCollectionsAndPlugins Tests -// ================================ -describe('useMarketplaceCollectionsAndPlugins', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should return initial state correctly', async () => { - const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - - expect(result.current.isLoading).toBe(false) - expect(result.current.isSuccess).toBe(false) - expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined() - expect(result.current.setMarketplaceCollections).toBeDefined() - expect(result.current.setMarketplaceCollectionPluginsMap).toBeDefined() - }) - - it('should provide queryMarketplaceCollectionsAndPlugins function', async () => { - const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - - expect(typeof result.current.queryMarketplaceCollectionsAndPlugins).toBe('function') - }) - - it('should provide setMarketplaceCollections function', async () => { - const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - - expect(typeof result.current.setMarketplaceCollections).toBe('function') - }) - - it('should provide setMarketplaceCollectionPluginsMap function', async () => { - const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - - expect(typeof result.current.setMarketplaceCollectionPluginsMap).toBe('function') - }) - - it('should return marketplaceCollections from data or override', async () => { - const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - - // Initial state - expect(result.current.marketplaceCollections).toBeUndefined() - }) - - it('should return marketplaceCollectionPluginsMap from data or override', async () => { - const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - - // Initial state - expect(result.current.marketplaceCollectionPluginsMap).toBeUndefined() - }) -}) - -// ================================ -// useMarketplacePluginsByCollectionId Tests -// ================================ -describe('useMarketplacePluginsByCollectionId', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should return initial state when collectionId is undefined', async () => { - const { useMarketplacePluginsByCollectionId } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePluginsByCollectionId(undefined)) - - expect(result.current.plugins).toEqual([]) - expect(result.current.isLoading).toBe(false) - expect(result.current.isSuccess).toBe(false) - }) - - it('should return isLoading false when collectionId is provided and query completes', async () => { - // The mock returns isFetching: false, isPending: false, so isLoading will be false - const { useMarketplacePluginsByCollectionId } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePluginsByCollectionId('test-collection')) - - // isLoading should be false since mock returns isFetching: false, isPending: false - expect(result.current.isLoading).toBe(false) - }) - - it('should accept query parameter', async () => { - const { useMarketplacePluginsByCollectionId } = await import('./hooks') - const { result } = renderHook(() => - useMarketplacePluginsByCollectionId('test-collection', { - category: 'tool', - type: 'plugin', - })) - - expect(result.current.plugins).toBeDefined() - }) - - it('should return plugins property from hook', async () => { - const { useMarketplacePluginsByCollectionId } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePluginsByCollectionId('collection-1')) - - // Hook should expose plugins property (may be array or fallback to empty array) - expect(result.current.plugins).toBeDefined() - }) -}) - -// ================================ -// useMarketplacePlugins Tests -// ================================ -describe('useMarketplacePlugins', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should return initial state correctly', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - expect(result.current.plugins).toBeUndefined() - expect(result.current.total).toBeUndefined() - expect(result.current.isLoading).toBe(false) - expect(result.current.isFetchingNextPage).toBe(false) - expect(result.current.hasNextPage).toBe(false) - expect(result.current.page).toBe(0) - }) - - it('should provide queryPlugins function', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - expect(typeof result.current.queryPlugins).toBe('function') - }) - - it('should provide queryPluginsWithDebounced function', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - expect(typeof result.current.queryPluginsWithDebounced).toBe('function') - }) - - it('should provide cancelQueryPluginsWithDebounced function', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - expect(typeof result.current.cancelQueryPluginsWithDebounced).toBe('function') - }) - - it('should provide resetPlugins function', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - expect(typeof result.current.resetPlugins).toBe('function') - }) - - it('should provide fetchNextPage function', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - expect(typeof result.current.fetchNextPage).toBe('function') - }) - - it('should normalize params with default pageSize', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // queryPlugins will normalize params internally - expect(result.current.queryPlugins).toBeDefined() - }) - - it('should handle queryPlugins call without errors', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Call queryPlugins - expect(() => { - result.current.queryPlugins({ - query: 'test', - sort_by: 'install_count', - sort_order: 'DESC', - category: 'tool', - page_size: 20, - }) - }).not.toThrow() - }) - - it('should handle queryPlugins with bundle type', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - expect(() => { - result.current.queryPlugins({ - query: 'test', - type: 'bundle', - page_size: 40, - }) - }).not.toThrow() - }) - - it('should handle resetPlugins call', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - expect(() => { - result.current.resetPlugins() - }).not.toThrow() - }) - - it('should handle queryPluginsWithDebounced call', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - expect(() => { - result.current.queryPluginsWithDebounced({ - query: 'debounced search', - category: 'all', - }) - }).not.toThrow() - }) - - it('should handle cancelQueryPluginsWithDebounced call', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - expect(() => { - result.current.cancelQueryPluginsWithDebounced() - }).not.toThrow() - }) - - it('should return correct page number', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Initially, page should be 0 when no query params - expect(result.current.page).toBe(0) - }) - - it('should handle queryPlugins with category all', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - expect(() => { - result.current.queryPlugins({ - query: 'test', - category: 'all', - sort_by: 'install_count', - sort_order: 'DESC', - }) - }).not.toThrow() - }) - - it('should handle queryPlugins with tags', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - expect(() => { - result.current.queryPlugins({ - query: 'test', - tags: ['search', 'image'], - exclude: ['excluded-plugin'], - }) - }).not.toThrow() - }) - - it('should handle queryPlugins with custom pageSize', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - expect(() => { - result.current.queryPlugins({ - query: 'test', - page_size: 100, - }) - }).not.toThrow() - }) -}) - -// ================================ -// Hooks queryFn Coverage Tests -// ================================ -describe('Hooks queryFn Coverage', () => { - beforeEach(() => { - vi.clearAllMocks() - mockInfiniteQueryData = undefined - }) - - it('should cover queryFn with pages data', async () => { - // Set mock data to have pages - mockInfiniteQueryData = { - pages: [ - { plugins: [{ name: 'plugin1' }], total: 10, page: 1, page_size: 40 }, - ], - } - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Trigger query to cover more code paths - result.current.queryPlugins({ - query: 'test', - category: 'tool', - }) - - // With mockInfiniteQueryData set, plugin flatMap should be covered - expect(result.current).toBeDefined() - }) - - it('should expose page and total from infinite query data', async () => { - mockInfiniteQueryData = { - pages: [ - { plugins: [{ name: 'plugin1' }, { name: 'plugin2' }], total: 20, page: 1, page_size: 40 }, - { plugins: [{ name: 'plugin3' }], total: 20, page: 2, page_size: 40 }, - ], - } - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // After setting query params, plugins should be computed - result.current.queryPlugins({ - query: 'search', - }) - - // Hook returns page count based on mock data - expect(result.current.page).toBe(2) - }) - - it('should return undefined total when no query is set', async () => { - mockInfiniteQueryData = undefined - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // No query set, total should be undefined - expect(result.current.total).toBeUndefined() - }) - - it('should return total from first page when query is set and data exists', async () => { - mockInfiniteQueryData = { - pages: [ - { plugins: [], total: 50, page: 1, page_size: 40 }, - ], - } - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - result.current.queryPlugins({ - query: 'test', - }) - - // After query, page should be computed from pages length - expect(result.current.page).toBe(1) - }) - - it('should cover queryFn for plugins type search', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Trigger query with plugin type - result.current.queryPlugins({ - type: 'plugin', - query: 'search test', - category: 'model', - sort_by: 'version_updated_at', - sort_order: 'ASC', - }) - - expect(result.current).toBeDefined() - }) - - it('should cover queryFn for bundles type search', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Trigger query with bundle type - result.current.queryPlugins({ - type: 'bundle', - query: 'bundle search', - }) - - expect(result.current).toBeDefined() - }) - - it('should handle empty pages array', async () => { - mockInfiniteQueryData = { - pages: [], - } - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - result.current.queryPlugins({ - query: 'test', - }) - - expect(result.current.page).toBe(0) - }) - - it('should handle API error in queryFn', async () => { - mockPostMarketplaceShouldFail = true - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Even when API fails, hook should still work - result.current.queryPlugins({ - query: 'test that fails', - }) - - expect(result.current).toBeDefined() - mockPostMarketplaceShouldFail = false - }) -}) - -// ================================ -// Advanced Hook Integration Tests -// ================================ -describe('Advanced Hook Integration', () => { - beforeEach(() => { - vi.clearAllMocks() - mockInfiniteQueryData = undefined - mockPostMarketplaceShouldFail = false - }) - - it('should test useMarketplaceCollectionsAndPlugins with query call', async () => { - const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - - // Call the query function - result.current.queryMarketplaceCollectionsAndPlugins({ - condition: 'category=tool', - type: 'plugin', - }) - - expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined() - }) - - it('should test useMarketplaceCollectionsAndPlugins with empty query', async () => { - const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - - // Call with undefined (converts to empty object) - result.current.queryMarketplaceCollectionsAndPlugins() - - expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined() - }) - - it('should test useMarketplacePluginsByCollectionId with different params', async () => { - const { useMarketplacePluginsByCollectionId } = await import('./hooks') - - // Test with various query params - const { result: result1 } = renderHook(() => - useMarketplacePluginsByCollectionId('collection-1', { - category: 'tool', - type: 'plugin', - exclude: ['plugin-to-exclude'], - })) - expect(result1.current).toBeDefined() - - const { result: result2 } = renderHook(() => - useMarketplacePluginsByCollectionId('collection-2', { - type: 'bundle', - })) - expect(result2.current).toBeDefined() - }) - - it('should test useMarketplacePlugins with various parameters', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Test with all possible parameters - result.current.queryPlugins({ - query: 'comprehensive test', - sort_by: 'install_count', - sort_order: 'DESC', - category: 'tool', - tags: ['tag1', 'tag2'], - exclude: ['excluded-plugin'], - type: 'plugin', - page_size: 50, - }) - - expect(result.current).toBeDefined() - - // Test reset - result.current.resetPlugins() - expect(result.current.plugins).toBeUndefined() - }) - - it('should test debounced query function', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Test debounced query - result.current.queryPluginsWithDebounced({ - query: 'debounced test', - }) - - // Cancel debounced query - result.current.cancelQueryPluginsWithDebounced() - - expect(result.current).toBeDefined() - }) -}) - -// ================================ -// Direct queryFn Coverage Tests -// ================================ -describe('Direct queryFn Coverage', () => { - beforeEach(() => { - vi.clearAllMocks() - mockInfiniteQueryData = undefined - mockPostMarketplaceShouldFail = false - capturedInfiniteQueryFn = null - capturedQueryFn = null - }) - - it('should directly test useMarketplacePlugins queryFn execution', async () => { - const { useMarketplacePlugins } = await import('./hooks') - - // First render to capture queryFn - const { result } = renderHook(() => useMarketplacePlugins()) - - // Trigger query to set queryParams and enable the query - result.current.queryPlugins({ - query: 'direct test', - category: 'tool', - sort_by: 'install_count', - sort_order: 'DESC', - page_size: 40, - }) - - // Now queryFn should be captured and enabled - if (capturedInfiniteQueryFn) { - const controller = new AbortController() - // Call queryFn directly to cover internal logic - const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal }) - expect(response).toBeDefined() - } - }) - - it('should test queryFn with bundle type', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - result.current.queryPlugins({ - type: 'bundle', - query: 'bundle test', - }) - - if (capturedInfiniteQueryFn) { - const controller = new AbortController() - const response = await capturedInfiniteQueryFn({ pageParam: 2, signal: controller.signal }) - expect(response).toBeDefined() - } - }) - - it('should test queryFn error handling', async () => { - mockPostMarketplaceShouldFail = true - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - result.current.queryPlugins({ - query: 'test that will fail', - }) - - if (capturedInfiniteQueryFn) { - const controller = new AbortController() - // This should trigger the catch block - const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal }) - expect(response).toBeDefined() - expect(response).toHaveProperty('plugins') - } - - mockPostMarketplaceShouldFail = false - }) - - it('should test useMarketplaceCollectionsAndPlugins queryFn', async () => { - const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - - // Trigger query to enable and capture queryFn - result.current.queryMarketplaceCollectionsAndPlugins({ - condition: 'category=tool', - }) - - if (capturedQueryFn) { - const controller = new AbortController() - const response = await capturedQueryFn({ signal: controller.signal }) - expect(response).toBeDefined() - } - }) - - it('should test queryFn with all category', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - result.current.queryPlugins({ - category: 'all', - query: 'all category test', - }) - - if (capturedInfiniteQueryFn) { - const controller = new AbortController() - const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal }) - expect(response).toBeDefined() - } - }) - - it('should test queryFn with tags and exclude', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - result.current.queryPlugins({ - query: 'tags test', - tags: ['tag1', 'tag2'], - exclude: ['excluded1', 'excluded2'], - }) - - if (capturedInfiniteQueryFn) { - const controller = new AbortController() - const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal }) - expect(response).toBeDefined() - } - }) - - it('should test useMarketplacePluginsByCollectionId queryFn coverage', async () => { - // Mock useQuery to capture queryFn from useMarketplacePluginsByCollectionId - const { useMarketplacePluginsByCollectionId } = await import('./hooks') - - // Test with undefined collectionId - should return empty array in queryFn - const { result: result1 } = renderHook(() => useMarketplacePluginsByCollectionId(undefined)) - expect(result1.current.plugins).toBeDefined() - - // Test with valid collectionId - should call API in queryFn - const { result: result2 } = renderHook(() => - useMarketplacePluginsByCollectionId('test-collection', { category: 'tool' })) - expect(result2.current).toBeDefined() - }) - - it('should test postMarketplace response with bundles', async () => { - // Temporarily modify mock response to return bundles - const originalBundles = [...mockPostMarketplaceResponse.data.bundles] - const originalPlugins = [...mockPostMarketplaceResponse.data.plugins] - mockPostMarketplaceResponse.data.bundles = [ - { type: 'bundle', org: 'test', name: 'bundle1', tags: [] }, - ] - mockPostMarketplaceResponse.data.plugins = [] - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - result.current.queryPlugins({ - type: 'bundle', - query: 'test bundles', - }) - - if (capturedInfiniteQueryFn) { - const controller = new AbortController() - const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal }) - expect(response).toBeDefined() - } - - // Restore original response - mockPostMarketplaceResponse.data.bundles = originalBundles - mockPostMarketplaceResponse.data.plugins = originalPlugins - }) - - it('should cover map callback with plugins data', async () => { - // Ensure API returns plugins - mockPostMarketplaceShouldFail = false - mockPostMarketplaceResponse.data.plugins = [ - { type: 'plugin', org: 'test', name: 'plugin-for-map-1', tags: [] }, - { type: 'plugin', org: 'test', name: 'plugin-for-map-2', tags: [] }, - ] - mockPostMarketplaceResponse.data.total = 2 - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Call queryPlugins to set queryParams (which triggers queryFn in our mock) - act(() => { - result.current.queryPlugins({ - query: 'map coverage test', - category: 'tool', - }) - }) - - // The queryFn is called by our mock when enabled is true - // Since we set queryParams, enabled should be true, and queryFn should be called - // with proper params, triggering the map callback - expect(result.current.queryPlugins).toBeDefined() - }) - - it('should test queryFn return structure', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - result.current.queryPlugins({ - query: 'structure test', - page_size: 20, - }) - - if (capturedInfiniteQueryFn) { - const controller = new AbortController() - const response = await capturedInfiniteQueryFn({ pageParam: 3, signal: controller.signal }) as { - plugins: unknown[] - total: number - page: number - page_size: number - } - - // Verify the returned structure - expect(response).toHaveProperty('plugins') - expect(response).toHaveProperty('total') - expect(response).toHaveProperty('page') - expect(response).toHaveProperty('page_size') - } - }) -}) - -// ================================ -// Line 198 flatMap Coverage Test -// ================================ -describe('flatMap Coverage', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPostMarketplaceShouldFail = false - }) - - it('should cover flatMap operation when data.pages exists', async () => { - // Set mock data with pages that have plugins - mockInfiniteQueryData = { - pages: [ - { - plugins: [ - { name: 'plugin1', type: 'plugin', org: 'test' }, - { name: 'plugin2', type: 'plugin', org: 'test' }, - ], - total: 5, - page: 1, - page_size: 40, - }, - { - plugins: [ - { name: 'plugin3', type: 'plugin', org: 'test' }, - ], - total: 5, - page: 2, - page_size: 40, - }, - ], - } - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Trigger query to set queryParams (hasQuery = true) - result.current.queryPlugins({ - query: 'flatmap test', - }) - - // Hook should be defined - expect(result.current).toBeDefined() - // Query function should be triggered (coverage is the goal here) - expect(result.current.queryPlugins).toBeDefined() - }) - - it('should return undefined plugins when no query params', async () => { - mockInfiniteQueryData = undefined - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Don't trigger query, so hasQuery = false - expect(result.current.plugins).toBeUndefined() - }) - - it('should test hook with pages data for flatMap path', async () => { - mockInfiniteQueryData = { - pages: [ - { plugins: [], total: 100, page: 1, page_size: 40 }, - { plugins: [], total: 100, page: 2, page_size: 40 }, - ], - } - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - result.current.queryPlugins({ query: 'total test' }) - - // Verify hook returns expected structure - expect(result.current.page).toBe(2) // pages.length - expect(result.current.queryPlugins).toBeDefined() - }) - - it('should handle API error and cover catch block', async () => { - mockPostMarketplaceShouldFail = true - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Trigger query that will fail - result.current.queryPlugins({ - query: 'error test', - category: 'tool', - }) - - // Wait for queryFn to execute and handle error - if (capturedInfiniteQueryFn) { - const controller = new AbortController() - try { - const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal }) as { - plugins: unknown[] - total: number - page: number - page_size: number - } - // When error is caught, should return fallback data - expect(response.plugins).toEqual([]) - expect(response.total).toBe(0) - } - catch { - // This is expected when API fails - } - } - - mockPostMarketplaceShouldFail = false - }) - - it('should test getNextPageParam directly', async () => { - const { useMarketplacePlugins } = await import('./hooks') - renderHook(() => useMarketplacePlugins()) - - // Test getNextPageParam function directly - if (capturedGetNextPageParam) { - // When there are more pages - const nextPage = capturedGetNextPageParam({ page: 1, page_size: 40, total: 100 }) - expect(nextPage).toBe(2) - - // When all data is loaded - const noMorePages = capturedGetNextPageParam({ page: 3, page_size: 40, total: 100 }) - expect(noMorePages).toBeUndefined() - - // Edge case: exactly at boundary - const atBoundary = capturedGetNextPageParam({ page: 2, page_size: 50, total: 100 }) - expect(atBoundary).toBeUndefined() - } - }) - - it('should cover catch block by simulating API failure', async () => { - // Enable API failure mode - mockPostMarketplaceShouldFail = true - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Set params to trigger the query - act(() => { - result.current.queryPlugins({ - query: 'catch block test', - type: 'plugin', - }) - }) - - // Directly invoke queryFn to trigger the catch block - if (capturedInfiniteQueryFn) { - const controller = new AbortController() - const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal }) as { - plugins: unknown[] - total: number - page: number - page_size: number - } - // Catch block should return fallback values - expect(response.plugins).toEqual([]) - expect(response.total).toBe(0) - expect(response.page).toBe(1) - } - - mockPostMarketplaceShouldFail = false - }) - - it('should cover flatMap when hasQuery and hasData are both true', async () => { - // Set mock data before rendering - mockInfiniteQueryData = { - pages: [ - { - plugins: [{ name: 'test-plugin-1' }, { name: 'test-plugin-2' }], - total: 10, - page: 1, - page_size: 40, - }, - ], - } - - const { useMarketplacePlugins } = await import('./hooks') - const { result, rerender } = renderHook(() => useMarketplacePlugins()) - - // Trigger query to set queryParams - act(() => { - result.current.queryPlugins({ - query: 'flatmap coverage test', - }) - }) - - // Force rerender to pick up state changes - rerender() - - // After rerender, hasQuery should be true - // The hook should compute plugins from pages.flatMap - expect(result.current).toBeDefined() - }) -}) - -// ================================ -// Async Utils Tests -// ================================ - -// Narrow mock surface and avoid any in tests -// Types are local to this spec to keep scope minimal - -type FnMock = ReturnType<typeof vi.fn> - -type MarketplaceClientMock = { - collectionPlugins: FnMock - collections: FnMock -} - -describe('Async Utils', () => { - let marketplaceClientMock: MarketplaceClientMock - - beforeAll(async () => { - const mod = await import('@/service/client') - marketplaceClientMock = mod.marketplaceClient as unknown as MarketplaceClientMock - }) - beforeEach(() => { - vi.clearAllMocks() - }) - - afterEach(() => { - globalThis.fetch = originalFetch - }) - - describe('getMarketplacePluginsByCollectionId', () => { - it('should fetch plugins by collection id successfully', async () => { - const mockPlugins = [ - { type: 'plugin', org: 'test', name: 'plugin1' }, - { type: 'plugin', org: 'test', name: 'plugin2' }, - ] - - // Adjusted to our mocked marketplaceClient instead of fetch - marketplaceClientMock.collectionPlugins.mockResolvedValueOnce({ - data: { plugins: mockPlugins }, - }) - - const { getMarketplacePluginsByCollectionId } = await import('./utils') - const result = await getMarketplacePluginsByCollectionId('test-collection', { - category: 'tool', - exclude: ['excluded-plugin'], - type: 'plugin', - }) - - expect(marketplaceClientMock.collectionPlugins).toHaveBeenCalled() - expect(result).toHaveLength(2) - }) - - it('should handle fetch error and return empty array', async () => { - // Simulate error from client - marketplaceClientMock.collectionPlugins.mockRejectedValueOnce(new Error('Network error')) - - const { getMarketplacePluginsByCollectionId } = await import('./utils') - const result = await getMarketplacePluginsByCollectionId('test-collection') - - expect(result).toEqual([]) - }) - - it('should pass abort signal when provided', async () => { - const mockPlugins = [{ type: 'plugins', org: 'test', name: 'plugin1' }] - // Our client mock receives the signal as second arg - marketplaceClientMock.collectionPlugins.mockResolvedValueOnce({ - data: { plugins: mockPlugins }, - }) - - const controller = new AbortController() - const { getMarketplacePluginsByCollectionId } = await import('./utils') - await getMarketplacePluginsByCollectionId('test-collection', {}, { signal: controller.signal }) - - expect(marketplaceClientMock.collectionPlugins).toHaveBeenCalled() - const call = marketplaceClientMock.collectionPlugins.mock.calls[0] - expect(call[1]).toMatchObject({ signal: controller.signal }) - }) - }) - - describe('getMarketplaceCollectionsAndPlugins', () => { - it('should fetch collections and plugins successfully', async () => { - const mockCollections = [ - { name: 'collection1', label: {}, description: {}, rule: '', created_at: '', updated_at: '' }, - ] - const mockPlugins = [{ type: 'plugins', org: 'test', name: 'plugin1' }] - - // Simulate two-step client calls: collections then collectionPlugins - let stage = 0 - marketplaceClientMock.collections.mockImplementationOnce(async () => { - stage = 1 - return { data: { collections: mockCollections } } - }) - marketplaceClientMock.collectionPlugins.mockImplementation(async () => { - if (stage === 1) { - return { data: { plugins: mockPlugins } } - } - return { data: { plugins: [] } } - }) - - const { getMarketplaceCollectionsAndPlugins } = await import('./utils') - const result = await getMarketplaceCollectionsAndPlugins({ - condition: 'category=tool', - type: 'plugin', - }) - - expect(result.marketplaceCollections).toBeDefined() - expect(result.marketplaceCollectionPluginsMap).toBeDefined() - }) - - it('should handle fetch error and return empty data', async () => { - // Simulate client error - marketplaceClientMock.collections.mockRejectedValueOnce(new Error('Network error')) - - const { getMarketplaceCollectionsAndPlugins } = await import('./utils') - const result = await getMarketplaceCollectionsAndPlugins() - - expect(result.marketplaceCollections).toEqual([]) - expect(result.marketplaceCollectionPluginsMap).toEqual({}) - }) - - it('should append condition and type to URL when provided', async () => { - // Assert that the client was called with query containing condition/type - const { getMarketplaceCollectionsAndPlugins } = await import('./utils') - await getMarketplaceCollectionsAndPlugins({ - condition: 'category=tool', - type: 'bundle', - }) - - expect(marketplaceClientMock.collections).toHaveBeenCalled() - const call = marketplaceClientMock.collections.mock.calls[0] - expect(call[0]).toMatchObject({ query: expect.objectContaining({ condition: 'category=tool', type: 'bundle' }) }) - }) - }) -}) - -// ================================ -// useMarketplaceContainerScroll Tests -// ================================ -describe('useMarketplaceContainerScroll', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should attach scroll event listener to container', async () => { - const mockCallback = vi.fn() - const mockContainer = document.createElement('div') - mockContainer.id = 'marketplace-container' - document.body.appendChild(mockContainer) - - const addEventListenerSpy = vi.spyOn(mockContainer, 'addEventListener') - const { useMarketplaceContainerScroll } = await import('./hooks') - - const TestComponent = () => { - useMarketplaceContainerScroll(mockCallback) - return null - } - - render(<TestComponent />) - expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)) - document.body.removeChild(mockContainer) - }) - - it('should call callback when scrolled to bottom', async () => { - const mockCallback = vi.fn() - const mockContainer = document.createElement('div') - mockContainer.id = 'scroll-test-container' - document.body.appendChild(mockContainer) - - Object.defineProperty(mockContainer, 'scrollTop', { value: 900, writable: true }) - Object.defineProperty(mockContainer, 'scrollHeight', { value: 1000, writable: true }) - Object.defineProperty(mockContainer, 'clientHeight', { value: 100, writable: true }) - - const { useMarketplaceContainerScroll } = await import('./hooks') - - const TestComponent = () => { - useMarketplaceContainerScroll(mockCallback, 'scroll-test-container') - return null - } - - render(<TestComponent />) - - const scrollEvent = new Event('scroll') - Object.defineProperty(scrollEvent, 'target', { value: mockContainer }) - mockContainer.dispatchEvent(scrollEvent) - - expect(mockCallback).toHaveBeenCalled() - document.body.removeChild(mockContainer) - }) - - it('should not call callback when scrollTop is 0', async () => { - const mockCallback = vi.fn() - const mockContainer = document.createElement('div') - mockContainer.id = 'scroll-test-container-2' - document.body.appendChild(mockContainer) - - Object.defineProperty(mockContainer, 'scrollTop', { value: 0, writable: true }) - Object.defineProperty(mockContainer, 'scrollHeight', { value: 1000, writable: true }) - Object.defineProperty(mockContainer, 'clientHeight', { value: 100, writable: true }) - - const { useMarketplaceContainerScroll } = await import('./hooks') - - const TestComponent = () => { - useMarketplaceContainerScroll(mockCallback, 'scroll-test-container-2') - return null - } - - render(<TestComponent />) - - const scrollEvent = new Event('scroll') - Object.defineProperty(scrollEvent, 'target', { value: mockContainer }) - mockContainer.dispatchEvent(scrollEvent) - - expect(mockCallback).not.toHaveBeenCalled() - document.body.removeChild(mockContainer) - }) - - it('should remove event listener on unmount', async () => { - const mockCallback = vi.fn() - const mockContainer = document.createElement('div') - mockContainer.id = 'scroll-unmount-container' - document.body.appendChild(mockContainer) - - const removeEventListenerSpy = vi.spyOn(mockContainer, 'removeEventListener') - const { useMarketplaceContainerScroll } = await import('./hooks') - - const TestComponent = () => { - useMarketplaceContainerScroll(mockCallback, 'scroll-unmount-container') - return null - } - - const { unmount } = render(<TestComponent />) - unmount() - - expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)) - document.body.removeChild(mockContainer) - }) -}) - -// ================================ -// Test Data Factory Tests -// ================================ -describe('Test Data Factories', () => { - describe('createMockPlugin', () => { - it('should create plugin with default values', () => { - const plugin = createMockPlugin() - - expect(plugin.type).toBe('plugin') - expect(plugin.org).toBe('test-org') - expect(plugin.version).toBe('1.0.0') - expect(plugin.verified).toBe(true) - expect(plugin.category).toBe(PluginCategoryEnum.tool) - expect(plugin.install_count).toBe(1000) - }) - - it('should allow overriding default values', () => { - const plugin = createMockPlugin({ - name: 'custom-plugin', - org: 'custom-org', - version: '2.0.0', - install_count: 5000, - }) - - expect(plugin.name).toBe('custom-plugin') - expect(plugin.org).toBe('custom-org') - expect(plugin.version).toBe('2.0.0') - expect(plugin.install_count).toBe(5000) - }) - - it('should create bundle type plugin', () => { - const bundle = createMockPlugin({ type: 'bundle' }) - - expect(bundle.type).toBe('bundle') - }) - }) - - describe('createMockPluginList', () => { - it('should create correct number of plugins', () => { - const plugins = createMockPluginList(5) - - expect(plugins).toHaveLength(5) - }) - - it('should create plugins with unique names', () => { - const plugins = createMockPluginList(3) - const names = plugins.map(p => p.name) - - expect(new Set(names).size).toBe(3) - }) - - it('should create plugins with decreasing install counts', () => { - const plugins = createMockPluginList(3) - - expect(plugins[0].install_count).toBeGreaterThan(plugins[1].install_count) - expect(plugins[1].install_count).toBeGreaterThan(plugins[2].install_count) - }) - }) - - describe('createMockCollection', () => { - it('should create collection with default values', () => { - const collection = createMockCollection() - - expect(collection.name).toBe('test-collection') - expect(collection.label['en-US']).toBe('Test Collection') - expect(collection.searchable).toBe(true) - }) - - it('should allow overriding default values', () => { - const collection = createMockCollection({ - name: 'custom-collection', - searchable: false, - }) - - expect(collection.name).toBe('custom-collection') - expect(collection.searchable).toBe(false) - }) - }) -}) diff --git a/web/app/components/plugins/marketplace/list/index.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/plugins/marketplace/list/index.spec.tsx rename to web/app/components/plugins/marketplace/list/__tests__/index.spec.tsx index 31419030a4..7f88cf366c 100644 --- a/web/app/components/plugins/marketplace/list/index.spec.tsx +++ b/web/app/components/plugins/marketplace/list/__tests__/index.spec.tsx @@ -1,17 +1,16 @@ -import type { MarketplaceCollection, SearchParamsFromCollection } from '../types' +import type { MarketplaceCollection, SearchParamsFromCollection } from '../../types' import type { Plugin } from '@/app/components/plugins/types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PluginCategoryEnum } from '@/app/components/plugins/types' -import List from './index' -import ListWithCollection from './list-with-collection' -import ListWrapper from './list-wrapper' +import List from '../index' +import ListWithCollection from '../list-with-collection' +import ListWrapper from '../list-wrapper' // ================================ // Mock External Dependencies Only // ================================ -// Mock i18n translation hook vi.mock('#i18n', () => ({ useTranslation: () => ({ t: (key: string, options?: { ns?: string, num?: number }) => { @@ -30,7 +29,6 @@ vi.mock('#i18n', () => ({ useLocale: () => 'en-US', })) -// Mock marketplace state hooks with controllable values const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => { return { mockMarketplaceData: { @@ -45,27 +43,18 @@ const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => { } }) -vi.mock('../state', () => ({ +vi.mock('../../state', () => ({ useMarketplaceData: () => mockMarketplaceData, })) -vi.mock('../atoms', () => ({ +vi.mock('../../atoms', () => ({ useMarketplaceMoreClick: () => mockMoreClick, })) -// Mock useLocale context vi.mock('@/context/i18n', () => ({ useLocale: () => 'en-US', })) -// Mock next-themes -vi.mock('next-themes', () => ({ - useTheme: () => ({ - theme: 'light', - }), -})) - -// Mock useTags hook const mockTags = [ { name: 'search', label: 'Search' }, { name: 'image', label: 'Image' }, @@ -85,7 +74,6 @@ vi.mock('@/app/components/plugins/hooks', () => ({ }), })) -// Mock ahooks useBoolean with controllable state let mockUseBooleanValue = false const mockSetTrue = vi.fn(() => { mockUseBooleanValue = true @@ -107,20 +95,17 @@ vi.mock('ahooks', () => ({ }, })) -// Mock i18n-config/language vi.mock('@/i18n-config/language', () => ({ getLanguage: (locale: string) => locale || 'en-US', })) -// Mock marketplace utils -vi.mock('../utils', () => ({ +vi.mock('../../utils', () => ({ getPluginLinkInMarketplace: (plugin: Plugin, _params?: Record<string, string | undefined>) => `/plugins/${plugin.org}/${plugin.name}`, getPluginDetailLinkInMarketplace: (plugin: Plugin) => `/plugins/${plugin.org}/${plugin.name}`, })) -// Mock Card component vi.mock('@/app/components/plugins/card', () => ({ default: ({ payload, footer }: { payload: Plugin, footer?: React.ReactNode }) => ( <div data-testid={`card-${payload.name}`}> @@ -131,7 +116,6 @@ vi.mock('@/app/components/plugins/card', () => ({ ), })) -// Mock CardMoreInfo component vi.mock('@/app/components/plugins/card/card-more-info', () => ({ default: ({ downloadCount, tags }: { downloadCount: number, tags: string[] }) => ( <div data-testid="card-more-info"> @@ -141,7 +125,6 @@ vi.mock('@/app/components/plugins/card/card-more-info', () => ({ ), })) -// Mock InstallFromMarketplace component vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({ default: ({ onClose }: { onClose: () => void }) => ( <div data-testid="install-from-marketplace"> @@ -150,15 +133,13 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () = ), })) -// Mock SortDropdown component -vi.mock('../sort-dropdown', () => ({ +vi.mock('../../sort-dropdown', () => ({ default: () => ( <div data-testid="sort-dropdown">Sort</div> ), })) -// Mock Empty component -vi.mock('../empty', () => ({ +vi.mock('../../empty', () => ({ default: ({ className }: { className?: string }) => ( <div data-testid="empty-component" className={className}> No plugins found @@ -166,7 +147,6 @@ vi.mock('../empty', () => ({ ), })) -// Mock Loading component vi.mock('@/app/components/base/loading', () => ({ default: () => <div data-testid="loading-component">Loading...</div>, })) diff --git a/web/app/components/plugins/marketplace/search-box/index.spec.tsx b/web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/marketplace/search-box/index.spec.tsx rename to web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx index 85be82cb33..e3c7450a39 100644 --- a/web/app/components/plugins/marketplace/search-box/index.spec.tsx +++ b/web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx @@ -1,10 +1,10 @@ import type { Tag } from '@/app/components/plugins/hooks' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import SearchBox from './index' -import SearchBoxWrapper from './search-box-wrapper' -import MarketplaceTrigger from './trigger/marketplace' -import ToolSelectorTrigger from './trigger/tool-selector' +import SearchBox from '../index' +import SearchBoxWrapper from '../search-box-wrapper' +import MarketplaceTrigger from '../trigger/marketplace' +import ToolSelectorTrigger from '../trigger/tool-selector' // ================================ // Mock external dependencies only @@ -36,7 +36,7 @@ const { mockSearchPluginText, mockHandleSearchPluginTextChange, mockFilterPlugin } }) -vi.mock('../atoms', () => ({ +vi.mock('../../atoms', () => ({ useSearchPluginText: () => [mockSearchPluginText, mockHandleSearchPluginTextChange], useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange], })) diff --git a/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx b/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx rename to web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx index f91c7ba4d3..664f8520b2 100644 --- a/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx +++ b/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -import SortDropdown from './index' +import SortDropdown from '../index' // ================================ // Mock external dependencies only @@ -31,7 +31,7 @@ vi.mock('#i18n', () => ({ let mockSort: { sortBy: string, sortOrder: string } = { sortBy: 'install_count', sortOrder: 'DESC' } const mockHandleSortChange = vi.fn() -vi.mock('../atoms', () => ({ +vi.mock('../../atoms', () => ({ useMarketplaceSort: () => [mockSort, mockHandleSortChange], })) @@ -39,7 +39,7 @@ vi.mock('../atoms', () => ({ let mockPortalOpenState = false vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open, onOpenChange }: { + PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: { children: React.ReactNode open: boolean onOpenChange: (open: boolean) => void diff --git a/web/app/components/plugins/plugin-auth/__tests__/authorized-in-data-source-node.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-data-source-node.spec.tsx new file mode 100644 index 0000000000..d5cea7a495 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-data-source-node.spec.tsx @@ -0,0 +1,45 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import AuthorizedInDataSourceNode from '../authorized-in-data-source-node' + +vi.mock('@/app/components/header/indicator', () => ({ + default: ({ color }: { color: string }) => <span data-testid="indicator" data-color={color} />, +})) + +describe('AuthorizedInDataSourceNode', () => { + const mockOnJump = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + cleanup() + }) + + it('renders with green indicator', () => { + render(<AuthorizedInDataSourceNode authorizationsNum={1} onJumpToDataSourcePage={mockOnJump} />) + expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green') + }) + + it('renders singular text for 1 authorization', () => { + render(<AuthorizedInDataSourceNode authorizationsNum={1} onJumpToDataSourcePage={mockOnJump} />) + expect(screen.getByText('plugin.auth.authorization')).toBeInTheDocument() + }) + + it('renders plural text for multiple authorizations', () => { + render(<AuthorizedInDataSourceNode authorizationsNum={3} onJumpToDataSourcePage={mockOnJump} />) + expect(screen.getByText('plugin.auth.authorizations')).toBeInTheDocument() + }) + + it('calls onJumpToDataSourcePage when button is clicked', () => { + render(<AuthorizedInDataSourceNode authorizationsNum={1} onJumpToDataSourcePage={mockOnJump} />) + fireEvent.click(screen.getByRole('button')) + expect(mockOnJump).toHaveBeenCalledTimes(1) + }) + + it('renders settings button', () => { + render(<AuthorizedInDataSourceNode authorizationsNum={1} onJumpToDataSourcePage={mockOnJump} />) + expect(screen.getByRole('button')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx new file mode 100644 index 0000000000..7e8208b995 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx @@ -0,0 +1,210 @@ +import type { ReactNode } from 'react' +import type { Credential, PluginPayload } from '../types' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AuthCategory, CredentialTypeEnum } from '../types' + +// ==================== Mock Setup ==================== + +const mockGetPluginCredentialInfo = vi.fn() +const mockGetPluginOAuthClientSchema = vi.fn() + +vi.mock('@/service/use-plugins-auth', () => ({ + useGetPluginCredentialInfo: (url: string) => ({ + data: url ? mockGetPluginCredentialInfo() : undefined, + isLoading: false, + }), + useDeletePluginCredential: () => ({ mutateAsync: vi.fn() }), + useSetPluginDefaultCredential: () => ({ mutateAsync: vi.fn() }), + useUpdatePluginCredential: () => ({ mutateAsync: vi.fn() }), + useInvalidPluginCredentialInfo: () => vi.fn(), + useGetPluginOAuthUrl: () => ({ mutateAsync: vi.fn() }), + useGetPluginOAuthClientSchema: () => ({ + data: mockGetPluginOAuthClientSchema(), + isLoading: false, + }), + useSetPluginOAuthCustomClient: () => ({ mutateAsync: vi.fn() }), + useDeletePluginOAuthCustomClient: () => ({ mutateAsync: vi.fn() }), + useInvalidPluginOAuthClientSchema: () => vi.fn(), + useAddPluginCredential: () => ({ mutateAsync: vi.fn() }), + useGetPluginCredentialSchema: () => ({ data: undefined, isLoading: false }), +})) + +vi.mock('@/service/use-tools', () => ({ + useInvalidToolsByType: () => vi.fn(), +})) + +const mockIsCurrentWorkspaceManager = vi.fn() +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(), + }), +})) + +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ notify: vi.fn() }), +})) + +vi.mock('@/hooks/use-oauth', () => ({ + openOAuthPopup: vi.fn(), +})) + +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 }) => ( + <QueryClientProvider client={testQueryClient}> + {children} + </QueryClientProvider> + ) +} + +const createPluginPayload = (overrides: Partial<PluginPayload> = {}): PluginPayload => ({ + category: AuthCategory.tool, + provider: 'test-provider', + ...overrides, +}) + +const createCredential = (overrides: Partial<Credential> = {}): 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, +}) + +// ==================== 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( + <AuthorizedInNode pluginPayload={pluginPayload} onAuthorizationItemClick={vi.fn()} />, + { 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( + <AuthorizedInNode pluginPayload={pluginPayload} onAuthorizationItemClick={vi.fn()} credentialId="selected-id" />, + { 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( + <AuthorizedInNode pluginPayload={pluginPayload} onAuthorizationItemClick={vi.fn()} credentialId="non-existent" />, + { 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( + <AuthorizedInNode pluginPayload={pluginPayload} onAuthorizationItemClick={vi.fn()} credentialId="unavailable-id" />, + { wrapper: createWrapper() }, + ) + 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( + <AuthorizedInNode pluginPayload={pluginPayload} onAuthorizationItemClick={vi.fn()} />, + { wrapper: createWrapper() }, + ) + 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( + <AuthorizedInNode pluginPayload={pluginPayload} onAuthorizationItemClick={onAuthorizationItemClick} />, + { wrapper: createWrapper() }, + ) + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[0]) + 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') + }) +}) diff --git a/web/app/components/plugins/plugin-auth/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/index.spec.tsx new file mode 100644 index 0000000000..16b5eb580d --- /dev/null +++ b/web/app/components/plugins/plugin-auth/__tests__/index.spec.tsx @@ -0,0 +1,247 @@ +import type { ReactNode } from 'react' +import type { Credential, PluginPayload } from '../types' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { describe, expect, it, vi } from 'vitest' +import { AuthCategory, CredentialTypeEnum } from '../types' + +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, +})) + +const mockIsCurrentWorkspaceManager = vi.fn() +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(), + }), +})) + +const mockNotify = vi.fn() +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ + notify: mockNotify, + }), +})) + +vi.mock('@/hooks/use-oauth', () => ({ + openOAuthPopup: vi.fn(), +})) + +vi.mock('@/service/use-triggers', () => ({ + useTriggerPluginDynamicOptions: () => ({ + data: { options: [] }, + isLoading: false, + }), + useTriggerPluginDynamicOptionsInfo: () => ({ + data: null, + isLoading: false, + }), + useInvalidTriggerDynamicOptions: () => vi.fn(), +})) + +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }) + +const _createWrapper = () => { + const testQueryClient = createTestQueryClient() + return ({ children }: { children: ReactNode }) => ( + <QueryClientProvider client={testQueryClient}> + {children} + </QueryClientProvider> + ) +} + +const _createPluginPayload = (overrides: Partial<PluginPayload> = {}): PluginPayload => ({ + category: AuthCategory.tool, + provider: 'test-provider', + ...overrides, +}) + +const createCredential = (overrides: Partial<Credential> = {}): 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>[] = []): Credential[] => { + return Array.from({ length: count }, (_, i) => createCredential({ + id: `credential-${i}`, + name: `Credential ${i}`, + is_default: i === 0, + ...overrides[i], + })) +} + +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() + }, 15000) + + 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') + }, 15000) + + 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') + }, 15000) +}) + +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') + }) + }) +}) diff --git a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx new file mode 100644 index 0000000000..6b66aca9dd --- /dev/null +++ b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx @@ -0,0 +1,255 @@ +import type { ReactNode } from 'react' +import type { Credential, PluginPayload } from '../types' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AuthCategory, CredentialTypeEnum } from '../types' + +// ==================== Mock Setup ==================== + +const mockGetPluginCredentialInfo = vi.fn() +const mockGetPluginOAuthClientSchema = vi.fn() + +vi.mock('@/service/use-plugins-auth', () => ({ + useGetPluginCredentialInfo: (url: string) => ({ + data: url ? mockGetPluginCredentialInfo() : undefined, + isLoading: false, + }), + useDeletePluginCredential: () => ({ mutateAsync: vi.fn() }), + useSetPluginDefaultCredential: () => ({ mutateAsync: vi.fn() }), + useUpdatePluginCredential: () => ({ mutateAsync: vi.fn() }), + useInvalidPluginCredentialInfo: () => vi.fn(), + useGetPluginOAuthUrl: () => ({ mutateAsync: vi.fn() }), + useGetPluginOAuthClientSchema: () => ({ + data: mockGetPluginOAuthClientSchema(), + isLoading: false, + }), + useSetPluginOAuthCustomClient: () => ({ mutateAsync: vi.fn() }), + useDeletePluginOAuthCustomClient: () => ({ mutateAsync: vi.fn() }), + useInvalidPluginOAuthClientSchema: () => vi.fn(), + useAddPluginCredential: () => ({ mutateAsync: vi.fn() }), + useGetPluginCredentialSchema: () => ({ data: undefined, isLoading: false }), +})) + +vi.mock('@/service/use-tools', () => ({ + useInvalidToolsByType: () => vi.fn(), +})) + +const mockIsCurrentWorkspaceManager = vi.fn() +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(), + }), +})) + +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ notify: vi.fn() }), +})) + +vi.mock('@/hooks/use-oauth', () => ({ + openOAuthPopup: vi.fn(), +})) + +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 }) => ( + <QueryClientProvider client={testQueryClient}> + {children} + </QueryClientProvider> + ) +} + +const createPluginPayload = (overrides: Partial<PluginPayload> = {}): PluginPayload => ({ + category: AuthCategory.tool, + provider: 'test-provider', + ...overrides, +}) + +const createCredential = (overrides: Partial<Credential> = {}): 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, +}) + +// ==================== 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( + <PluginAuthInAgent pluginPayload={pluginPayload} />, + { 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( + <PluginAuthInAgent pluginPayload={pluginPayload} />, + { 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( + <PluginAuthInAgent pluginPayload={pluginPayload} credentialId="selected-id" />, + { 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( + <PluginAuthInAgent pluginPayload={pluginPayload} credentialId="non-existent-id" />, + { 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( + <PluginAuthInAgent pluginPayload={pluginPayload} credentialId="unavailable-id" />, + { wrapper: createWrapper() }, + ) + 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( + <PluginAuthInAgent pluginPayload={pluginPayload} onAuthorizationItemClick={onAuthorizationItemClick} />, + { wrapper: createWrapper() }, + ) + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[0]) + expect(screen.getAllByRole('button').length).toBeGreaterThan(0) + }) + + it('should trigger handleAuthorizationItemClick and close popup when 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( + <PluginAuthInAgent pluginPayload={pluginPayload} onAuthorizationItemClick={onAuthorizationItemClick} />, + { wrapper: createWrapper() }, + ) + const triggerButton = screen.getByRole('button') + fireEvent.click(triggerButton) + const workspaceDefaultItems = screen.getAllByText('plugin.auth.workspaceDefault') + const popupItem = workspaceDefaultItems.length > 1 ? workspaceDefaultItems[1] : workspaceDefaultItems[0] + fireEvent.click(popupItem) + 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( + <PluginAuthInAgent pluginPayload={pluginPayload} onAuthorizationItemClick={onAuthorizationItemClick} />, + { wrapper: createWrapper() }, + ) + const triggerButton = screen.getByRole('button') + fireEvent.click(triggerButton) + const credentialItems = screen.getAllByText('Specific Credential') + const popupItem = credentialItems[credentialItems.length - 1] + fireEvent.click(popupItem) + 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') + }) +}) diff --git a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-datasource-node.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-datasource-node.spec.tsx new file mode 100644 index 0000000000..4fd899af4f --- /dev/null +++ b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-datasource-node.spec.tsx @@ -0,0 +1,51 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import PluginAuthInDataSourceNode from '../plugin-auth-in-datasource-node' + +describe('PluginAuthInDataSourceNode', () => { + const mockOnJump = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + cleanup() + }) + + it('renders connect button when not authorized', () => { + render(<PluginAuthInDataSourceNode onJumpToDataSourcePage={mockOnJump} />) + expect(screen.getByText('common.integrations.connect')).toBeInTheDocument() + }) + + it('renders connect button', () => { + render(<PluginAuthInDataSourceNode onJumpToDataSourcePage={mockOnJump} />) + expect(screen.getByRole('button', { name: /common\.integrations\.connect/ })).toBeInTheDocument() + }) + + it('calls onJumpToDataSourcePage when connect button is clicked', () => { + render(<PluginAuthInDataSourceNode onJumpToDataSourcePage={mockOnJump} />) + fireEvent.click(screen.getByRole('button', { name: /common\.integrations\.connect/ })) + expect(mockOnJump).toHaveBeenCalledTimes(1) + }) + + it('hides connect button and shows children when authorized', () => { + render( + <PluginAuthInDataSourceNode isAuthorized onJumpToDataSourcePage={mockOnJump}> + <div data-testid="child-content">Data Source Connected</div> + </PluginAuthInDataSourceNode>, + ) + expect(screen.queryByText('common.integrations.connect')).not.toBeInTheDocument() + expect(screen.getByTestId('child-content')).toBeInTheDocument() + }) + + it('shows connect button when isAuthorized is false', () => { + render( + <PluginAuthInDataSourceNode isAuthorized={false} onJumpToDataSourcePage={mockOnJump}> + <div data-testid="child-content">Data Source Connected</div> + </PluginAuthInDataSourceNode>, + ) + expect(screen.getByText('common.integrations.connect')).toBeInTheDocument() + expect(screen.queryByTestId('child-content')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx new file mode 100644 index 0000000000..511f3a25a3 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx @@ -0,0 +1,139 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import PluginAuth from '../plugin-auth' +import { AuthCategory } from '../types' + +const mockUsePluginAuth = vi.fn() +vi.mock('../hooks/use-plugin-auth', () => ({ + usePluginAuth: (...args: unknown[]) => mockUsePluginAuth(...args), +})) + +vi.mock('../authorize', () => ({ + default: ({ pluginPayload }: { pluginPayload: { provider: string } }) => ( + <div data-testid="authorize"> + Authorize: + {pluginPayload.provider} + </div> + ), +})) + +vi.mock('../authorized', () => ({ + default: ({ pluginPayload }: { pluginPayload: { provider: string } }) => ( + <div data-testid="authorized"> + Authorized: + {pluginPayload.provider} + </div> + ), +})) + +const defaultPayload = { + category: AuthCategory.tool, + provider: 'test-provider', +} + +describe('PluginAuth', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + cleanup() + }) + + it('renders Authorize component when not authorized', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: false, + canOAuth: false, + canApiKey: true, + credentials: [], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + render(<PluginAuth pluginPayload={defaultPayload} />) + expect(screen.getByTestId('authorize')).toBeInTheDocument() + expect(screen.queryByTestId('authorized')).not.toBeInTheDocument() + }) + + it('renders Authorized component when authorized and no children', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: true, + canOAuth: true, + canApiKey: true, + credentials: [{ id: '1', name: 'key', is_default: true, provider: 'test' }], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + render(<PluginAuth pluginPayload={defaultPayload} />) + expect(screen.getByTestId('authorized')).toBeInTheDocument() + expect(screen.queryByTestId('authorize')).not.toBeInTheDocument() + }) + + it('renders children when authorized and children provided', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: true, + canOAuth: false, + canApiKey: true, + credentials: [{ id: '1', name: 'key', is_default: true, provider: 'test' }], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + render( + <PluginAuth pluginPayload={defaultPayload}> + <div data-testid="custom-children">Custom Content</div> + </PluginAuth>, + ) + expect(screen.getByTestId('custom-children')).toBeInTheDocument() + expect(screen.queryByTestId('authorized')).not.toBeInTheDocument() + }) + + it('applies className when not authorized', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: false, + canOAuth: false, + canApiKey: true, + credentials: [], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + const { container } = render(<PluginAuth pluginPayload={defaultPayload} className="custom-class" />) + expect((container.firstChild as HTMLElement).className).toContain('custom-class') + }) + + it('does not apply className when authorized', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: true, + canOAuth: false, + canApiKey: true, + credentials: [], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + const { container } = render(<PluginAuth pluginPayload={defaultPayload} className="custom-class" />) + expect((container.firstChild as HTMLElement).className).not.toContain('custom-class') + }) + + it('passes pluginPayload.provider to usePluginAuth', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: false, + canOAuth: false, + canApiKey: false, + credentials: [], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + render(<PluginAuth pluginPayload={defaultPayload} />) + expect(mockUsePluginAuth).toHaveBeenCalledWith(defaultPayload, true) + }) +}) diff --git a/web/app/components/plugins/plugin-auth/__tests__/utils.spec.ts b/web/app/components/plugins/plugin-auth/__tests__/utils.spec.ts new file mode 100644 index 0000000000..878f3111ab --- /dev/null +++ b/web/app/components/plugins/plugin-auth/__tests__/utils.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { transformFormSchemasSecretInput } from '../utils' + +describe('plugin-auth/utils', () => { + describe('transformFormSchemasSecretInput', () => { + it('replaces secret input values with [__HIDDEN__]', () => { + const values = { api_key: 'sk-12345', username: 'admin' } + const result = transformFormSchemasSecretInput(['api_key'], values) + expect(result.api_key).toBe('[__HIDDEN__]') + expect(result.username).toBe('admin') + }) + + it('does not replace falsy values (empty string)', () => { + const values = { api_key: '', username: 'admin' } + const result = transformFormSchemasSecretInput(['api_key'], values) + expect(result.api_key).toBe('') + }) + + it('does not replace undefined values', () => { + const values = { username: 'admin' } + const result = transformFormSchemasSecretInput(['api_key'], values) + expect(result.api_key).toBeUndefined() + }) + + it('handles multiple secret fields', () => { + const values = { key1: 'secret1', key2: 'secret2', normal: 'value' } + const result = transformFormSchemasSecretInput(['key1', 'key2'], values) + expect(result.key1).toBe('[__HIDDEN__]') + expect(result.key2).toBe('[__HIDDEN__]') + expect(result.normal).toBe('value') + }) + + it('does not mutate the original values', () => { + const values = { api_key: 'sk-12345' } + const result = transformFormSchemasSecretInput(['api_key'], values) + expect(result).not.toBe(values) + expect(values.api_key).toBe('sk-12345') + }) + + it('returns same values when no secret names provided', () => { + const values = { api_key: 'sk-12345', username: 'admin' } + const result = transformFormSchemasSecretInput([], values) + expect(result).toEqual(values) + }) + + it('handles null-like values correctly', () => { + const values = { key: null, key2: 0, key3: false } + const result = transformFormSchemasSecretInput(['key', 'key2', 'key3'], values) + // null, 0, false are falsy — should not be replaced + expect(result.key).toBeNull() + expect(result.key2).toBe(0) + expect(result.key3).toBe(false) + }) + }) +}) diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/add-api-key-button.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-api-key-button.spec.tsx new file mode 100644 index 0000000000..794f847168 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-api-key-button.spec.tsx @@ -0,0 +1,67 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AuthCategory } from '../../types' +import AddApiKeyButton from '../add-api-key-button' + +let _mockModalOpen = false +vi.mock('../api-key-modal', () => ({ + default: ({ onClose, onUpdate }: { onClose: () => void, onUpdate?: () => void }) => { + _mockModalOpen = true + return ( + <div data-testid="api-key-modal"> + <button data-testid="modal-close" onClick={onClose}>Close</button> + <button data-testid="modal-update" onClick={onUpdate}>Update</button> + </div> + ) + }, +})) + +const defaultPayload = { + category: AuthCategory.tool, + provider: 'test-provider', +} + +describe('AddApiKeyButton', () => { + beforeEach(() => { + vi.clearAllMocks() + _mockModalOpen = false + }) + + afterEach(() => { + cleanup() + }) + + it('renders button with default text', () => { + render(<AddApiKeyButton pluginPayload={defaultPayload} />) + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('renders button with custom text', () => { + render(<AddApiKeyButton pluginPayload={defaultPayload} buttonText="Add Key" />) + expect(screen.getByText('Add Key')).toBeInTheDocument() + }) + + it('opens modal when button is clicked', () => { + render(<AddApiKeyButton pluginPayload={defaultPayload} />) + fireEvent.click(screen.getByRole('button')) + expect(screen.getByTestId('api-key-modal')).toBeInTheDocument() + }) + + it('respects disabled prop', () => { + render(<AddApiKeyButton pluginPayload={defaultPayload} disabled />) + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('closes modal when onClose is called', () => { + render(<AddApiKeyButton pluginPayload={defaultPayload} />) + fireEvent.click(screen.getByRole('button')) + expect(screen.getByTestId('api-key-modal')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('modal-close')) + expect(screen.queryByTestId('api-key-modal')).not.toBeInTheDocument() + }) + + it('applies custom button variant', () => { + render(<AddApiKeyButton pluginPayload={defaultPayload} buttonVariant="primary" />) + expect(screen.getByRole('button')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx new file mode 100644 index 0000000000..46d57a8ab3 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx @@ -0,0 +1,102 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AuthCategory } from '../../types' + +const mockGetPluginOAuthUrl = vi.fn().mockResolvedValue({ authorization_url: 'https://auth.example.com' }) +const mockOpenOAuthPopup = vi.fn() + +vi.mock('@/hooks/use-i18n', () => ({ + useRenderI18nObject: () => (obj: Record<string, string> | string) => typeof obj === 'string' ? obj : obj.en_US || '', +})) + +vi.mock('@/hooks/use-oauth', () => ({ + openOAuthPopup: (...args: unknown[]) => mockOpenOAuthPopup(...args), +})) + +vi.mock('../../hooks/use-credential', () => ({ + useGetPluginOAuthUrlHook: () => ({ + mutateAsync: mockGetPluginOAuthUrl, + }), + useGetPluginOAuthClientSchemaHook: () => ({ + data: { + schema: [], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: true, + client_params: {}, + redirect_uri: 'https://redirect.example.com', + }, + isLoading: false, + }), +})) + +vi.mock('../oauth-client-settings', () => ({ + default: ({ onClose }: { onClose: () => void }) => ( + <div data-testid="oauth-settings-modal"> + <button data-testid="oauth-settings-close" onClick={onClose}>Close</button> + </div> + ), +})) + +vi.mock('@/app/components/base/form/types', () => ({ + FormTypeEnum: { radio: 'radio' }, +})) + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), +})) + +const basePayload = { + category: AuthCategory.tool, + provider: 'test-provider', +} + +describe('AddOAuthButton', () => { + let AddOAuthButton: (typeof import('../add-oauth-button'))['default'] + + beforeEach(async () => { + vi.clearAllMocks() + const mod = await import('../add-oauth-button') + AddOAuthButton = mod.default + }) + + it('should render OAuth button when configured (system params exist)', () => { + render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" />) + + expect(screen.getByText('Use OAuth')).toBeInTheDocument() + }) + + it('should open OAuth settings modal when settings icon clicked', () => { + render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" />) + + fireEvent.click(screen.getByTestId('oauth-settings-button')) + + expect(screen.getByTestId('oauth-settings-modal')).toBeInTheDocument() + }) + + it('should close OAuth settings modal', () => { + render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" />) + + fireEvent.click(screen.getByTestId('oauth-settings-button')) + fireEvent.click(screen.getByTestId('oauth-settings-close')) + + expect(screen.queryByTestId('oauth-settings-modal')).not.toBeInTheDocument() + }) + + it('should trigger OAuth flow on main button click', async () => { + render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" />) + + const button = screen.getByText('Use OAuth').closest('button') + if (button) + fireEvent.click(button) + + expect(mockGetPluginOAuthUrl).toHaveBeenCalled() + }) + + it('should be disabled when disabled prop is true', () => { + render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" disabled />) + + const button = screen.getByText('Use OAuth').closest('button') + expect(button).toBeDisabled() + }) +}) diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx new file mode 100644 index 0000000000..a99b3363d6 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx @@ -0,0 +1,165 @@ +import type { ApiKeyModalProps } from '../api-key-modal' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AuthCategory } from '../../types' + +const mockNotify = vi.fn() +const mockAddPluginCredential = vi.fn().mockResolvedValue({}) +const mockUpdatePluginCredential = vi.fn().mockResolvedValue({}) +const mockFormValues = { isCheckValidated: true, values: { __name__: 'My Key', api_key: 'sk-123' } } + +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ + notify: mockNotify, + }), +})) + +vi.mock('../../hooks/use-credential', () => ({ + useAddPluginCredentialHook: () => ({ + mutateAsync: mockAddPluginCredential, + }), + useGetPluginCredentialSchemaHook: () => ({ + data: [ + { name: 'api_key', label: 'API Key', type: 'secret-input', required: true }, + ], + isLoading: false, + }), + useUpdatePluginCredentialHook: () => ({ + mutateAsync: mockUpdatePluginCredential, + }), +})) + +vi.mock('../../../readme-panel/entrance', () => ({ + ReadmeEntrance: () => <div data-testid="readme-entrance" />, +})) + +vi.mock('../../../readme-panel/store', () => ({ + ReadmeShowType: { modal: 'modal' }, +})) + +vi.mock('@/app/components/base/encrypted-bottom', () => ({ + EncryptedBottom: () => <div data-testid="encrypted-bottom" />, +})) + +vi.mock('@/app/components/base/modal/modal', () => ({ + default: ({ children, title, onClose, onConfirm, onExtraButtonClick, showExtraButton, disabled }: { + children: React.ReactNode + title: string + onClose?: () => void + onCancel?: () => void + onConfirm?: () => void + onExtraButtonClick?: () => void + showExtraButton?: boolean + disabled?: boolean + [key: string]: unknown + }) => ( + <div data-testid="modal"> + <div data-testid="modal-title">{title}</div> + {children} + <button data-testid="modal-confirm" onClick={onConfirm} disabled={disabled}>Confirm</button> + <button data-testid="modal-close" onClick={onClose}>Close</button> + {showExtraButton && <button data-testid="modal-extra" onClick={onExtraButtonClick}>Remove</button>} + </div> + ), +})) + +vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({ + default: React.forwardRef((_props: Record<string, unknown>, ref: React.Ref<unknown>) => { + React.useImperativeHandle(ref, () => ({ + getFormValues: () => mockFormValues, + })) + return <div data-testid="auth-form" /> + }), +})) + +vi.mock('@/app/components/base/form/types', () => ({ + FormTypeEnum: { textInput: 'text-input' }, +})) + +const basePayload = { + category: AuthCategory.tool, + provider: 'test-provider', +} + +describe('ApiKeyModal', () => { + let ApiKeyModal: React.FC<ApiKeyModalProps> + + beforeEach(async () => { + vi.clearAllMocks() + const mod = await import('../api-key-modal') + ApiKeyModal = mod.default + }) + + it('should render modal with correct title', () => { + render(<ApiKeyModal pluginPayload={basePayload} />) + + expect(screen.getByTestId('modal-title')).toHaveTextContent('plugin.auth.useApiAuth') + }) + + it('should render auth form when data is loaded', () => { + render(<ApiKeyModal pluginPayload={basePayload} />) + + expect(screen.getByTestId('auth-form')).toBeInTheDocument() + }) + + it('should show remove button when editValues is provided', () => { + render(<ApiKeyModal pluginPayload={basePayload} editValues={{ api_key: 'existing' }} />) + + expect(screen.getByTestId('modal-extra')).toBeInTheDocument() + }) + + it('should not show remove button in add mode', () => { + render(<ApiKeyModal pluginPayload={basePayload} />) + + expect(screen.queryByTestId('modal-extra')).not.toBeInTheDocument() + }) + + it('should call onClose when close button clicked', () => { + const mockOnClose = vi.fn() + render(<ApiKeyModal pluginPayload={basePayload} onClose={mockOnClose} />) + + fireEvent.click(screen.getByTestId('modal-close')) + expect(mockOnClose).toHaveBeenCalled() + }) + + it('should call addPluginCredential on confirm in add mode', async () => { + const mockOnClose = vi.fn() + const mockOnUpdate = vi.fn() + render(<ApiKeyModal pluginPayload={basePayload} onClose={mockOnClose} onUpdate={mockOnUpdate} />) + + fireEvent.click(screen.getByTestId('modal-confirm')) + + await waitFor(() => { + expect(mockAddPluginCredential).toHaveBeenCalledWith(expect.objectContaining({ + type: 'api-key', + name: 'My Key', + })) + }) + }) + + it('should call updatePluginCredential on confirm in edit mode', async () => { + render(<ApiKeyModal pluginPayload={basePayload} editValues={{ api_key: 'existing', __credential_id__: 'cred-1' }} />) + + fireEvent.click(screen.getByTestId('modal-confirm')) + + await waitFor(() => { + expect(mockUpdatePluginCredential).toHaveBeenCalled() + }) + }) + + it('should call onRemove when remove button clicked', () => { + const mockOnRemove = vi.fn() + render(<ApiKeyModal pluginPayload={basePayload} editValues={{ api_key: 'existing' }} onRemove={mockOnRemove} />) + + fireEvent.click(screen.getByTestId('modal-extra')) + expect(mockOnRemove).toHaveBeenCalled() + }) + + it('should render readme entrance when detail is provided', () => { + const payload = { ...basePayload, detail: { name: 'Test' } as never } + render(<ApiKeyModal pluginPayload={payload} />) + + expect(screen.getByTestId('readme-entrance')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-auth/authorize/authorize-components.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx similarity index 98% rename from web/app/components/plugins/plugin-auth/authorize/authorize-components.spec.tsx rename to web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx index f2a80ead3c..51aa287fea 100644 --- a/web/app/components/plugins/plugin-auth/authorize/authorize-components.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx @@ -1,10 +1,10 @@ import type { ReactNode } from 'react' -import type { PluginPayload } from '../types' +import type { PluginPayload } from '../../types' import type { FormSchema } from '@/app/components/base/form/types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { AuthCategory } from '../types' +import { AuthCategory } from '../../types' // Create a wrapper with QueryClientProvider const createTestQueryClient = () => @@ -36,7 +36,7 @@ const mockAddPluginCredential = vi.fn() const mockUpdatePluginCredential = vi.fn() const mockGetPluginCredentialSchema = vi.fn() -vi.mock('../hooks/use-credential', () => ({ +vi.mock('../../hooks/use-credential', () => ({ useGetPluginOAuthUrlHook: () => ({ mutateAsync: mockGetPluginOAuthUrl, }), @@ -117,12 +117,12 @@ const createFormSchema = (overrides: Partial<FormSchema> = {}): FormSchema => ({ // ==================== AddApiKeyButton Tests ==================== describe('AddApiKeyButton', () => { - let AddApiKeyButton: typeof import('./add-api-key-button').default + let AddApiKeyButton: typeof import('../add-api-key-button').default beforeEach(async () => { vi.clearAllMocks() mockGetPluginCredentialSchema.mockReturnValue([]) - const importedAddApiKeyButton = await import('./add-api-key-button') + const importedAddApiKeyButton = await import('../add-api-key-button') AddApiKeyButton = importedAddApiKeyButton.default }) @@ -327,7 +327,7 @@ describe('AddApiKeyButton', () => { describe('Memoization', () => { it('should be a memoized component', async () => { - const AddApiKeyButtonDefault = (await import('./add-api-key-button')).default + const AddApiKeyButtonDefault = (await import('../add-api-key-button')).default expect(typeof AddApiKeyButtonDefault).toBe('object') }) }) @@ -335,7 +335,7 @@ describe('AddApiKeyButton', () => { // ==================== AddOAuthButton Tests ==================== describe('AddOAuthButton', () => { - let AddOAuthButton: typeof import('./add-oauth-button').default + let AddOAuthButton: typeof import('../add-oauth-button').default beforeEach(async () => { vi.clearAllMocks() @@ -347,7 +347,7 @@ describe('AddOAuthButton', () => { redirect_uri: 'https://example.com/callback', }) mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: 'https://oauth.example.com/auth' }) - const importedAddOAuthButton = await import('./add-oauth-button') + const importedAddOAuthButton = await import('../add-oauth-button') AddOAuthButton = importedAddOAuthButton.default }) @@ -856,7 +856,7 @@ describe('AddOAuthButton', () => { // ==================== ApiKeyModal Tests ==================== describe('ApiKeyModal', () => { - let ApiKeyModal: typeof import('./api-key-modal').default + let ApiKeyModal: typeof import('../api-key-modal').default beforeEach(async () => { vi.clearAllMocks() @@ -870,7 +870,7 @@ describe('ApiKeyModal', () => { isCheckValidated: false, values: {}, }) - const importedApiKeyModal = await import('./api-key-modal') + const importedApiKeyModal = await import('../api-key-modal') ApiKeyModal = importedApiKeyModal.default }) @@ -1272,13 +1272,13 @@ describe('ApiKeyModal', () => { // ==================== OAuthClientSettings Tests ==================== describe('OAuthClientSettings', () => { - let OAuthClientSettings: typeof import('./oauth-client-settings').default + let OAuthClientSettings: typeof import('../oauth-client-settings').default beforeEach(async () => { vi.clearAllMocks() mockSetPluginOAuthCustomClient.mockResolvedValue({}) mockDeletePluginOAuthCustomClient.mockResolvedValue({}) - const importedOAuthClientSettings = await import('./oauth-client-settings') + const importedOAuthClientSettings = await import('../oauth-client-settings') OAuthClientSettings = importedOAuthClientSettings.default }) @@ -2193,7 +2193,7 @@ describe('OAuthClientSettings', () => { describe('Memoization', () => { it('should be a memoized component', async () => { - const OAuthClientSettingsDefault = (await import('./oauth-client-settings')).default + const OAuthClientSettingsDefault = (await import('../oauth-client-settings')).default expect(typeof OAuthClientSettingsDefault).toBe('object') }) }) @@ -2216,7 +2216,7 @@ describe('Authorize Components Integration', () => { describe('AddApiKeyButton -> ApiKeyModal Flow', () => { it('should open ApiKeyModal when AddApiKeyButton is clicked', async () => { - const AddApiKeyButton = (await import('./add-api-key-button')).default + const AddApiKeyButton = (await import('../add-api-key-button')).default const pluginPayload = createPluginPayload() render(<AddApiKeyButton pluginPayload={pluginPayload} />, { wrapper: createWrapper() }) @@ -2231,7 +2231,7 @@ describe('Authorize Components Integration', () => { describe('AddOAuthButton -> OAuthClientSettings Flow', () => { it('should open OAuthClientSettings when setup button is clicked', async () => { - const AddOAuthButton = (await import('./add-oauth-button')).default + const AddOAuthButton = (await import('../add-oauth-button')).default const pluginPayload = createPluginPayload() mockGetPluginOAuthClientSchema.mockReturnValue({ schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })], diff --git a/web/app/components/plugins/plugin-auth/authorize/index.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/plugins/plugin-auth/authorize/index.spec.tsx rename to web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx index 354ef8eeea..fb7eb4bd12 100644 --- a/web/app/components/plugins/plugin-auth/authorize/index.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx @@ -1,10 +1,10 @@ import type { ReactNode } from 'react' -import type { PluginPayload } from '../types' +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' +import { AuthCategory } from '../../types' +import Authorize from '../index' // Create a wrapper with QueryClientProvider for real component testing const createTestQueryClient = () => @@ -29,7 +29,7 @@ const createWrapper = () => { // Mock API hooks - only mock network-related hooks const mockGetPluginOAuthClientSchema = vi.fn() -vi.mock('../hooks/use-credential', () => ({ +vi.mock('../../hooks/use-credential', () => ({ useGetPluginOAuthUrlHook: () => ({ mutateAsync: vi.fn().mockResolvedValue({ authorization_url: '' }), }), @@ -568,7 +568,7 @@ describe('Authorize', () => { // ==================== Component Memoization ==================== describe('Component Memoization', () => { it('should be a memoized component (exported with memo)', async () => { - const AuthorizeDefault = (await import('./index')).default + const AuthorizeDefault = (await import('../index')).default expect(AuthorizeDefault).toBeDefined() // memo wrapped components are React elements with $$typeof expect(typeof AuthorizeDefault).toBe('object') diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx new file mode 100644 index 0000000000..61920e2869 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx @@ -0,0 +1,179 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AuthCategory } from '../../types' + +const mockNotify = vi.fn() +const mockSetPluginOAuthCustomClient = vi.fn().mockResolvedValue({}) +const mockDeletePluginOAuthCustomClient = vi.fn().mockResolvedValue({}) +const mockInvalidPluginOAuthClientSchema = vi.fn() +const mockFormValues = { isCheckValidated: true, values: { __oauth_client__: 'custom', client_id: 'test-id' } } + +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ + notify: mockNotify, + }), +})) + +vi.mock('../../hooks/use-credential', () => ({ + useSetPluginOAuthCustomClientHook: () => ({ + mutateAsync: mockSetPluginOAuthCustomClient, + }), + useDeletePluginOAuthCustomClientHook: () => ({ + mutateAsync: mockDeletePluginOAuthCustomClient, + }), + useInvalidPluginOAuthClientSchemaHook: () => mockInvalidPluginOAuthClientSchema, +})) + +vi.mock('../../../readme-panel/entrance', () => ({ + ReadmeEntrance: () => <div data-testid="readme-entrance" />, +})) + +vi.mock('../../../readme-panel/store', () => ({ + ReadmeShowType: { modal: 'modal' }, +})) + +vi.mock('@/app/components/base/modal/modal', () => ({ + default: ({ children, title, onClose: _onClose, onConfirm, onCancel, onExtraButtonClick, footerSlot }: { + children: React.ReactNode + title: string + onClose?: () => void + onConfirm?: () => void + onCancel?: () => void + onExtraButtonClick?: () => void + footerSlot?: React.ReactNode + [key: string]: unknown + }) => ( + <div data-testid="modal"> + <div data-testid="modal-title">{title}</div> + {children} + <button data-testid="modal-confirm" onClick={onConfirm}>Save And Auth</button> + <button data-testid="modal-cancel" onClick={onCancel}>Save Only</button> + <button data-testid="modal-close" onClick={onExtraButtonClick}>Cancel</button> + {!!footerSlot && <div data-testid="footer-slot">{footerSlot}</div>} + </div> + ), +})) + +vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({ + default: React.forwardRef((_props: Record<string, unknown>, ref: React.Ref<unknown>) => { + React.useImperativeHandle(ref, () => ({ + getFormValues: () => mockFormValues, + })) + return <div data-testid="auth-form" /> + }), +})) + +vi.mock('@tanstack/react-form', () => ({ + useForm: (config: Record<string, unknown>) => ({ + store: { subscribe: vi.fn(), getState: () => ({ values: config.defaultValues || {} }) }, + }), + useStore: (_store: unknown, selector: (state: Record<string, unknown>) => unknown) => { + return selector({ values: { __oauth_client__: 'custom' } }) + }, +})) + +const basePayload = { + category: AuthCategory.tool, + provider: 'test-provider', +} + +const defaultSchemas = [ + { name: 'client_id', label: 'Client ID', type: 'text-input', required: true }, +] as never + +describe('OAuthClientSettings', () => { + let OAuthClientSettings: (typeof import('../oauth-client-settings'))['default'] + + beforeEach(async () => { + vi.clearAllMocks() + const mod = await import('../oauth-client-settings') + OAuthClientSettings = mod.default + }) + + it('should render modal with correct title', () => { + render( + <OAuthClientSettings + pluginPayload={basePayload} + schemas={defaultSchemas} + />, + ) + + expect(screen.getByTestId('modal-title')).toHaveTextContent('plugin.auth.oauthClientSettings') + }) + + it('should render auth form', () => { + render( + <OAuthClientSettings + pluginPayload={basePayload} + schemas={defaultSchemas} + />, + ) + + expect(screen.getByTestId('auth-form')).toBeInTheDocument() + }) + + it('should call onClose when cancel clicked', () => { + const mockOnClose = vi.fn() + render( + <OAuthClientSettings + pluginPayload={basePayload} + schemas={defaultSchemas} + onClose={mockOnClose} + />, + ) + + fireEvent.click(screen.getByTestId('modal-close')) + expect(mockOnClose).toHaveBeenCalled() + }) + + it('should save settings on save only button click', async () => { + const mockOnClose = vi.fn() + const mockOnUpdate = vi.fn() + render( + <OAuthClientSettings + pluginPayload={basePayload} + schemas={defaultSchemas} + onClose={mockOnClose} + onUpdate={mockOnUpdate} + />, + ) + + fireEvent.click(screen.getByTestId('modal-cancel')) + + await waitFor(() => { + expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledWith(expect.objectContaining({ + enable_oauth_custom_client: true, + })) + }) + }) + + it('should save and authorize on confirm button click', async () => { + const mockOnAuth = vi.fn().mockResolvedValue(undefined) + render( + <OAuthClientSettings + pluginPayload={basePayload} + schemas={defaultSchemas} + onAuth={mockOnAuth} + />, + ) + + fireEvent.click(screen.getByTestId('modal-confirm')) + + await waitFor(() => { + expect(mockSetPluginOAuthCustomClient).toHaveBeenCalled() + }) + }) + + it('should render readme entrance when detail is provided', () => { + const payload = { ...basePayload, detail: { name: 'Test' } as never } + render( + <OAuthClientSettings + pluginPayload={payload} + schemas={defaultSchemas} + />, + ) + + expect(screen.getByTestId('readme-entrance')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-auth/authorized/index.spec.tsx b/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-auth/authorized/index.spec.tsx rename to web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx index 6d6fbf7cb4..f56c814222 100644 --- a/web/app/components/plugins/plugin-auth/authorized/index.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx @@ -1,10 +1,10 @@ import type { ReactNode } from 'react' -import type { Credential, PluginPayload } from '../types' +import type { Credential, PluginPayload } from '../../types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { AuthCategory, CredentialTypeEnum } from '../types' -import Authorized from './index' +import { AuthCategory, CredentialTypeEnum } from '../../types' +import Authorized from '../index' // ==================== Mock Setup ==================== @@ -13,7 +13,7 @@ const mockDeletePluginCredential = vi.fn() const mockSetPluginDefaultCredential = vi.fn() const mockUpdatePluginCredential = vi.fn() -vi.mock('../hooks/use-credential', () => ({ +vi.mock('../../hooks/use-credential', () => ({ useDeletePluginCredentialHook: () => ({ mutateAsync: mockDeletePluginCredential, }), @@ -1620,7 +1620,7 @@ describe('Authorized Component', () => { // ==================== Memoization Test ==================== describe('Memoization', () => { it('should be memoized', async () => { - const AuthorizedModule = await import('./index') + const AuthorizedModule = await import('../index') // memo returns an object with $$typeof expect(typeof AuthorizedModule.default).toBe('object') }) diff --git a/web/app/components/plugins/plugin-auth/authorized/item.spec.tsx b/web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-auth/authorized/item.spec.tsx rename to web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx index 7ea82010b1..156b20b7d9 100644 --- a/web/app/components/plugins/plugin-auth/authorized/item.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx @@ -1,8 +1,8 @@ -import type { Credential } from '../types' +import type { Credential } from '../../types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { CredentialTypeEnum } from '../types' -import Item from './item' +import { CredentialTypeEnum } from '../../types' +import Item from '../item' // ==================== Test Utilities ==================== @@ -829,7 +829,7 @@ describe('Item Component', () => { // ==================== Memoization Test ==================== describe('Memoization', () => { it('should be memoized', async () => { - const ItemModule = await import('./item') + const ItemModule = await import('../item') // memo returns an object with $$typeof expect(typeof ItemModule.default).toBe('object') }) diff --git a/web/app/components/plugins/plugin-auth/hooks/__tests__/use-credential.spec.ts b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-credential.spec.ts new file mode 100644 index 0000000000..7777fbff97 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-credential.spec.ts @@ -0,0 +1,186 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AuthCategory, CredentialTypeEnum } from '../../types' +import { + useAddPluginCredentialHook, + useDeletePluginCredentialHook, + useDeletePluginOAuthCustomClientHook, + useGetPluginCredentialInfoHook, + useGetPluginCredentialSchemaHook, + useGetPluginOAuthClientSchemaHook, + useGetPluginOAuthUrlHook, + useInvalidPluginCredentialInfoHook, + useInvalidPluginOAuthClientSchemaHook, + useSetPluginDefaultCredentialHook, + useSetPluginOAuthCustomClientHook, + useUpdatePluginCredentialHook, +} from '../use-credential' + +// Mock service hooks +const mockUseGetPluginCredentialInfo = vi.fn().mockReturnValue({ data: null, isLoading: false }) +const mockUseDeletePluginCredential = vi.fn().mockReturnValue({ mutateAsync: vi.fn() }) +const mockUseInvalidPluginCredentialInfo = vi.fn().mockReturnValue(vi.fn()) +const mockUseSetPluginDefaultCredential = vi.fn().mockReturnValue({ mutateAsync: vi.fn() }) +const mockUseGetPluginCredentialSchema = vi.fn().mockReturnValue({ data: [], isLoading: false }) +const mockUseAddPluginCredential = vi.fn().mockReturnValue({ mutateAsync: vi.fn() }) +const mockUseUpdatePluginCredential = vi.fn().mockReturnValue({ mutateAsync: vi.fn() }) +const mockUseGetPluginOAuthUrl = vi.fn().mockReturnValue({ mutateAsync: vi.fn() }) +const mockUseGetPluginOAuthClientSchema = vi.fn().mockReturnValue({ data: null, isLoading: false }) +const mockUseInvalidPluginOAuthClientSchema = vi.fn().mockReturnValue(vi.fn()) +const mockUseSetPluginOAuthCustomClient = vi.fn().mockReturnValue({ mutateAsync: vi.fn() }) +const mockUseDeletePluginOAuthCustomClient = vi.fn().mockReturnValue({ mutateAsync: vi.fn() }) +const mockInvalidToolsByType = vi.fn() + +vi.mock('@/service/use-plugins-auth', () => ({ + useGetPluginCredentialInfo: (...args: unknown[]) => mockUseGetPluginCredentialInfo(...args), + useDeletePluginCredential: (...args: unknown[]) => mockUseDeletePluginCredential(...args), + useInvalidPluginCredentialInfo: (...args: unknown[]) => mockUseInvalidPluginCredentialInfo(...args), + useSetPluginDefaultCredential: (...args: unknown[]) => mockUseSetPluginDefaultCredential(...args), + useGetPluginCredentialSchema: (...args: unknown[]) => mockUseGetPluginCredentialSchema(...args), + useAddPluginCredential: (...args: unknown[]) => mockUseAddPluginCredential(...args), + useUpdatePluginCredential: (...args: unknown[]) => mockUseUpdatePluginCredential(...args), + useGetPluginOAuthUrl: (...args: unknown[]) => mockUseGetPluginOAuthUrl(...args), + useGetPluginOAuthClientSchema: (...args: unknown[]) => mockUseGetPluginOAuthClientSchema(...args), + useInvalidPluginOAuthClientSchema: (...args: unknown[]) => mockUseInvalidPluginOAuthClientSchema(...args), + useSetPluginOAuthCustomClient: (...args: unknown[]) => mockUseSetPluginOAuthCustomClient(...args), + useDeletePluginOAuthCustomClient: (...args: unknown[]) => mockUseDeletePluginOAuthCustomClient(...args), +})) + +vi.mock('@/service/use-tools', () => ({ + useInvalidToolsByType: () => mockInvalidToolsByType, +})) + +const toolPayload = { + category: AuthCategory.tool, + provider: 'test-provider', + providerType: 'builtin', +} + +describe('use-credential hooks', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('useGetPluginCredentialInfoHook', () => { + it('should call service with correct URL when enabled', () => { + renderHook(() => useGetPluginCredentialInfoHook(toolPayload, true)) + expect(mockUseGetPluginCredentialInfo).toHaveBeenCalledWith( + `/workspaces/current/tool-provider/builtin/${toolPayload.provider}/credential/info`, + ) + }) + + it('should pass empty string when disabled', () => { + renderHook(() => useGetPluginCredentialInfoHook(toolPayload, false)) + expect(mockUseGetPluginCredentialInfo).toHaveBeenCalledWith('') + }) + }) + + describe('useDeletePluginCredentialHook', () => { + it('should call service with correct URL', () => { + renderHook(() => useDeletePluginCredentialHook(toolPayload)) + expect(mockUseDeletePluginCredential).toHaveBeenCalledWith( + `/workspaces/current/tool-provider/builtin/${toolPayload.provider}/delete`, + ) + }) + }) + + describe('useInvalidPluginCredentialInfoHook', () => { + it('should return a function that invalidates both credential info and tools', () => { + const { result } = renderHook(() => useInvalidPluginCredentialInfoHook(toolPayload)) + + result.current() + + const invalidFn = mockUseInvalidPluginCredentialInfo.mock.results[0].value + expect(invalidFn).toHaveBeenCalled() + expect(mockInvalidToolsByType).toHaveBeenCalled() + }) + }) + + describe('useSetPluginDefaultCredentialHook', () => { + it('should call service with correct URL', () => { + renderHook(() => useSetPluginDefaultCredentialHook(toolPayload)) + expect(mockUseSetPluginDefaultCredential).toHaveBeenCalledWith( + `/workspaces/current/tool-provider/builtin/${toolPayload.provider}/default-credential`, + ) + }) + }) + + describe('useGetPluginCredentialSchemaHook', () => { + it('should call service with correct schema URL for API_KEY', () => { + renderHook(() => useGetPluginCredentialSchemaHook(toolPayload, CredentialTypeEnum.API_KEY)) + expect(mockUseGetPluginCredentialSchema).toHaveBeenCalledWith( + `/workspaces/current/tool-provider/builtin/${toolPayload.provider}/credential/schema/${CredentialTypeEnum.API_KEY}`, + ) + }) + + it('should call service with correct schema URL for OAUTH2', () => { + renderHook(() => useGetPluginCredentialSchemaHook(toolPayload, CredentialTypeEnum.OAUTH2)) + expect(mockUseGetPluginCredentialSchema).toHaveBeenCalledWith( + `/workspaces/current/tool-provider/builtin/${toolPayload.provider}/credential/schema/${CredentialTypeEnum.OAUTH2}`, + ) + }) + }) + + describe('useAddPluginCredentialHook', () => { + it('should call service with correct URL', () => { + renderHook(() => useAddPluginCredentialHook(toolPayload)) + expect(mockUseAddPluginCredential).toHaveBeenCalledWith( + `/workspaces/current/tool-provider/builtin/${toolPayload.provider}/add`, + ) + }) + }) + + describe('useUpdatePluginCredentialHook', () => { + it('should call service with correct URL', () => { + renderHook(() => useUpdatePluginCredentialHook(toolPayload)) + expect(mockUseUpdatePluginCredential).toHaveBeenCalledWith( + `/workspaces/current/tool-provider/builtin/${toolPayload.provider}/update`, + ) + }) + }) + + describe('useGetPluginOAuthUrlHook', () => { + it('should call service with correct URL', () => { + renderHook(() => useGetPluginOAuthUrlHook(toolPayload)) + expect(mockUseGetPluginOAuthUrl).toHaveBeenCalledWith( + `/oauth/plugin/${toolPayload.provider}/tool/authorization-url`, + ) + }) + }) + + describe('useGetPluginOAuthClientSchemaHook', () => { + it('should call service with correct URL', () => { + renderHook(() => useGetPluginOAuthClientSchemaHook(toolPayload)) + expect(mockUseGetPluginOAuthClientSchema).toHaveBeenCalledWith( + `/workspaces/current/tool-provider/builtin/${toolPayload.provider}/oauth/client-schema`, + ) + }) + }) + + describe('useInvalidPluginOAuthClientSchemaHook', () => { + it('should call service with correct URL', () => { + renderHook(() => useInvalidPluginOAuthClientSchemaHook(toolPayload)) + expect(mockUseInvalidPluginOAuthClientSchema).toHaveBeenCalledWith( + `/workspaces/current/tool-provider/builtin/${toolPayload.provider}/oauth/client-schema`, + ) + }) + }) + + describe('useSetPluginOAuthCustomClientHook', () => { + it('should call service with correct URL', () => { + renderHook(() => useSetPluginOAuthCustomClientHook(toolPayload)) + expect(mockUseSetPluginOAuthCustomClient).toHaveBeenCalledWith( + `/workspaces/current/tool-provider/builtin/${toolPayload.provider}/oauth/custom-client`, + ) + }) + }) + + describe('useDeletePluginOAuthCustomClientHook', () => { + it('should call service with correct URL', () => { + renderHook(() => useDeletePluginOAuthCustomClientHook(toolPayload)) + expect(mockUseDeletePluginOAuthCustomClient).toHaveBeenCalledWith( + `/workspaces/current/tool-provider/builtin/${toolPayload.provider}/oauth/custom-client`, + ) + }) + }) +}) diff --git a/web/app/components/plugins/plugin-auth/hooks/__tests__/use-get-api.spec.ts b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-get-api.spec.ts new file mode 100644 index 0000000000..6b1063dce5 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-get-api.spec.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { AuthCategory, CredentialTypeEnum } from '../../types' +import { useGetApi } from '../use-get-api' + +describe('useGetApi', () => { + const provider = 'test-provider' + + describe('tool category', () => { + it('returns correct API paths for tool category', () => { + const api = useGetApi({ category: AuthCategory.tool, provider }) + expect(api.getCredentialInfo).toBe(`/workspaces/current/tool-provider/builtin/${provider}/credential/info`) + expect(api.setDefaultCredential).toBe(`/workspaces/current/tool-provider/builtin/${provider}/default-credential`) + expect(api.getCredentials).toBe(`/workspaces/current/tool-provider/builtin/${provider}/credentials`) + expect(api.addCredential).toBe(`/workspaces/current/tool-provider/builtin/${provider}/add`) + expect(api.updateCredential).toBe(`/workspaces/current/tool-provider/builtin/${provider}/update`) + expect(api.deleteCredential).toBe(`/workspaces/current/tool-provider/builtin/${provider}/delete`) + expect(api.getOauthUrl).toBe(`/oauth/plugin/${provider}/tool/authorization-url`) + }) + + it('returns a function for getCredentialSchema', () => { + const api = useGetApi({ category: AuthCategory.tool, provider }) + expect(typeof api.getCredentialSchema).toBe('function') + const schemaUrl = api.getCredentialSchema('api-key' as never) + expect(schemaUrl).toBe(`/workspaces/current/tool-provider/builtin/${provider}/credential/schema/api-key`) + }) + + it('includes OAuth client endpoints', () => { + const api = useGetApi({ category: AuthCategory.tool, provider }) + expect(api.getOauthClientSchema).toBe(`/workspaces/current/tool-provider/builtin/${provider}/oauth/client-schema`) + expect(api.setCustomOauthClient).toBe(`/workspaces/current/tool-provider/builtin/${provider}/oauth/custom-client`) + }) + }) + + describe('datasource category', () => { + it('returns correct API paths for datasource category', () => { + const api = useGetApi({ category: AuthCategory.datasource, provider }) + expect(api.getCredentials).toBe(`/auth/plugin/datasource/${provider}`) + expect(api.addCredential).toBe(`/auth/plugin/datasource/${provider}`) + expect(api.updateCredential).toBe(`/auth/plugin/datasource/${provider}/update`) + expect(api.deleteCredential).toBe(`/auth/plugin/datasource/${provider}/delete`) + expect(api.setDefaultCredential).toBe(`/auth/plugin/datasource/${provider}/default`) + expect(api.getOauthUrl).toBe(`/oauth/plugin/${provider}/datasource/get-authorization-url`) + }) + + it('returns empty string for getCredentialInfo', () => { + const api = useGetApi({ category: AuthCategory.datasource, provider }) + expect(api.getCredentialInfo).toBe('') + }) + + it('returns a function for getCredentialSchema that returns empty string', () => { + const api = useGetApi({ category: AuthCategory.datasource, provider }) + expect(api.getCredentialSchema(CredentialTypeEnum.API_KEY)).toBe('') + }) + }) + + describe('other categories', () => { + it('returns empty strings as fallback for unsupported category', () => { + const api = useGetApi({ category: AuthCategory.model, provider }) + expect(api.getCredentialInfo).toBe('') + expect(api.setDefaultCredential).toBe('') + expect(api.getCredentials).toBe('') + expect(api.addCredential).toBe('') + expect(api.updateCredential).toBe('') + expect(api.deleteCredential).toBe('') + expect(api.getOauthUrl).toBe('') + }) + + it('returns a function for getCredentialSchema that returns empty string', () => { + const api = useGetApi({ category: AuthCategory.model, provider }) + expect(api.getCredentialSchema(CredentialTypeEnum.API_KEY)).toBe('') + }) + }) + + describe('default category', () => { + it('defaults to tool category when category is not specified', () => { + const api = useGetApi({ provider } as { category: AuthCategory, provider: string }) + expect(api.getCredentialInfo).toContain('tool-provider') + }) + }) +}) diff --git a/web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth-action.spec.ts b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth-action.spec.ts new file mode 100644 index 0000000000..d31b29ab85 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth-action.spec.ts @@ -0,0 +1,191 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { usePluginAuthAction } from '../../hooks/use-plugin-auth-action' +import { AuthCategory } from '../../types' + +const mockDeletePluginCredential = vi.fn().mockResolvedValue({}) +const mockSetPluginDefaultCredential = vi.fn().mockResolvedValue({}) +const mockUpdatePluginCredential = vi.fn().mockResolvedValue({}) +const mockNotify = vi.fn() + +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ + notify: mockNotify, + }), +})) + +vi.mock('../../hooks/use-credential', () => ({ + useDeletePluginCredentialHook: () => ({ + mutateAsync: mockDeletePluginCredential, + }), + useSetPluginDefaultCredentialHook: () => ({ + mutateAsync: mockSetPluginDefaultCredential, + }), + useUpdatePluginCredentialHook: () => ({ + mutateAsync: mockUpdatePluginCredential, + }), +})) + +const pluginPayload = { + category: AuthCategory.tool, + provider: 'test-provider', +} + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return function Wrapper({ children }: { children: ReactNode }) { + return React.createElement(QueryClientProvider, { client: queryClient }, children) + } +} + +describe('usePluginAuthAction', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should initialize with default state', () => { + const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { + wrapper: createWrapper(), + }) + + expect(result.current.doingAction).toBe(false) + expect(result.current.deleteCredentialId).toBeNull() + expect(result.current.editValues).toBeNull() + }) + + it('should open and close confirm dialog', () => { + const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.openConfirm('cred-1') + }) + expect(result.current.deleteCredentialId).toBe('cred-1') + + act(() => { + result.current.closeConfirm() + }) + expect(result.current.deleteCredentialId).toBeNull() + }) + + it('should handle edit action', () => { + const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { + wrapper: createWrapper(), + }) + + const editVals = { key: 'value' } + act(() => { + result.current.handleEdit('cred-1', editVals) + }) + expect(result.current.editValues).toEqual(editVals) + }) + + it('should handle remove action by setting deleteCredentialId', () => { + const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.handleEdit('cred-1', { key: 'value' }) + }) + + act(() => { + result.current.handleRemove() + }) + expect(result.current.deleteCredentialId).toBe('cred-1') + }) + + it('should handle confirm delete', async () => { + const mockOnUpdate = vi.fn() + const { result } = renderHook(() => usePluginAuthAction(pluginPayload, mockOnUpdate), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.openConfirm('cred-1') + }) + + await act(async () => { + await result.current.handleConfirm() + }) + + expect(mockDeletePluginCredential).toHaveBeenCalledWith({ credential_id: 'cred-1' }) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })) + expect(mockOnUpdate).toHaveBeenCalled() + expect(result.current.deleteCredentialId).toBeNull() + }) + + it('should handle set default credential', async () => { + const mockOnUpdate = vi.fn() + const { result } = renderHook(() => usePluginAuthAction(pluginPayload, mockOnUpdate), { + wrapper: createWrapper(), + }) + + await act(async () => { + await result.current.handleSetDefault('cred-1') + }) + + expect(mockSetPluginDefaultCredential).toHaveBeenCalledWith('cred-1') + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })) + expect(mockOnUpdate).toHaveBeenCalled() + }) + + it('should handle rename credential', async () => { + const mockOnUpdate = vi.fn() + const { result } = renderHook(() => usePluginAuthAction(pluginPayload, mockOnUpdate), { + wrapper: createWrapper(), + }) + + await act(async () => { + await result.current.handleRename({ + credential_id: 'cred-1', + name: 'New Name', + }) + }) + + expect(mockUpdatePluginCredential).toHaveBeenCalledWith({ + credential_id: 'cred-1', + name: 'New Name', + }) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })) + expect(mockOnUpdate).toHaveBeenCalled() + }) + + it('should prevent concurrent actions during doingAction', async () => { + const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.handleSetDoingAction(true) + }) + expect(result.current.doingAction).toBe(true) + + act(() => { + result.current.openConfirm('cred-1') + }) + await act(async () => { + await result.current.handleConfirm() + }) + expect(mockDeletePluginCredential).not.toHaveBeenCalled() + }) + + it('should handle confirm without pending credential ID', async () => { + const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { + wrapper: createWrapper(), + }) + + await act(async () => { + await result.current.handleConfirm() + }) + + expect(mockDeletePluginCredential).not.toHaveBeenCalled() + expect(result.current.deleteCredentialId).toBeNull() + }) +}) diff --git a/web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth.spec.ts b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth.spec.ts new file mode 100644 index 0000000000..2903eb8c34 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth.spec.ts @@ -0,0 +1,110 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AuthCategory, CredentialTypeEnum } from '../../types' +import { usePluginAuth } from '../use-plugin-auth' + +// Mock dependencies +const mockCredentials = [ + { id: '1', credential_type: CredentialTypeEnum.API_KEY, is_default: false }, + { id: '2', credential_type: CredentialTypeEnum.OAUTH2, is_default: true }, +] + +const mockCredentialInfo = vi.fn().mockReturnValue({ + credentials: mockCredentials, + supported_credential_types: [CredentialTypeEnum.API_KEY, CredentialTypeEnum.OAUTH2], + allow_custom_token: true, +}) + +const mockInvalidate = vi.fn() + +vi.mock('../use-credential', () => ({ + useGetPluginCredentialInfoHook: (_payload: unknown, enable?: boolean) => ({ + data: enable ? mockCredentialInfo() : undefined, + isLoading: false, + }), + useInvalidPluginCredentialInfoHook: () => mockInvalidate, +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: true, + }), +})) + +const basePayload = { + category: AuthCategory.tool, + provider: 'test-provider', +} + +describe('usePluginAuth', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return authorized state when credentials exist', () => { + const { result } = renderHook(() => usePluginAuth(basePayload, true)) + + expect(result.current.isAuthorized).toBe(true) + expect(result.current.credentials).toHaveLength(2) + }) + + it('should detect OAuth and API Key support', () => { + const { result } = renderHook(() => usePluginAuth(basePayload, true)) + + expect(result.current.canOAuth).toBe(true) + expect(result.current.canApiKey).toBe(true) + }) + + it('should return disabled=false for workspace managers', () => { + const { result } = renderHook(() => usePluginAuth(basePayload, true)) + + expect(result.current.disabled).toBe(false) + }) + + it('should return notAllowCustomCredential=false when allowed', () => { + const { result } = renderHook(() => usePluginAuth(basePayload, true)) + + expect(result.current.notAllowCustomCredential).toBe(false) + }) + + it('should return unauthorized when enable is false', () => { + const { result } = renderHook(() => usePluginAuth(basePayload, false)) + + expect(result.current.isAuthorized).toBe(false) + expect(result.current.credentials).toEqual([]) + }) + + it('should provide invalidate function', () => { + const { result } = renderHook(() => usePluginAuth(basePayload, true)) + + expect(result.current.invalidPluginCredentialInfo).toBe(mockInvalidate) + }) + + it('should handle empty credentials', () => { + mockCredentialInfo.mockReturnValueOnce({ + credentials: [], + supported_credential_types: [], + allow_custom_token: false, + }) + + const { result } = renderHook(() => usePluginAuth(basePayload, true)) + + expect(result.current.isAuthorized).toBe(false) + expect(result.current.canOAuth).toBe(false) + expect(result.current.canApiKey).toBe(false) + expect(result.current.notAllowCustomCredential).toBe(true) + }) + + it('should handle only API Key support', () => { + mockCredentialInfo.mockReturnValueOnce({ + credentials: [{ id: '1' }], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + + const { result } = renderHook(() => usePluginAuth(basePayload, true)) + + expect(result.current.canApiKey).toBe(true) + expect(result.current.canOAuth).toBe(false) + }) +}) diff --git a/web/app/components/plugins/plugin-auth/index.spec.tsx b/web/app/components/plugins/plugin-auth/index.spec.tsx deleted file mode 100644 index 328de71e8d..0000000000 --- a/web/app/components/plugins/plugin-auth/index.spec.tsx +++ /dev/null @@ -1,2035 +0,0 @@ -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 }) => ( - <QueryClientProvider client={testQueryClient}> - {children} - </QueryClientProvider> - ) -} - -// Factory functions for test data -const createPluginPayload = (overrides: Partial<PluginPayload> = {}): PluginPayload => ({ - category: AuthCategory.tool, - provider: 'test-provider', - ...overrides, -}) - -const createCredential = (overrides: Partial<Credential> = {}): 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>[] = []): 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<string, unknown>) - - // 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( - <PluginAuth pluginPayload={pluginPayload} />, - { 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( - <PluginAuth pluginPayload={pluginPayload} />, - { 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( - <PluginAuth pluginPayload={pluginPayload}> - <div data-testid="custom-children">Custom Content</div> - </PluginAuth>, - { 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( - <PluginAuth pluginPayload={pluginPayload} className="custom-class" />, - { 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( - <PluginAuth pluginPayload={pluginPayload} className="custom-class" />, - { 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( - <PluginAuthInAgent pluginPayload={pluginPayload} />, - { 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( - <PluginAuthInAgent pluginPayload={pluginPayload} />, - { 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( - <PluginAuthInAgent - pluginPayload={pluginPayload} - credentialId="selected-id" - />, - { 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( - <PluginAuthInAgent - pluginPayload={pluginPayload} - credentialId="non-existent-id" - />, - { 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( - <PluginAuthInAgent - pluginPayload={pluginPayload} - credentialId="unavailable-id" - />, - { 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( - <PluginAuthInAgent - pluginPayload={pluginPayload} - onAuthorizationItemClick={onAuthorizationItemClick} - />, - { 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( - <PluginAuthInAgent - pluginPayload={pluginPayload} - onAuthorizationItemClick={onAuthorizationItemClick} - />, - { 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( - <PluginAuthInAgent - pluginPayload={pluginPayload} - onAuthorizationItemClick={onAuthorizationItemClick} - />, - { 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( - <PluginAuthInDataSourceNode - isAuthorized={false} - onJumpToDataSourcePage={onJumpToDataSourcePage} - />, - ) - - 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( - <PluginAuthInDataSourceNode - isAuthorized={false} - onJumpToDataSourcePage={onJumpToDataSourcePage} - />, - ) - - 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( - <PluginAuthInDataSourceNode - isAuthorized={true} - onJumpToDataSourcePage={onJumpToDataSourcePage} - > - <div data-testid="children-content">Authorized Content</div> - </PluginAuthInDataSourceNode>, - ) - - 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( - <PluginAuthInDataSourceNode - isAuthorized={true} - onJumpToDataSourcePage={onJumpToDataSourcePage} - />, - ) - - 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( - <PluginAuthInDataSourceNode - isAuthorized={false} - onJumpToDataSourcePage={onJumpToDataSourcePage} - > - <div data-testid="children-content">Authorized Content</div> - </PluginAuthInDataSourceNode>, - ) - - 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( - <PluginAuthInDataSourceNode - onJumpToDataSourcePage={onJumpToDataSourcePage} - > - <div data-testid="children-content">Content</div> - </PluginAuthInDataSourceNode>, - ) - - // 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( - <AuthorizedInDataSourceNode - authorizationsNum={1} - onJumpToDataSourcePage={onJumpToDataSourcePage} - />, - ) - - 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( - <AuthorizedInDataSourceNode - authorizationsNum={3} - onJumpToDataSourcePage={onJumpToDataSourcePage} - />, - ) - - 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( - <AuthorizedInDataSourceNode - authorizationsNum={1} - onJumpToDataSourcePage={onJumpToDataSourcePage} - />, - ) - - 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( - <AuthorizedInDataSourceNode - authorizationsNum={1} - onJumpToDataSourcePage={vi.fn()} - />, - ) - - // 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( - <AuthorizedInDataSourceNode - authorizationsNum={0} - onJumpToDataSourcePage={vi.fn()} - />, - ) - - // 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( - <AuthorizedInNode - pluginPayload={pluginPayload} - onAuthorizationItemClick={vi.fn()} - />, - { 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( - <AuthorizedInNode - pluginPayload={pluginPayload} - onAuthorizationItemClick={vi.fn()} - credentialId="selected-id" - />, - { 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( - <AuthorizedInNode - pluginPayload={pluginPayload} - onAuthorizationItemClick={vi.fn()} - credentialId="non-existent" - />, - { 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( - <AuthorizedInNode - pluginPayload={pluginPayload} - onAuthorizationItemClick={vi.fn()} - credentialId="unavailable-id" - />, - { 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( - <AuthorizedInNode - pluginPayload={pluginPayload} - onAuthorizationItemClick={vi.fn()} - />, - { 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( - <AuthorizedInNode - pluginPayload={pluginPayload} - onAuthorizationItemClick={onAuthorizationItemClick} - />, - { 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( - <PluginAuth pluginPayload={pluginPayload} />, - { 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( - <PluginAuth pluginPayload={pluginPayload} />, - { 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( - <PluginAuth pluginPayload={pluginPayload} />, - { 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( - <PluginAuth pluginPayload={pluginPayload} />, - { 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-detail-panel/action-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/action-list.spec.tsx similarity index 88% rename from web/app/components/plugins/plugin-detail-panel/action-list.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/action-list.spec.tsx index 14ed18eb9a..a2ef24918d 100644 --- a/web/app/components/plugins/plugin-detail-panel/action-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/action-list.spec.tsx @@ -1,18 +1,7 @@ import type { PluginDetail } from '@/app/components/plugins/types' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import ActionList from './action-list' - -// Mock dependencies -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: Record<string, unknown>) => { - if (options?.num !== undefined) - return `${options.num} ${options.action || 'actions'}` - return key - }, - }), -})) +import ActionList from '../action-list' const mockToolData = [ { name: 'tool-1', label: { en_US: 'Tool 1' } }, @@ -82,7 +71,7 @@ describe('ActionList', () => { const detail = createPluginDetail() render(<ActionList detail={detail} />) - expect(screen.getByText('2 actions')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.actionNum:{"num":2,"action":"actions"}')).toBeInTheDocument() expect(screen.getAllByTestId('tool-item')).toHaveLength(2) }) @@ -124,7 +113,7 @@ describe('ActionList', () => { // The provider key is constructed from plugin_id and tool identity name // When they match the mock, it renders - expect(screen.getByText('2 actions')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.actionNum:{"num":2,"action":"actions"}')).toBeInTheDocument() }) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/agent-strategy-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/agent-strategy-list.spec.tsx similarity index 88% rename from web/app/components/plugins/plugin-detail-panel/agent-strategy-list.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/agent-strategy-list.spec.tsx index b9b737c51b..34015c0487 100644 --- a/web/app/components/plugins/plugin-detail-panel/agent-strategy-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/agent-strategy-list.spec.tsx @@ -1,17 +1,7 @@ import type { PluginDetail, StrategyDetail } from '@/app/components/plugins/types' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import AgentStrategyList from './agent-strategy-list' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: Record<string, unknown>) => { - if (options?.num !== undefined) - return `${options.num} ${options.strategy || 'strategies'}` - return key - }, - }), -})) +import AgentStrategyList from '../agent-strategy-list' const mockStrategies = [ { @@ -91,7 +81,7 @@ describe('AgentStrategyList', () => { it('should render strategy items when data is available', () => { render(<AgentStrategyList detail={createPluginDetail()} />) - expect(screen.getByText('1 strategy')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.strategyNum:{"num":1,"strategy":"strategy"}')).toBeInTheDocument() expect(screen.getByTestId('strategy-item')).toBeInTheDocument() }) @@ -114,7 +104,7 @@ describe('AgentStrategyList', () => { } render(<AgentStrategyList detail={createPluginDetail()} />) - expect(screen.getByText('2 strategies')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.strategyNum:{"num":2,"strategy":"strategies"}')).toBeInTheDocument() expect(screen.getAllByTestId('strategy-item')).toHaveLength(2) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/datasource-action-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/datasource-action-list.spec.tsx similarity index 85% rename from web/app/components/plugins/plugin-detail-panel/datasource-action-list.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/datasource-action-list.spec.tsx index e315bbf62b..d5a8b6f473 100644 --- a/web/app/components/plugins/plugin-detail-panel/datasource-action-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/datasource-action-list.spec.tsx @@ -1,17 +1,7 @@ import type { PluginDetail } from '@/app/components/plugins/types' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import DatasourceActionList from './datasource-action-list' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: Record<string, unknown>) => { - if (options?.num !== undefined) - return `${options.num} ${options.action || 'actions'}` - return key - }, - }), -})) +import DatasourceActionList from '../datasource-action-list' const mockDataSourceList = [ { plugin_id: 'test-plugin', name: 'Data Source 1' }, @@ -72,7 +62,7 @@ describe('DatasourceActionList', () => { render(<DatasourceActionList detail={createPluginDetail()} />) // The component always shows "0 action" because data is hardcoded as empty array - expect(screen.getByText('0 action')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.actionNum:{"num":0,"action":"action"}')).toBeInTheDocument() }) it('should return null when no provider found', () => { @@ -98,7 +88,7 @@ describe('DatasourceActionList', () => { render(<DatasourceActionList detail={detail} />) - expect(screen.getByText('0 action')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.actionNum:{"num":0,"action":"action"}')).toBeInTheDocument() }) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/detail-header.spec.tsx similarity index 94% rename from web/app/components/plugins/plugin-detail-panel/detail-header.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/detail-header.spec.tsx index cc0ac404b2..a35fcec8be 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/detail-header.spec.tsx @@ -1,10 +1,10 @@ -import type { PluginDetail } from '../types' +import type { PluginDetail } from '../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import * as amplitude from '@/app/components/base/amplitude' import Toast from '@/app/components/base/toast' -import { PluginSource } from '../types' -import DetailHeader from './detail-header' +import { PluginSource } from '../../types' +import DetailHeader from '../detail-header' const { mockSetShowUpdatePluginModal, @@ -24,12 +24,6 @@ const { } }) -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - vi.mock('ahooks', async () => { const React = await import('react') return { @@ -90,7 +84,7 @@ vi.mock('@/service/use-tools', () => ({ useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders, })) -vi.mock('../install-plugin/hooks', () => ({ +vi.mock('../../install-plugin/hooks', () => ({ useGitHubReleases: () => ({ checkForUpdates: mockCheckForUpdates, fetchReleases: mockFetchReleases, @@ -106,13 +100,13 @@ let mockAutoUpgradeInfo: { upgrade_time_of_day: number } | null = null -vi.mock('../plugin-page/use-reference-setting', () => ({ +vi.mock('../../plugin-page/use-reference-setting', () => ({ default: () => ({ referenceSetting: mockAutoUpgradeInfo ? { auto_upgrade: mockAutoUpgradeInfo } : null, }), })) -vi.mock('../reference-setting-modal/auto-update-setting/types', () => ({ +vi.mock('../../reference-setting-modal/auto-update-setting/types', () => ({ AUTO_UPDATE_MODE: { update_all: 'update_all', partial: 'partial', @@ -120,7 +114,7 @@ vi.mock('../reference-setting-modal/auto-update-setting/types', () => ({ }, })) -vi.mock('../reference-setting-modal/auto-update-setting/utils', () => ({ +vi.mock('../../reference-setting-modal/auto-update-setting/utils', () => ({ convertUTCDaySecondsToLocalSeconds: (seconds: number) => seconds, timeOfDayToDayjs: () => ({ format: () => '10:00 AM' }), })) @@ -137,32 +131,32 @@ vi.mock('@/utils/var', () => ({ getMarketplaceUrl: (path: string) => `https://marketplace.example.com${path}`, })) -vi.mock('../card/base/card-icon', () => ({ +vi.mock('../../card/base/card-icon', () => ({ default: ({ src }: { src: string }) => <div data-testid="card-icon" data-src={src} />, })) -vi.mock('../card/base/description', () => ({ +vi.mock('../../card/base/description', () => ({ default: ({ text }: { text: string }) => <div data-testid="description">{text}</div>, })) -vi.mock('../card/base/org-info', () => ({ +vi.mock('../../card/base/org-info', () => ({ default: ({ orgName }: { orgName: string }) => <div data-testid="org-info">{orgName}</div>, })) -vi.mock('../card/base/title', () => ({ +vi.mock('../../card/base/title', () => ({ default: ({ title }: { title: string }) => <div data-testid="title">{title}</div>, })) -vi.mock('../base/badges/verified', () => ({ +vi.mock('../../base/badges/verified', () => ({ default: () => <span data-testid="verified-badge" />, })) -vi.mock('../base/deprecation-notice', () => ({ +vi.mock('../../base/deprecation-notice', () => ({ default: () => <div data-testid="deprecation-notice" />, })) // Enhanced operation-dropdown mock -vi.mock('./operation-dropdown', () => ({ +vi.mock('../operation-dropdown', () => ({ default: ({ onInfo, onCheckVersion, onRemove }: { onInfo: () => void, onCheckVersion: () => void, onRemove: () => void }) => ( <div data-testid="operation-dropdown"> <button data-testid="info-btn" onClick={onInfo}>Info</button> @@ -173,7 +167,7 @@ vi.mock('./operation-dropdown', () => ({ })) // Enhanced update modal mock -vi.mock('../update-plugin/from-market-place', () => ({ +vi.mock('../../update-plugin/from-market-place', () => ({ default: ({ onSave, onCancel }: { onSave: () => void, onCancel: () => void }) => { return ( <div data-testid="update-modal"> @@ -185,7 +179,7 @@ vi.mock('../update-plugin/from-market-place', () => ({ })) // Enhanced version picker mock -vi.mock('../update-plugin/plugin-version-picker', () => ({ +vi.mock('../../update-plugin/plugin-version-picker', () => ({ default: ({ trigger, onSelect, onShowChange }: { trigger: React.ReactNode, onSelect: (state: { version: string, unique_identifier: string, isDowngrade?: boolean }) => void, onShowChange: (show: boolean) => void }) => ( <div data-testid="version-picker"> {trigger} @@ -211,7 +205,7 @@ vi.mock('../update-plugin/plugin-version-picker', () => ({ ), })) -vi.mock('../plugin-page/plugin-info', () => ({ +vi.mock('../../plugin-page/plugin-info', () => ({ default: ({ onHide }: { onHide: () => void }) => ( <div data-testid="plugin-info"> <button data-testid="plugin-info-close" onClick={onHide}>Close</button> @@ -219,7 +213,7 @@ vi.mock('../plugin-page/plugin-info', () => ({ ), })) -vi.mock('../plugin-auth', () => ({ +vi.mock('../../plugin-auth', () => ({ AuthCategory: { tool: 'tool' }, PluginAuth: () => <div data-testid="plugin-auth" />, })) @@ -369,7 +363,7 @@ describe('DetailHeader', () => { }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - expect(screen.getByText('detailPanel.operation.update')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.operation.update')).toBeInTheDocument() }) it('should show update button for GitHub source', () => { @@ -379,7 +373,7 @@ describe('DetailHeader', () => { }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - expect(screen.getByText('detailPanel.operation.update')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.operation.update')).toBeInTheDocument() }) }) @@ -556,7 +550,7 @@ describe('DetailHeader', () => { }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - const updateBtn = screen.getByText('detailPanel.operation.update') + const updateBtn = screen.getByText('plugin.detailPanel.operation.update') fireEvent.click(updateBtn) expect(updateBtn).toBeInTheDocument() @@ -589,7 +583,7 @@ describe('DetailHeader', () => { }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - fireEvent.click(screen.getByText('detailPanel.operation.update')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.update')) await waitFor(() => { expect(mockFetchReleases).toHaveBeenCalledWith('owner', 'repo') @@ -605,7 +599,7 @@ describe('DetailHeader', () => { }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - fireEvent.click(screen.getByText('detailPanel.operation.update')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.update')) await waitFor(() => { expect(mockFetchReleases).toHaveBeenCalled() @@ -619,7 +613,7 @@ describe('DetailHeader', () => { }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - fireEvent.click(screen.getByText('detailPanel.operation.update')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.update')) await waitFor(() => { expect(mockSetShowUpdatePluginModal).toHaveBeenCalled() @@ -638,7 +632,7 @@ describe('DetailHeader', () => { }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - fireEvent.click(screen.getByText('detailPanel.operation.update')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.update')) await waitFor(() => { expect(mockOnUpdate).toHaveBeenCalled() @@ -916,7 +910,7 @@ describe('DetailHeader', () => { }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - fireEvent.click(screen.getByText('detailPanel.operation.update')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.update')) await waitFor(() => { expect(screen.getByTestId('update-modal')).toBeInTheDocument() @@ -930,7 +924,7 @@ describe('DetailHeader', () => { }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - fireEvent.click(screen.getByText('detailPanel.operation.update')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.update')) await waitFor(() => { expect(screen.getByTestId('update-modal')).toBeInTheDocument() }) @@ -949,7 +943,7 @@ describe('DetailHeader', () => { }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - fireEvent.click(screen.getByText('detailPanel.operation.update')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.update')) await waitFor(() => { expect(screen.getByTestId('update-modal')).toBeInTheDocument() }) diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-card.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-card.spec.tsx similarity index 89% rename from web/app/components/plugins/plugin-detail-panel/endpoint-card.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-card.spec.tsx index 203bd6a02a..b6710887a5 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-card.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-card.spec.tsx @@ -1,14 +1,8 @@ -import type { EndpointListItem, PluginDetail } from '../types' +import type { EndpointListItem, PluginDetail } from '../../types' import { act, fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import Toast from '@/app/components/base/toast' -import EndpointCard from './endpoint-card' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import EndpointCard from '../endpoint-card' vi.mock('copy-to-clipboard', () => ({ default: vi.fn(), @@ -76,7 +70,7 @@ vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ addDefaultValue: (value: unknown) => value, })) -vi.mock('./endpoint-modal', () => ({ +vi.mock('../endpoint-modal', () => ({ default: ({ onCancel, onSaved }: { onCancel: () => void, onSaved: (state: unknown) => void }) => ( <div data-testid="endpoint-modal"> <button data-testid="modal-cancel" onClick={onCancel}>Cancel</button> @@ -163,7 +157,7 @@ describe('EndpointCard', () => { it('should show active status when enabled', () => { render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) - expect(screen.getByText('detailPanel.serviceOk')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.serviceOk')).toBeInTheDocument() expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green') }) @@ -171,7 +165,7 @@ describe('EndpointCard', () => { const disabledData = { ...mockEndpointData, enabled: false } render(<EndpointCard pluginDetail={mockPluginDetail} data={disabledData} handleChange={mockHandleChange} />) - expect(screen.getByText('detailPanel.disabled')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.disabled')).toBeInTheDocument() expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'gray') }) }) @@ -182,7 +176,7 @@ describe('EndpointCard', () => { fireEvent.click(screen.getByRole('switch')) - expect(screen.getByText('detailPanel.endpointDisableTip')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.endpointDisableTip')).toBeInTheDocument() }) it('should call disableEndpoint when confirm disable', () => { @@ -190,7 +184,7 @@ describe('EndpointCard', () => { fireEvent.click(screen.getByRole('switch')) // Click confirm button in the Confirm dialog - fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) expect(mockDisableEndpoint).toHaveBeenCalledWith('ep-1') }) @@ -205,7 +199,7 @@ describe('EndpointCard', () => { if (deleteButton) fireEvent.click(deleteButton) - expect(screen.getByText('detailPanel.endpointDeleteTip')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.endpointDeleteTip')).toBeInTheDocument() }) it('should call deleteEndpoint when confirm delete', () => { @@ -216,7 +210,7 @@ describe('EndpointCard', () => { expect(deleteButton).toBeDefined() if (deleteButton) fireEvent.click(deleteButton) - fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) expect(mockDeleteEndpoint).toHaveBeenCalledWith('ep-1') }) @@ -290,12 +284,12 @@ describe('EndpointCard', () => { render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) fireEvent.click(screen.getByRole('switch')) - expect(screen.getByText('detailPanel.endpointDisableTip')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.endpointDisableTip')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'operation.cancel' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) // Confirm should be hidden - expect(screen.queryByText('detailPanel.endpointDisableTip')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.detailPanel.endpointDisableTip')).not.toBeInTheDocument() }) it('should hide delete confirm when cancel clicked', () => { @@ -306,11 +300,11 @@ describe('EndpointCard', () => { expect(deleteButton).toBeDefined() if (deleteButton) fireEvent.click(deleteButton) - expect(screen.getByText('detailPanel.endpointDeleteTip')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.endpointDeleteTip')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'operation.cancel' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) - expect(screen.queryByText('detailPanel.endpointDeleteTip')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.detailPanel.endpointDeleteTip')).not.toBeInTheDocument() }) it('should hide edit modal when cancel clicked', () => { @@ -344,7 +338,7 @@ describe('EndpointCard', () => { render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) fireEvent.click(screen.getByRole('switch')) - fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) expect(mockDisableEndpoint).toHaveBeenCalled() }) @@ -357,7 +351,7 @@ describe('EndpointCard', () => { const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary')) if (deleteButton) fireEvent.click(deleteButton) - fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) expect(mockDeleteEndpoint).toHaveBeenCalled() }) diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx similarity index 94% rename from web/app/components/plugins/plugin-detail-panel/endpoint-list.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx index 0c9865153a..bc25cd816f 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx @@ -1,13 +1,7 @@ import type { PluginDetail } from '@/app/components/plugins/types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import EndpointList from './endpoint-list' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import EndpointList from '../endpoint-list' vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => `https://docs.example.com${path}`, @@ -41,13 +35,13 @@ vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ toolCredentialToFormSchemas: (schemas: unknown[]) => schemas, })) -vi.mock('./endpoint-card', () => ({ +vi.mock('../endpoint-card', () => ({ default: ({ data }: { data: { name: string } }) => ( <div data-testid="endpoint-card">{data.name}</div> ), })) -vi.mock('./endpoint-modal', () => ({ +vi.mock('../endpoint-modal', () => ({ default: ({ onCancel, onSaved }: { onCancel: () => void, onSaved: (state: unknown) => void }) => ( <div data-testid="endpoint-modal"> <button data-testid="modal-cancel" onClick={onCancel}>Cancel</button> @@ -91,7 +85,7 @@ describe('EndpointList', () => { it('should render endpoint list', () => { render(<EndpointList detail={createPluginDetail()} />) - expect(screen.getByText('detailPanel.endpoints')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.endpoints')).toBeInTheDocument() }) it('should render endpoint cards', () => { @@ -112,7 +106,7 @@ describe('EndpointList', () => { mockEndpointListData = { endpoints: [] } render(<EndpointList detail={createPluginDetail()} />) - expect(screen.getByText('detailPanel.endpointsEmpty')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.endpointsEmpty')).toBeInTheDocument() }) it('should render add button', () => { @@ -165,7 +159,7 @@ describe('EndpointList', () => { render(<EndpointList detail={detail} />) // Verify the component renders correctly - expect(screen.getByText('detailPanel.endpoints')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.endpoints')).toBeInTheDocument() }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx similarity index 88% rename from web/app/components/plugins/plugin-detail-panel/endpoint-modal.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx index 96fa647e91..4ed7ec48a5 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx @@ -1,19 +1,9 @@ -import type { FormSchema } from '../../base/form/types' -import type { PluginDetail } from '../types' +import type { FormSchema } from '../../../base/form/types' +import type { PluginDetail } from '../../types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import Toast from '@/app/components/base/toast' -import EndpointModal from './endpoint-modal' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, opts?: Record<string, unknown>) => { - if (opts?.field) - return `${key}: ${opts.field}` - return key - }, - }), -})) +import EndpointModal from '../endpoint-modal' vi.mock('@/hooks/use-i18n', () => ({ useRenderI18nObject: () => (obj: Record<string, string> | string) => @@ -45,7 +35,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal }, })) -vi.mock('../readme-panel/entrance', () => ({ +vi.mock('../../readme-panel/entrance', () => ({ ReadmeEntrance: () => <div data-testid="readme-entrance" />, })) @@ -110,8 +100,8 @@ describe('EndpointModal', () => { />, ) - expect(screen.getByText('detailPanel.endpointModalTitle')).toBeInTheDocument() - expect(screen.getByText('detailPanel.endpointModalDesc')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.endpointModalTitle')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.endpointModalDesc')).toBeInTheDocument() }) it('should render form with fieldMoreInfo url link', () => { @@ -125,8 +115,7 @@ describe('EndpointModal', () => { ) expect(screen.getByTestId('field-more-info')).toBeInTheDocument() - // Should render the "howToGet" link when url exists - expect(screen.getByText('howToGet')).toBeInTheDocument() + expect(screen.getByText('tools.howToGet')).toBeInTheDocument() }) it('should render readme entrance', () => { @@ -154,7 +143,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.cancel' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) expect(mockOnCancel).toHaveBeenCalledTimes(1) }) @@ -260,7 +249,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', @@ -283,7 +272,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', @@ -302,7 +291,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockOnSaved).toHaveBeenCalledWith({ name: 'Valid Name' }) }) @@ -321,7 +310,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockToastNotify).not.toHaveBeenCalled() expect(mockOnSaved).toHaveBeenCalled() @@ -344,7 +333,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true }) }) @@ -364,7 +353,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true }) }) @@ -384,7 +373,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true }) }) @@ -404,7 +393,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false }) }) @@ -424,7 +413,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true }) }) @@ -444,7 +433,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false }) }) @@ -464,7 +453,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true }) }) @@ -484,7 +473,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false }) }) @@ -504,7 +493,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockOnSaved).toHaveBeenCalledWith({ text: 'hello' }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/plugins/plugin-detail-panel/index.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/index.spec.tsx index 0cc9671e1b..c3989ab71f 100644 --- a/web/app/components/plugins/plugin-detail-panel/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/index.spec.tsx @@ -2,11 +2,11 @@ import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/t import { act, fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types' -import PluginDetailPanel from './index' +import PluginDetailPanel from '../index' // Mock store const mockSetDetail = vi.fn() -vi.mock('./store', () => ({ +vi.mock('../store', () => ({ usePluginStore: () => ({ setDetail: mockSetDetail, }), @@ -14,7 +14,7 @@ vi.mock('./store', () => ({ // Mock DetailHeader const mockDetailHeaderOnUpdate = vi.fn() -vi.mock('./detail-header', () => ({ +vi.mock('../detail-header', () => ({ default: ({ detail, onUpdate, onHide }: { detail: PluginDetail onUpdate: (isDelete?: boolean) => void @@ -49,7 +49,7 @@ vi.mock('./detail-header', () => ({ })) // Mock ActionList -vi.mock('./action-list', () => ({ +vi.mock('../action-list', () => ({ default: ({ detail }: { detail: PluginDetail }) => ( <div data-testid="action-list"> <span data-testid="action-list-plugin-id">{detail.plugin_id}</span> @@ -58,7 +58,7 @@ vi.mock('./action-list', () => ({ })) // Mock AgentStrategyList -vi.mock('./agent-strategy-list', () => ({ +vi.mock('../agent-strategy-list', () => ({ default: ({ detail }: { detail: PluginDetail }) => ( <div data-testid="agent-strategy-list"> <span data-testid="strategy-list-plugin-id">{detail.plugin_id}</span> @@ -67,7 +67,7 @@ vi.mock('./agent-strategy-list', () => ({ })) // Mock EndpointList -vi.mock('./endpoint-list', () => ({ +vi.mock('../endpoint-list', () => ({ default: ({ detail }: { detail: PluginDetail }) => ( <div data-testid="endpoint-list"> <span data-testid="endpoint-list-plugin-id">{detail.plugin_id}</span> @@ -76,7 +76,7 @@ vi.mock('./endpoint-list', () => ({ })) // Mock ModelList -vi.mock('./model-list', () => ({ +vi.mock('../model-list', () => ({ default: ({ detail }: { detail: PluginDetail }) => ( <div data-testid="model-list"> <span data-testid="model-list-plugin-id">{detail.plugin_id}</span> @@ -85,7 +85,7 @@ vi.mock('./model-list', () => ({ })) // Mock DatasourceActionList -vi.mock('./datasource-action-list', () => ({ +vi.mock('../datasource-action-list', () => ({ default: ({ detail }: { detail: PluginDetail }) => ( <div data-testid="datasource-action-list"> <span data-testid="datasource-list-plugin-id">{detail.plugin_id}</span> @@ -94,7 +94,7 @@ vi.mock('./datasource-action-list', () => ({ })) // Mock SubscriptionList -vi.mock('./subscription-list', () => ({ +vi.mock('../subscription-list', () => ({ SubscriptionList: ({ pluginDetail }: { pluginDetail: PluginDetail }) => ( <div data-testid="subscription-list"> <span data-testid="subscription-list-plugin-id">{pluginDetail.plugin_id}</span> @@ -103,14 +103,14 @@ vi.mock('./subscription-list', () => ({ })) // Mock TriggerEventsList -vi.mock('./trigger/event-list', () => ({ +vi.mock('../trigger/event-list', () => ({ TriggerEventsList: () => ( <div data-testid="trigger-events-list">Events List</div> ), })) // Mock ReadmeEntrance -vi.mock('../readme-panel/entrance', () => ({ +vi.mock('../../readme-panel/entrance', () => ({ ReadmeEntrance: ({ pluginDetail, className }: { pluginDetail: PluginDetail, className?: string }) => ( <div data-testid="readme-entrance" className={className}> <span data-testid="readme-plugin-id">{pluginDetail.plugin_id}</span> diff --git a/web/app/components/plugins/plugin-detail-panel/model-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/model-list.spec.tsx similarity index 87% rename from web/app/components/plugins/plugin-detail-panel/model-list.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/model-list.spec.tsx index 2283ad0c43..a01c238ced 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/model-list.spec.tsx @@ -1,17 +1,7 @@ import type { PluginDetail } from '@/app/components/plugins/types' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import ModelList from './model-list' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: Record<string, unknown>) => { - if (options?.num !== undefined) - return `${options.num} models` - return key - }, - }), -})) +import ModelList from '../model-list' const mockModels = [ { model: 'gpt-4', provider: 'openai' }, @@ -72,7 +62,7 @@ describe('ModelList', () => { it('should render model list when data is available', () => { render(<ModelList detail={createPluginDetail()} />) - expect(screen.getByText('2 models')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.modelNum:{"num":2}')).toBeInTheDocument() }) it('should render model icons and names', () => { @@ -96,7 +86,7 @@ describe('ModelList', () => { mockModelListResponse = { data: [] } render(<ModelList detail={createPluginDetail()} />) - expect(screen.getByText('0 models')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.modelNum:{"num":0}')).toBeInTheDocument() expect(screen.queryByTestId('model-icon')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/operation-dropdown.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/operation-dropdown.spec.tsx similarity index 81% rename from web/app/components/plugins/plugin-detail-panel/operation-dropdown.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/operation-dropdown.spec.tsx index 5501526b12..7379927ffd 100644 --- a/web/app/components/plugins/plugin-detail-panel/operation-dropdown.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/operation-dropdown.spec.tsx @@ -1,14 +1,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginSource } from '../types' -import OperationDropdown from './operation-dropdown' - -// Mock dependencies -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import { PluginSource } from '../../types' +import OperationDropdown from '../operation-dropdown' vi.mock('@/context/global-public-context', () => ({ useGlobalPublicStore: <T,>(selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => T): T => @@ -72,55 +65,55 @@ describe('OperationDropdown', () => { it('should render info option for github source', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.github} />) - expect(screen.getByText('detailPanel.operation.info')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.operation.info')).toBeInTheDocument() }) it('should render check update option for github source', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.github} />) - expect(screen.getByText('detailPanel.operation.checkUpdate')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.operation.checkUpdate')).toBeInTheDocument() }) it('should render view detail option for github source with marketplace enabled', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.github} />) - expect(screen.getByText('detailPanel.operation.viewDetail')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.operation.viewDetail')).toBeInTheDocument() }) it('should render view detail option for marketplace source', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.marketplace} />) - expect(screen.getByText('detailPanel.operation.viewDetail')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.operation.viewDetail')).toBeInTheDocument() }) it('should always render remove option', () => { render(<OperationDropdown {...defaultProps} />) - expect(screen.getByText('detailPanel.operation.remove')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.operation.remove')).toBeInTheDocument() }) it('should not render info option for marketplace source', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.marketplace} />) - expect(screen.queryByText('detailPanel.operation.info')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.detailPanel.operation.info')).not.toBeInTheDocument() }) it('should not render check update option for marketplace source', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.marketplace} />) - expect(screen.queryByText('detailPanel.operation.checkUpdate')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.detailPanel.operation.checkUpdate')).not.toBeInTheDocument() }) it('should not render view detail for local source', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.local} />) - expect(screen.queryByText('detailPanel.operation.viewDetail')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.detailPanel.operation.viewDetail')).not.toBeInTheDocument() }) it('should not render view detail for debugging source', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.debugging} />) - expect(screen.queryByText('detailPanel.operation.viewDetail')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.detailPanel.operation.viewDetail')).not.toBeInTheDocument() }) }) @@ -138,7 +131,7 @@ describe('OperationDropdown', () => { it('should call onInfo when info option is clicked', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.github} />) - fireEvent.click(screen.getByText('detailPanel.operation.info')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.info')) expect(mockOnInfo).toHaveBeenCalledTimes(1) }) @@ -146,7 +139,7 @@ describe('OperationDropdown', () => { it('should call onCheckVersion when check update option is clicked', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.github} />) - fireEvent.click(screen.getByText('detailPanel.operation.checkUpdate')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.checkUpdate')) expect(mockOnCheckVersion).toHaveBeenCalledTimes(1) }) @@ -154,7 +147,7 @@ describe('OperationDropdown', () => { it('should call onRemove when remove option is clicked', () => { render(<OperationDropdown {...defaultProps} />) - fireEvent.click(screen.getByText('detailPanel.operation.remove')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.remove')) expect(mockOnRemove).toHaveBeenCalledTimes(1) }) @@ -162,7 +155,7 @@ describe('OperationDropdown', () => { it('should have correct href for view detail link', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.github} />) - const link = screen.getByText('detailPanel.operation.viewDetail').closest('a') + const link = screen.getByText('plugin.detailPanel.operation.viewDetail').closest('a') expect(link).toHaveAttribute('href', 'https://github.com/test/repo') expect(link).toHaveAttribute('target', '_blank') }) @@ -182,7 +175,7 @@ describe('OperationDropdown', () => { <OperationDropdown {...defaultProps} source={source} />, ) expect(screen.getByTestId('portal-elem')).toBeInTheDocument() - expect(screen.getByText('detailPanel.operation.remove')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.operation.remove')).toBeInTheDocument() unmount() }) }) @@ -197,7 +190,7 @@ describe('OperationDropdown', () => { const { unmount } = render( <OperationDropdown {...defaultProps} detailUrl={url} source={PluginSource.github} />, ) - const link = screen.getByText('detailPanel.operation.viewDetail').closest('a') + const link = screen.getByText('plugin.detailPanel.operation.viewDetail').closest('a') expect(link).toHaveAttribute('href', url) unmount() }) diff --git a/web/app/components/plugins/plugin-detail-panel/store.spec.ts b/web/app/components/plugins/plugin-detail-panel/__tests__/store.spec.ts similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/store.spec.ts rename to web/app/components/plugins/plugin-detail-panel/__tests__/store.spec.ts index 4116bb9790..3afcc95288 100644 --- a/web/app/components/plugins/plugin-detail-panel/store.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/store.spec.ts @@ -1,7 +1,7 @@ -import type { SimpleDetail } from './store' +import type { SimpleDetail } from '../store' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it } from 'vitest' -import { usePluginStore } from './store' +import { usePluginStore } from '../store' // Factory function to create mock SimpleDetail const createSimpleDetail = (overrides: Partial<SimpleDetail> = {}): SimpleDetail => ({ diff --git a/web/app/components/plugins/plugin-detail-panel/strategy-detail.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-detail.spec.tsx similarity index 93% rename from web/app/components/plugins/plugin-detail-panel/strategy-detail.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/strategy-detail.spec.tsx index 32ae6ff735..6203545943 100644 --- a/web/app/components/plugins/plugin-detail-panel/strategy-detail.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-detail.spec.tsx @@ -1,13 +1,7 @@ import type { StrategyDetail as StrategyDetailType } from '@/app/components/plugins/types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import StrategyDetail from './strategy-detail' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import StrategyDetail from '../strategy-detail' vi.mock('@/hooks/use-i18n', () => ({ useRenderI18nObject: () => (obj: Record<string, string>) => obj?.en_US || '', @@ -93,7 +87,7 @@ describe('StrategyDetail', () => { it('should render parameters section', () => { render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />) - expect(screen.getByText('setBuiltInTools.parameters')).toBeInTheDocument() + expect(screen.getByText('tools.setBuiltInTools.parameters')).toBeInTheDocument() expect(screen.getByText('Parameter 1')).toBeInTheDocument() }) @@ -141,7 +135,7 @@ describe('StrategyDetail', () => { } render(<StrategyDetail provider={mockProvider} detail={detailWithNumber} onHide={mockOnHide} />) - expect(screen.getByText('setBuiltInTools.number')).toBeInTheDocument() + expect(screen.getByText('tools.setBuiltInTools.number')).toBeInTheDocument() }) it('should display correct type for checkbox', () => { @@ -161,7 +155,7 @@ describe('StrategyDetail', () => { } render(<StrategyDetail provider={mockProvider} detail={detailWithFile} onHide={mockOnHide} />) - expect(screen.getByText('setBuiltInTools.file')).toBeInTheDocument() + expect(screen.getByText('tools.setBuiltInTools.file')).toBeInTheDocument() }) it('should display correct type for array[tools]', () => { @@ -190,7 +184,7 @@ describe('StrategyDetail', () => { const detailEmpty = { ...mockDetail, parameters: [] } render(<StrategyDetail provider={mockProvider} detail={detailEmpty} onHide={mockOnHide} />) - expect(screen.getByText('setBuiltInTools.parameters')).toBeInTheDocument() + expect(screen.getByText('tools.setBuiltInTools.parameters')).toBeInTheDocument() }) it('should handle no output schema', () => { diff --git a/web/app/components/plugins/plugin-detail-panel/strategy-item.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-item.spec.tsx similarity index 97% rename from web/app/components/plugins/plugin-detail-panel/strategy-item.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/strategy-item.spec.tsx index fde2f82965..31afeaf9f1 100644 --- a/web/app/components/plugins/plugin-detail-panel/strategy-item.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-item.spec.tsx @@ -1,7 +1,7 @@ import type { StrategyDetail } from '@/app/components/plugins/types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import StrategyItem from './strategy-item' +import StrategyItem from '../strategy-item' vi.mock('@/hooks/use-i18n', () => ({ useRenderI18nObject: () => (obj: Record<string, string>) => obj?.en_US || '', @@ -11,7 +11,7 @@ vi.mock('@/utils/classnames', () => ({ cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '), })) -vi.mock('./strategy-detail', () => ({ +vi.mock('../strategy-detail', () => ({ default: ({ onHide }: { onHide: () => void }) => ( <div data-testid="strategy-detail-panel"> <button data-testid="hide-btn" onClick={onHide}>Hide</button> diff --git a/web/app/components/plugins/plugin-detail-panel/utils.spec.ts b/web/app/components/plugins/plugin-detail-panel/__tests__/utils.spec.ts similarity index 98% rename from web/app/components/plugins/plugin-detail-panel/utils.spec.ts rename to web/app/components/plugins/plugin-detail-panel/__tests__/utils.spec.ts index 6c911d5ebd..602badc9c5 100644 --- a/web/app/components/plugins/plugin-detail-panel/utils.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/utils.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { NAME_FIELD } from './utils' +import { NAME_FIELD } from '../utils' describe('utils', () => { describe('NAME_FIELD', () => { diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-trigger.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-trigger.spec.tsx new file mode 100644 index 0000000000..d52a62a2ee --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-trigger.spec.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/app/components/base/app-icon', () => ({ + default: ({ size }: { size: string }) => <div data-testid="app-icon" data-size={size} />, +})) + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), +})) + +describe('AppTrigger', () => { + let AppTrigger: (typeof import('../app-trigger'))['default'] + + beforeEach(async () => { + vi.clearAllMocks() + const mod = await import('../app-trigger') + AppTrigger = mod.default + }) + + it('should render placeholder when no app is selected', () => { + render(<AppTrigger open={false} />) + + expect(screen.queryByTestId('app-icon')).not.toBeInTheDocument() + }) + + it('should render app details when appDetail is provided', () => { + const appDetail = { + name: 'My App', + icon_type: 'emoji', + icon: '🤖', + icon_background: '#fff', + } + render(<AppTrigger open={false} appDetail={appDetail as never} />) + + expect(screen.getByTestId('app-icon')).toBeInTheDocument() + expect(screen.getByText('My App')).toBeInTheDocument() + }) + + it('should render when open', () => { + const { container } = render(<AppTrigger open={true} />) + + expect(container.firstChild).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/app-selector/index.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx index fd66e7c45e..5497786794 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx @@ -6,12 +6,12 @@ import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { InputVarType } from '@/app/components/workflow/types' import { AppModeEnum } from '@/types/app' -import AppInputsForm from './app-inputs-form' -import AppInputsPanel from './app-inputs-panel' -import AppPicker from './app-picker' -import AppTrigger from './app-trigger' +import AppInputsForm from '../app-inputs-form' +import AppInputsPanel from '../app-inputs-panel' +import AppPicker from '../app-picker' +import AppTrigger from '../app-trigger' -import AppSelector from './index' +import AppSelector from '../index' // ==================== Mock Setup ==================== diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.spec.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/header-modals.spec.tsx similarity index 98% rename from web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/header-modals.spec.tsx index 4011ee13f5..843800c190 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/header-modals.spec.tsx @@ -1,15 +1,9 @@ -import type { PluginDetail } from '../../../types' -import type { ModalStates, VersionTarget } from '../hooks' +import type { PluginDetail } from '../../../../types' +import type { ModalStates, VersionTarget } from '../../hooks' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginSource } from '../../../types' -import HeaderModals from './header-modals' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import { PluginSource } from '../../../../types' +import HeaderModals from '../header-modals' vi.mock('@/context/i18n', () => ({ useGetLanguage: () => 'en_US', @@ -270,7 +264,7 @@ describe('HeaderModals', () => { />, ) - expect(screen.getByTestId('delete-title')).toHaveTextContent('action.delete') + expect(screen.getByTestId('delete-title')).toHaveTextContent('plugin.action.delete') }) it('should call hideDeleteConfirm when cancel is clicked', () => { diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.spec.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/plugin-source-badge.spec.tsx similarity index 89% rename from web/app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/plugin-source-badge.spec.tsx index e2fa1f6140..4d60433efb 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/plugin-source-badge.spec.tsx @@ -1,13 +1,7 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginSource } from '../../../types' -import PluginSourceBadge from './plugin-source-badge' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import { PluginSource } from '../../../../types' +import PluginSourceBadge from '../plugin-source-badge' vi.mock('@/app/components/base/tooltip', () => ({ default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => ( @@ -28,7 +22,7 @@ describe('PluginSourceBadge', () => { const tooltip = screen.getByTestId('tooltip') expect(tooltip).toBeInTheDocument() - expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.marketplace') + expect(tooltip).toHaveAttribute('data-content', 'plugin.detailPanel.categoryTip.marketplace') }) it('should render github source badge', () => { @@ -36,7 +30,7 @@ describe('PluginSourceBadge', () => { const tooltip = screen.getByTestId('tooltip') expect(tooltip).toBeInTheDocument() - expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.github') + expect(tooltip).toHaveAttribute('data-content', 'plugin.detailPanel.categoryTip.github') }) it('should render local source badge', () => { @@ -44,7 +38,7 @@ describe('PluginSourceBadge', () => { const tooltip = screen.getByTestId('tooltip') expect(tooltip).toBeInTheDocument() - expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.local') + expect(tooltip).toHaveAttribute('data-content', 'plugin.detailPanel.categoryTip.local') }) it('should render debugging source badge', () => { @@ -52,7 +46,7 @@ describe('PluginSourceBadge', () => { const tooltip = screen.getByTestId('tooltip') expect(tooltip).toBeInTheDocument() - expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.debugging') + expect(tooltip).toHaveAttribute('data-content', 'plugin.detailPanel.categoryTip.debugging') }) }) @@ -94,7 +88,7 @@ describe('PluginSourceBadge', () => { expect(screen.getByTestId('tooltip')).toHaveAttribute( 'data-content', - 'detailPanel.categoryTip.marketplace', + 'plugin.detailPanel.categoryTip.marketplace', ) }) @@ -103,7 +97,7 @@ describe('PluginSourceBadge', () => { expect(screen.getByTestId('tooltip')).toHaveAttribute( 'data-content', - 'detailPanel.categoryTip.github', + 'plugin.detailPanel.categoryTip.github', ) }) @@ -112,7 +106,7 @@ describe('PluginSourceBadge', () => { expect(screen.getByTestId('tooltip')).toHaveAttribute( 'data-content', - 'detailPanel.categoryTip.local', + 'plugin.detailPanel.categoryTip.local', ) }) @@ -121,7 +115,7 @@ describe('PluginSourceBadge', () => { expect(screen.getByTestId('tooltip')).toHaveAttribute( 'data-content', - 'detailPanel.categoryTip.debugging', + 'plugin.detailPanel.categoryTip.debugging', ) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.spec.ts b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-detail-header-state.spec.ts similarity index 97% rename from web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.spec.ts rename to web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-detail-header-state.spec.ts index 2e14fed60a..044d03ca61 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-detail-header-state.spec.ts @@ -1,8 +1,8 @@ -import type { PluginDetail } from '../../../types' +import type { PluginDetail } from '../../../../types' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginSource } from '../../../types' -import { useDetailHeaderState } from './use-detail-header-state' +import { PluginSource } from '../../../../types' +import { useDetailHeaderState } from '../use-detail-header-state' let mockEnableMarketplace = true vi.mock('@/context/global-public-context', () => ({ @@ -18,13 +18,13 @@ let mockAutoUpgradeInfo: { upgrade_time_of_day: number } | null = null -vi.mock('../../../plugin-page/use-reference-setting', () => ({ +vi.mock('../../../../plugin-page/use-reference-setting', () => ({ default: () => ({ referenceSetting: mockAutoUpgradeInfo ? { auto_upgrade: mockAutoUpgradeInfo } : null, }), })) -vi.mock('../../../reference-setting-modal/auto-update-setting/types', () => ({ +vi.mock('../../../../reference-setting-modal/auto-update-setting/types', () => ({ AUTO_UPDATE_MODE: { update_all: 'update_all', partial: 'partial', diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.spec.ts b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-plugin-operations.spec.ts similarity index 98% rename from web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.spec.ts rename to web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-plugin-operations.spec.ts index 683c4080ea..15397ab6fc 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-plugin-operations.spec.ts @@ -1,11 +1,11 @@ -import type { PluginDetail } from '../../../types' -import type { ModalStates, VersionTarget } from './use-detail-header-state' +import type { PluginDetail } from '../../../../types' +import type { ModalStates, VersionTarget } from '../use-detail-header-state' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import * as amplitude from '@/app/components/base/amplitude' import Toast from '@/app/components/base/toast' -import { PluginSource } from '../../../types' -import { usePluginOperations } from './use-plugin-operations' +import { PluginSource } from '../../../../types' +import { usePluginOperations } from '../use-plugin-operations' type VersionPickerMock = { setTargetVersion: (version: VersionTarget) => void @@ -50,7 +50,7 @@ vi.mock('@/service/use-tools', () => ({ useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders, })) -vi.mock('../../../install-plugin/hooks', () => ({ +vi.mock('../../../../install-plugin/hooks', () => ({ useGitHubReleases: () => ({ checkForUpdates: mockCheckForUpdates, fetchReleases: mockFetchReleases, diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/model-selector/index.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx index 91c978ad7d..e5750d007b 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import Toast from '@/app/components/base/toast' import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import ModelParameterModal from './index' +import ModelParameterModal from '../index' // ==================== Mock Setup ==================== @@ -159,7 +159,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-param ), })) -vi.mock('./llm-params-panel', () => ({ +vi.mock('../llm-params-panel', () => ({ default: ({ provider, modelId, onCompletionParamsChange, isAdvancedMode }: { provider: string modelId: string @@ -179,7 +179,7 @@ vi.mock('./llm-params-panel', () => ({ ), })) -vi.mock('./tts-params-panel', () => ({ +vi.mock('../tts-params-panel', () => ({ default: ({ language, voice, onChange }: { currentModel?: ModelItem language?: string diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/llm-params-panel.spec.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/llm-params-panel.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/model-selector/llm-params-panel.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/llm-params-panel.spec.tsx index 27505146b0..17fad8d7a7 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/llm-params-panel.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/llm-params-panel.spec.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' // Import component after mocks -import LLMParamsPanel from './llm-params-panel' +import LLMParamsPanel from '../llm-params-panel' // ==================== Mock Setup ==================== // All vi.mock() calls are hoisted, so inline all mock data diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.spec.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx index 304bd563f7..a5633b30d1 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' // Import component after mocks -import TTSParamsPanel from './tts-params-panel' +import TTSParamsPanel from '../tts-params-panel' // ==================== Mock Setup ==================== // All vi.mock() calls are hoisted, so inline all mock data diff --git a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx index 288289b64d..c5defa3ab0 100644 --- a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx @@ -8,7 +8,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' // ==================== Imports (after mocks) ==================== import { MCPToolAvailabilityProvider } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability' -import MultipleToolSelector from './index' +import MultipleToolSelector from '../index' // ==================== Mock Setup ==================== @@ -30,9 +30,9 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector', () => ({ onSelectMultiple, onDelete, controlledState, - onControlledStateChange, + onControlledStateChange: _onControlledStateChange, panelShowState, - onPanelShowStateChange, + onPanelShowStateChange: _onPanelShowStateChange, isEdit, supportEnableSwitch, }: { @@ -150,15 +150,15 @@ const createMCPTool = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvi author: 'test-author', type: 'mcp', icon: 'test-icon.png', - label: { en_US: 'MCP Provider' } as any, - description: { en_US: 'MCP Provider description' } as any, + label: { en_US: 'MCP Provider' } as unknown as ToolWithProvider['label'], + description: { en_US: 'MCP Provider description' } as unknown as ToolWithProvider['description'], is_team_authorization: true, allow_delete: false, labels: [], tools: [{ name: 'mcp-tool-1', - label: { en_US: 'MCP Tool 1' } as any, - description: { en_US: 'MCP Tool 1 description' } as any, + label: { en_US: 'MCP Tool 1' } as unknown as ToolWithProvider['label'], + description: { en_US: 'MCP Tool 1 description' } as unknown as ToolWithProvider['description'], parameters: [], output_schema: {}, }], @@ -641,7 +641,7 @@ describe('MultipleToolSelector', () => { it('should handle undefined value', () => { // Arrange & Act - value defaults to [] in component - renderComponent({ value: undefined as any }) + renderComponent({ value: undefined as unknown as ToolValue[] }) // Assert expect(screen.getByText('plugin.detailPanel.toolSelector.empty')).toBeInTheDocument() diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx similarity index 96% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx index d9e1bf9cc3..2f5dfe4256 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx @@ -1,12 +1,12 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { DeleteConfirm } from './delete-confirm' +import { DeleteConfirm } from '../delete-confirm' const mockRefetch = vi.fn() const mockDelete = vi.fn() const mockToast = vi.fn() -vi.mock('./use-subscription-list', () => ({ +vi.mock('../use-subscription-list', () => ({ useSubscriptionList: () => ({ refetch: mockRefetch }), })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/index.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx index 5c71977bc7..837a679b4b 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx @@ -3,8 +3,8 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-select import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { SubscriptionList } from './index' -import { SubscriptionListMode } from './types' +import { SubscriptionList } from '../index' +import { SubscriptionListMode } from '../types' const mockRefetch = vi.fn() let mockSubscriptionListError: Error | null = null @@ -16,7 +16,7 @@ let mockSubscriptionListState: { let mockPluginDetail: PluginDetail | undefined -vi.mock('./use-subscription-list', () => ({ +vi.mock('../use-subscription-list', () => ({ useSubscriptionList: () => { if (mockSubscriptionListError) throw mockSubscriptionListError @@ -24,7 +24,7 @@ vi.mock('./use-subscription-list', () => ({ }, })) -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: (selector: (state: { detail: PluginDetail | undefined }) => PluginDetail | undefined) => selector({ detail: mockPluginDetail }), })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/list-view.spec.tsx similarity index 93% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/list-view.spec.tsx index bac4b5f8ff..7a849d8cd9 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/list-view.spec.tsx @@ -2,15 +2,15 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-select import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { SubscriptionListView } from './list-view' +import { SubscriptionListView } from '../list-view' let mockSubscriptions: TriggerSubscription[] = [] -vi.mock('./use-subscription-list', () => ({ +vi.mock('../use-subscription-list', () => ({ useSubscriptionList: () => ({ subscriptions: mockSubscriptions }), })) -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: () => ({ detail: undefined }), })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx index 44e041d6e2..b131def3c7 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx @@ -1,7 +1,7 @@ import type { TriggerLogEntity } from '@/app/components/workflow/block-selector/types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import LogViewer from './log-viewer' +import LogViewer from '../log-viewer' const mockToastNotify = vi.fn() const mockWriteText = vi.fn() diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx similarity index 95% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx index 09ea047e40..d8d41ff9b2 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx @@ -2,12 +2,12 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-select import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { SubscriptionSelectorEntry } from './selector-entry' +import { SubscriptionSelectorEntry } from '../selector-entry' let mockSubscriptions: TriggerSubscription[] = [] const mockRefetch = vi.fn() -vi.mock('./use-subscription-list', () => ({ +vi.mock('../use-subscription-list', () => ({ useSubscriptionList: () => ({ subscriptions: mockSubscriptions, isLoading: false, @@ -15,7 +15,7 @@ vi.mock('./use-subscription-list', () => ({ }), })) -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: () => ({ detail: undefined }), })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx similarity index 97% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx index eeba994602..48fe2e52c4 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx @@ -2,7 +2,7 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-select import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { SubscriptionSelectorView } from './selector-view' +import { SubscriptionSelectorView } from '../selector-view' let mockSubscriptions: TriggerSubscription[] = [] const mockRefetch = vi.fn() @@ -10,11 +10,11 @@ const mockDelete = vi.fn((_: string, options?: { onSuccess?: () => void }) => { options?.onSuccess?.() }) -vi.mock('./use-subscription-list', () => ({ +vi.mock('../use-subscription-list', () => ({ useSubscriptionList: () => ({ subscriptions: mockSubscriptions, refetch: mockRefetch }), })) -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: () => ({ detail: undefined }), })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx similarity index 95% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx index e707ab0b01..cafd8178cf 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx @@ -2,15 +2,15 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-select import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import SubscriptionCard from './subscription-card' +import SubscriptionCard from '../subscription-card' const mockRefetch = vi.fn() -vi.mock('./use-subscription-list', () => ({ +vi.mock('../use-subscription-list', () => ({ useSubscriptionList: () => ({ refetch: mockRefetch }), })) -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: () => ({ detail: { id: 'detail-1', diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/use-subscription-list.spec.ts similarity index 93% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.spec.ts rename to web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/use-subscription-list.spec.ts index 1f462344bf..fc8a0e4642 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/use-subscription-list.spec.ts @@ -1,7 +1,7 @@ -import type { SimpleDetail } from '../store' +import type { SimpleDetail } from '../../store' import { renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { useSubscriptionList } from './use-subscription-list' +import { useSubscriptionList } from '../use-subscription-list' let mockDetail: SimpleDetail | undefined const mockRefetch = vi.fn() @@ -12,7 +12,7 @@ vi.mock('@/service/use-triggers', () => ({ useTriggerSubscriptions: (...args: unknown[]) => mockTriggerSubscriptions(...args), })) -vi.mock('../store', () => ({ +vi.mock('../../store', () => ({ usePluginStore: (selector: (state: { detail: SimpleDetail | undefined }) => SimpleDetail | undefined) => selector({ detail: mockDetail }), })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx index 2c9d0f5002..20eac10903 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx @@ -4,7 +4,7 @@ import * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SupportedCreationMethods } from '@/app/components/plugins/types' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { CommonCreateModal } from './common-modal' +import { CommonCreateModal } from '../common-modal' type PluginDetail = { plugin_id: string @@ -67,12 +67,12 @@ function createMockLogData(logs: TriggerLogEntity[] = []): { logs: TriggerLogEnt const mockPluginDetail = createMockPluginDetail() const mockUsePluginStore = vi.fn(() => mockPluginDetail) -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: () => mockUsePluginStore(), })) const mockRefetch = vi.fn() -vi.mock('../use-subscription-list', () => ({ +vi.mock('../../use-subscription-list', () => ({ useSubscriptionList: () => ({ refetch: mockRefetch, }), @@ -244,7 +244,7 @@ vi.mock('@/app/components/base/encrypted-bottom', () => ({ EncryptedBottom: () => <div data-testid="encrypted-bottom">Encrypted</div>, })) -vi.mock('../log-viewer', () => ({ +vi.mock('../../log-viewer', () => ({ default: ({ logs }: { logs: TriggerLogEntity[] }) => ( <div data-testid="log-viewer"> {logs.map(log => ( diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx index 8520d7e2e9..3fe9884b92 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx @@ -1,10 +1,10 @@ -import type { SimpleDetail } from '../../store' +import type { SimpleDetail } from '../../../store' import type { TriggerOAuthConfig, TriggerProviderApiEntity, TriggerSubscription, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { SupportedCreationMethods } from '@/app/components/plugins/types' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { CreateButtonType, CreateSubscriptionButton, DEFAULT_METHOD } from './index' +import { CreateButtonType, CreateSubscriptionButton, DEFAULT_METHOD } from '../index' let mockPortalOpenState = false @@ -40,14 +40,14 @@ vi.mock('@/app/components/base/toast', () => ({ })) let mockStoreDetail: SimpleDetail | undefined -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: (selector: (state: { detail: SimpleDetail | undefined }) => SimpleDetail | undefined) => selector({ detail: mockStoreDetail }), })) const mockSubscriptions: TriggerSubscription[] = [] const mockRefetch = vi.fn() -vi.mock('../use-subscription-list', () => ({ +vi.mock('../../use-subscription-list', () => ({ useSubscriptionList: () => ({ subscriptions: mockSubscriptions, refetch: mockRefetch, @@ -72,7 +72,7 @@ vi.mock('@/hooks/use-oauth', () => ({ }), })) -vi.mock('./common-modal', () => ({ +vi.mock('../common-modal', () => ({ CommonCreateModal: ({ createType, onClose, builder }: { createType: SupportedCreationMethods onClose: () => void @@ -88,7 +88,7 @@ vi.mock('./common-modal', () => ({ ), })) -vi.mock('./oauth-client', () => ({ +vi.mock('../oauth-client', () => ({ OAuthClientSettingsModal: ({ oauthConfig, onClose, showOAuthCreateModal }: { oauthConfig?: TriggerOAuthConfig onClose: () => void diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx index 93cbbd518b..12419a9bf3 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { OAuthClientSettingsModal } from './oauth-client' +import { OAuthClientSettingsModal } from '../oauth-client' type PluginDetail = { plugin_id: string @@ -56,7 +56,7 @@ function createMockSubscriptionBuilder(overrides: Partial<TriggerSubscriptionBui const mockPluginDetail = createMockPluginDetail() const mockUsePluginStore = vi.fn(() => mockPluginDetail) -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: () => mockUsePluginStore(), })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.spec.ts rename to web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts index de54a2b87c..89566f3af7 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts @@ -7,7 +7,7 @@ import { ClientTypeEnum, getErrorMessage, useOAuthClientState, -} from './use-oauth-client-state' +} from '../use-oauth-client-state' // ============================================================================ // Mock Factory Functions diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/apikey-edit-modal.spec.tsx similarity index 95% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/apikey-edit-modal.spec.tsx index e5e82d4c0e..af145df2da 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/apikey-edit-modal.spec.tsx @@ -2,14 +2,14 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-select import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { ApiKeyEditModal } from './apikey-edit-modal' +import { ApiKeyEditModal } from '../apikey-edit-modal' const mockRefetch = vi.fn() const mockUpdate = vi.fn() const mockVerify = vi.fn() const mockToast = vi.fn() -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: () => ({ detail: { id: 'detail-1', @@ -37,7 +37,7 @@ vi.mock('../../store', () => ({ }), })) -vi.mock('../use-subscription-list', () => ({ +vi.mock('../../use-subscription-list', () => ({ useSubscriptionList: () => ({ refetch: mockRefetch }), })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/edit/index.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx index a6162967f0..7d188a3f6d 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx @@ -5,10 +5,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { FormTypeEnum } from '@/app/components/base/form/types' import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { ApiKeyEditModal } from './apikey-edit-modal' -import { EditModal } from './index' -import { ManualEditModal } from './manual-edit-modal' -import { OAuthEditModal } from './oauth-edit-modal' +import { ApiKeyEditModal } from '../apikey-edit-modal' +import { EditModal } from '../index' +import { ManualEditModal } from '../manual-edit-modal' +import { OAuthEditModal } from '../oauth-edit-modal' // ==================== Mock Setup ==================== @@ -63,13 +63,13 @@ const mockPluginStoreDetail = { }, } -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: (selector: (state: { detail: typeof mockPluginStoreDetail }) => unknown) => selector({ detail: mockPluginStoreDetail }), })) const mockRefetch = vi.fn() -vi.mock('../use-subscription-list', () => ({ +vi.mock('../../use-subscription-list', () => ({ useSubscriptionList: () => ({ refetch: mockRefetch }), })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/manual-edit-modal.spec.tsx similarity index 95% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/manual-edit-modal.spec.tsx index 048c20eeeb..c6144542ab 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/manual-edit-modal.spec.tsx @@ -2,13 +2,13 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-select import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { ManualEditModal } from './manual-edit-modal' +import { ManualEditModal } from '../manual-edit-modal' const mockRefetch = vi.fn() const mockUpdate = vi.fn() const mockToast = vi.fn() -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: () => ({ detail: { id: 'detail-1', @@ -21,7 +21,7 @@ vi.mock('../../store', () => ({ }), })) -vi.mock('../use-subscription-list', () => ({ +vi.mock('../../use-subscription-list', () => ({ useSubscriptionList: () => ({ refetch: mockRefetch }), })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/oauth-edit-modal.spec.tsx similarity index 95% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/oauth-edit-modal.spec.tsx index ccbe4792ac..7bdcdbc936 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/oauth-edit-modal.spec.tsx @@ -2,13 +2,13 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-select import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { OAuthEditModal } from './oauth-edit-modal' +import { OAuthEditModal } from '../oauth-edit-modal' const mockRefetch = vi.fn() const mockUpdate = vi.fn() const mockToast = vi.fn() -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: () => ({ detail: { id: 'detail-1', @@ -21,7 +21,7 @@ vi.mock('../../store', () => ({ }), })) -vi.mock('../use-subscription-list', () => ({ +vi.mock('../../use-subscription-list', () => ({ useSubscriptionList: () => ({ refetch: mockRefetch }), })) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/tool-selector/index.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx index f4ed1bcae5..26e4de0fd7 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx @@ -18,9 +18,9 @@ import { ToolItem, ToolSettingsPanel, ToolTrigger, -} from './components' -import { usePluginInstalledCheck, useToolSelectorState } from './hooks' -import ToolSelector from './index' +} from '../components' +import { usePluginInstalledCheck, useToolSelectorState } from '../hooks' +import ToolSelector from '../index' // ==================== Mock Setup ==================== @@ -181,11 +181,11 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ ), })) -vi.mock('../../../readme-panel/entrance', () => ({ +vi.mock('../../../../readme-panel/entrance', () => ({ ReadmeEntrance: () => <div data-testid="readme-entrance" />, })) -vi.mock('./components/reasoning-config-form', () => ({ +vi.mock('../components/reasoning-config-form', () => ({ default: ({ onChange, value, diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-base-form.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-base-form.spec.tsx new file mode 100644 index 0000000000..73ebb89e0b --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-base-form.spec.tsx @@ -0,0 +1,107 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/app/components/base/textarea', () => ({ + default: ({ value, onChange, disabled, placeholder }: { + value?: string + onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void + disabled?: boolean + placeholder?: string + }) => ( + <textarea + data-testid="description-textarea" + value={value || ''} + onChange={onChange} + disabled={disabled} + placeholder={placeholder} + /> + ), +})) + +vi.mock('../../../../readme-panel/entrance', () => ({ + ReadmeEntrance: () => <div data-testid="readme-entrance" />, +})) + +vi.mock('@/app/components/workflow/block-selector/tool-picker', () => ({ + default: ({ trigger }: { trigger: React.ReactNode }) => ( + <div data-testid="tool-picker">{trigger}</div> + ), +})) + +vi.mock('../tool-trigger', () => ({ + default: ({ value, provider }: { open?: boolean, value?: unknown, provider?: unknown }) => ( + <div data-testid="tool-trigger" data-has-value={!!value} data-has-provider={!!provider} /> + ), +})) + +const mockOnDescriptionChange = vi.fn() +const mockOnShowChange = vi.fn() +const mockOnSelectTool = vi.fn() +const mockOnSelectMultipleTool = vi.fn() + +const defaultProps = { + isShowChooseTool: false, + hasTrigger: true, + onShowChange: mockOnShowChange, + onSelectTool: mockOnSelectTool, + onSelectMultipleTool: mockOnSelectMultipleTool, + onDescriptionChange: mockOnDescriptionChange, +} + +describe('ToolBaseForm', () => { + let ToolBaseForm: (typeof import('../tool-base-form'))['default'] + + beforeEach(async () => { + vi.clearAllMocks() + const mod = await import('../tool-base-form') + ToolBaseForm = mod.default + }) + + it('should render tool trigger within tool picker', () => { + render(<ToolBaseForm {...defaultProps} />) + + expect(screen.getByTestId('tool-trigger')).toBeInTheDocument() + expect(screen.getByTestId('tool-picker')).toBeInTheDocument() + }) + + it('should render description textarea', () => { + render(<ToolBaseForm {...defaultProps} />) + + expect(screen.getByTestId('description-textarea')).toBeInTheDocument() + }) + + it('should disable textarea when no provider_name in value', () => { + render(<ToolBaseForm {...defaultProps} />) + + expect(screen.getByTestId('description-textarea')).toBeDisabled() + }) + + it('should enable textarea when value has provider_name', () => { + const value = { provider_name: 'test-provider', tool_name: 'test', extra: { description: 'Hello' } } as never + render(<ToolBaseForm {...defaultProps} value={value} />) + + expect(screen.getByTestId('description-textarea')).not.toBeDisabled() + }) + + it('should call onDescriptionChange when textarea content changes', () => { + const value = { provider_name: 'test-provider', tool_name: 'test', extra: { description: 'Hello' } } as never + render(<ToolBaseForm {...defaultProps} value={value} />) + + fireEvent.change(screen.getByTestId('description-textarea'), { target: { value: 'Updated' } }) + expect(mockOnDescriptionChange).toHaveBeenCalled() + }) + + it('should show ReadmeEntrance when provider has plugin_unique_identifier', () => { + const provider = { plugin_unique_identifier: 'test/plugin' } as never + render(<ToolBaseForm {...defaultProps} currentProvider={provider} />) + + expect(screen.getByTestId('readme-entrance')).toBeInTheDocument() + }) + + it('should not show ReadmeEntrance without plugin_unique_identifier', () => { + render(<ToolBaseForm {...defaultProps} />) + + expect(screen.queryByTestId('readme-entrance')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-credentials-form.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-credentials-form.spec.tsx new file mode 100644 index 0000000000..20655d0139 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-credentials-form.spec.tsx @@ -0,0 +1,113 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/hooks/use-i18n', () => ({ + useRenderI18nObject: () => (obj: Record<string, string> | string) => typeof obj === 'string' ? obj : obj?.en_US || '', +})) + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: vi.fn() }, + useToastContext: () => ({ notify: vi.fn() }), +})) + +const mockFormSchemas = [ + { name: 'api_key', label: { en_US: 'API Key' }, type: 'secret-input', required: true }, +] + +vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ + addDefaultValue: (values: Record<string, unknown>) => values, + toolCredentialToFormSchemas: () => mockFormSchemas, +})) + +vi.mock('@/service/tools', () => ({ + fetchBuiltInToolCredential: vi.fn().mockResolvedValue({ api_key: 'sk-existing-key' }), + fetchBuiltInToolCredentialSchema: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({ + default: ({ value: _value, onChange }: { formSchemas: unknown[], value: Record<string, unknown>, onChange: (v: Record<string, unknown>) => void }) => ( + <div data-testid="credential-form"> + <input + data-testid="form-input" + onChange={e => onChange({ api_key: e.target.value })} + /> + </div> + ), +})) + +describe('ToolCredentialForm', () => { + let ToolCredentialForm: (typeof import('../tool-credentials-form'))['default'] + + beforeEach(async () => { + vi.clearAllMocks() + const mod = await import('../tool-credentials-form') + ToolCredentialForm = mod.default + }) + + it('should render loading state initially', async () => { + await act(async () => { + render( + <ToolCredentialForm + collection={{ id: 'test', name: 'Test', labels: [] } as never} + onCancel={vi.fn()} + onSaved={vi.fn()} + />, + ) + }) + + // After act resolves async effects, form should be loaded + expect(screen.getByTestId('credential-form')).toBeInTheDocument() + }) + + it('should render form after loading', async () => { + await act(async () => { + render( + <ToolCredentialForm + collection={{ id: 'test', name: 'Test', labels: [] } as never} + onCancel={vi.fn()} + onSaved={vi.fn()} + />, + ) + }) + + expect(screen.getByTestId('credential-form')).toBeInTheDocument() + }) + + it('should call onCancel when cancel button clicked', async () => { + const mockOnCancel = vi.fn() + await act(async () => { + render( + <ToolCredentialForm + collection={{ id: 'test', name: 'Test', labels: [] } as never} + onCancel={mockOnCancel} + onSaved={vi.fn()} + />, + ) + }) + + const cancelBtn = screen.getByText('common.operation.cancel') + fireEvent.click(cancelBtn) + expect(mockOnCancel).toHaveBeenCalled() + }) + + it('should call onSaved when save button clicked', async () => { + const mockOnSaved = vi.fn() + await act(async () => { + render( + <ToolCredentialForm + collection={{ id: 'test', name: 'Test', labels: [] } as never} + onCancel={vi.fn()} + onSaved={mockOnSaved} + />, + ) + }) + + fireEvent.click(screen.getByText('common.operation.save')) + expect(mockOnSaved).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-plugin-installed-check.spec.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-plugin-installed-check.spec.ts new file mode 100644 index 0000000000..f3cf0fab54 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-plugin-installed-check.spec.ts @@ -0,0 +1,63 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { usePluginInstalledCheck } from '../use-plugin-installed-check' + +const mockManifest = { + data: { + plugin: { + name: 'test-plugin', + version: '1.0.0', + }, + }, +} + +vi.mock('@/service/use-plugins', () => ({ + usePluginManifestInfo: (pluginID: string) => ({ + data: pluginID ? mockManifest : undefined, + }), +})) + +describe('usePluginInstalledCheck', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should extract pluginID from provider name', () => { + const { result } = renderHook(() => usePluginInstalledCheck('org/plugin/tool')) + + expect(result.current.pluginID).toBe('org/plugin') + }) + + it('should detect plugin in marketplace when manifest exists', () => { + const { result } = renderHook(() => usePluginInstalledCheck('org/plugin/tool')) + + expect(result.current.inMarketPlace).toBe(true) + expect(result.current.manifest).toEqual(mockManifest.data.plugin) + }) + + it('should handle empty provider name', () => { + const { result } = renderHook(() => usePluginInstalledCheck('')) + + expect(result.current.pluginID).toBe('') + expect(result.current.inMarketPlace).toBe(false) + }) + + it('should handle undefined provider name', () => { + const { result } = renderHook(() => usePluginInstalledCheck()) + + expect(result.current.pluginID).toBe('') + expect(result.current.inMarketPlace).toBe(false) + }) + + it('should handle provider name with only one segment', () => { + const { result } = renderHook(() => usePluginInstalledCheck('single')) + + expect(result.current.pluginID).toBe('single') + }) + + it('should handle provider name with two segments', () => { + const { result } = renderHook(() => usePluginInstalledCheck('org/plugin')) + + expect(result.current.pluginID).toBe('org/plugin') + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector-state.spec.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector-state.spec.ts new file mode 100644 index 0000000000..5af624649c --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector-state.spec.ts @@ -0,0 +1,226 @@ +import type * as React from 'react' +import type { ToolValue } from '@/app/components/workflow/block-selector/types' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useToolSelectorState } from '../use-tool-selector-state' + +const mockToolParams = [ + { name: 'param1', form: 'llm', type: 'string', required: true, label: { en_US: 'Param 1' } }, + { name: 'param2', form: 'form', type: 'number', required: false, label: { en_US: 'Param 2' } }, +] + +const mockTools = [ + { + id: 'test-provider', + name: 'Test Provider', + tools: [ + { + name: 'test-tool', + label: { en_US: 'Test Tool' }, + description: { en_US: 'A test tool' }, + parameters: mockToolParams, + }, + ], + }, +] + +vi.mock('@/service/use-tools', () => ({ + useAllBuiltInTools: () => ({ data: mockTools }), + useAllCustomTools: () => ({ data: [] }), + useAllWorkflowTools: () => ({ data: [] }), + useAllMCPTools: () => ({ data: [] }), + useInvalidateAllBuiltInTools: () => vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@/service/use-plugins', () => ({ + useInvalidateInstalledPluginList: () => vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('../use-plugin-installed-check', () => ({ + usePluginInstalledCheck: () => ({ + inMarketPlace: false, + manifest: null, + pluginID: '', + }), +})) + +vi.mock('@/utils/get-icon', () => ({ + getIconFromMarketPlace: () => '', +})) + +vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ + toolParametersToFormSchemas: (params: unknown[]) => (params as Record<string, unknown>[]).map(p => ({ + ...p, + variable: p.name, + })), + generateFormValue: (value: Record<string, unknown>) => value || {}, + getPlainValue: (value: Record<string, unknown>) => value || {}, + getStructureValue: (value: Record<string, unknown>) => value || {}, +})) + +describe('useToolSelectorState', () => { + const mockOnSelect = vi.fn() + const _mockOnSelectMultiple = vi.fn() + + const toolValue: ToolValue = { + provider_name: 'test-provider', + provider_show_name: 'Test Provider', + tool_name: 'test-tool', + tool_label: 'Test Tool', + tool_description: 'A test tool', + settings: {}, + parameters: {}, + enabled: true, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should initialize with default panel states', () => { + const { result } = renderHook(() => + useToolSelectorState({ onSelect: mockOnSelect }), + ) + + expect(result.current.isShow).toBe(false) + expect(result.current.isShowChooseTool).toBe(false) + expect(result.current.currType).toBe('settings') + }) + + it('should find current provider from tool value', () => { + const { result } = renderHook(() => + useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }), + ) + + expect(result.current.currentProvider).toBeDefined() + expect(result.current.currentProvider?.id).toBe('test-provider') + }) + + it('should find current tool from provider', () => { + const { result } = renderHook(() => + useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }), + ) + + expect(result.current.currentTool).toBeDefined() + expect(result.current.currentTool?.name).toBe('test-tool') + }) + + it('should compute tool settings and params correctly', () => { + const { result } = renderHook(() => + useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }), + ) + + // param2 has form='form' (not 'llm'), so it goes to settings + expect(result.current.currentToolSettings).toHaveLength(1) + expect(result.current.currentToolSettings[0].name).toBe('param2') + + // param1 has form='llm', so it goes to params + expect(result.current.currentToolParams).toHaveLength(1) + expect(result.current.currentToolParams[0].name).toBe('param1') + }) + + it('should show tab slider when both settings and params exist', () => { + const { result } = renderHook(() => + useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }), + ) + + expect(result.current.showTabSlider).toBe(true) + expect(result.current.userSettingsOnly).toBe(false) + expect(result.current.reasoningConfigOnly).toBe(false) + }) + + it('should toggle panel visibility', () => { + const { result } = renderHook(() => + useToolSelectorState({ onSelect: mockOnSelect }), + ) + + act(() => { + result.current.setIsShow(true) + }) + expect(result.current.isShow).toBe(true) + + act(() => { + result.current.setIsShowChooseTool(true) + }) + expect(result.current.isShowChooseTool).toBe(true) + }) + + it('should switch tab type', () => { + const { result } = renderHook(() => + useToolSelectorState({ onSelect: mockOnSelect }), + ) + + act(() => { + result.current.setCurrType('params') + }) + expect(result.current.currType).toBe('params') + }) + + it('should handle description change', () => { + const { result } = renderHook(() => + useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }), + ) + + const event = { target: { value: 'New description' } } as React.ChangeEvent<HTMLTextAreaElement> + act(() => { + result.current.handleDescriptionChange(event) + }) + + expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({ + extra: expect.objectContaining({ description: 'New description' }), + })) + }) + + it('should handle enabled change', () => { + const { result } = renderHook(() => + useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }), + ) + + act(() => { + result.current.handleEnabledChange(false) + }) + + expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({ + enabled: false, + })) + }) + + it('should handle authorization item click', () => { + const { result } = renderHook(() => + useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }), + ) + + act(() => { + result.current.handleAuthorizationItemClick('cred-123') + }) + + expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({ + credential_id: 'cred-123', + })) + }) + + it('should not call onSelect if value is undefined', () => { + const { result } = renderHook(() => + useToolSelectorState({ onSelect: mockOnSelect }), + ) + + act(() => { + result.current.handleEnabledChange(true) + }) + expect(mockOnSelect).not.toHaveBeenCalled() + }) + + it('should return empty arrays when no provider matches', () => { + const { result } = renderHook(() => + useToolSelectorState({ + value: { ...toolValue, provider_name: 'nonexistent' }, + onSelect: mockOnSelect, + }), + ) + + expect(result.current.currentProvider).toBeUndefined() + expect(result.current.currentTool).toBeUndefined() + expect(result.current.currentToolSettings).toEqual([]) + expect(result.current.currentToolParams).toEqual([]) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.spec.tsx b/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-detail-drawer.spec.tsx similarity index 89% rename from web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-detail-drawer.spec.tsx index 5ae7b62f13..a4414adb59 100644 --- a/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-detail-drawer.spec.tsx @@ -2,13 +2,7 @@ import type { TriggerEvent } from '@/app/components/plugins/types' import type { TriggerProviderApiEntity } from '@/app/components/workflow/block-selector/types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { EventDetailDrawer } from './event-detail-drawer' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import { EventDetailDrawer } from '../event-detail-drawer' vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useLanguage: () => 'en_US', @@ -121,21 +115,21 @@ describe('EventDetailDrawer', () => { it('should render parameters section', () => { render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('setBuiltInTools.parameters')).toBeInTheDocument() + expect(screen.getByText('tools.setBuiltInTools.parameters')).toBeInTheDocument() expect(screen.getByText('Parameter 1')).toBeInTheDocument() }) it('should render output section', () => { render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('events.output')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.events.output')).toBeInTheDocument() expect(screen.getByTestId('output-field')).toHaveTextContent('result') }) it('should render back button', () => { render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('detailPanel.operation.back')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.operation.back')).toBeInTheDocument() }) }) @@ -154,7 +148,7 @@ describe('EventDetailDrawer', () => { it('should call onClose when back clicked', () => { render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - fireEvent.click(screen.getByText('detailPanel.operation.back')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.back')) expect(mockOnClose).toHaveBeenCalledTimes(1) }) @@ -165,14 +159,14 @@ describe('EventDetailDrawer', () => { const eventWithNoParams = { ...mockEventInfo, parameters: [] } render(<EventDetailDrawer eventInfo={eventWithNoParams} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('events.item.noParameters')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.events.item.noParameters')).toBeInTheDocument() }) it('should handle no output schema', () => { const eventWithNoOutput = { ...mockEventInfo, output_schema: {} } render(<EventDetailDrawer eventInfo={eventWithNoOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('events.output')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.events.output')).toBeInTheDocument() expect(screen.queryByTestId('output-field')).not.toBeInTheDocument() }) }) @@ -185,7 +179,7 @@ describe('EventDetailDrawer', () => { } render(<EventDetailDrawer eventInfo={eventWithNumber} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('setBuiltInTools.number')).toBeInTheDocument() + expect(screen.getByText('tools.setBuiltInTools.number')).toBeInTheDocument() }) it('should display correct type for checkbox', () => { @@ -205,7 +199,7 @@ describe('EventDetailDrawer', () => { } render(<EventDetailDrawer eventInfo={eventWithFile} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('setBuiltInTools.file')).toBeInTheDocument() + expect(screen.getByText('tools.setBuiltInTools.file')).toBeInTheDocument() }) it('should display original type for unknown types', () => { @@ -232,7 +226,7 @@ describe('EventDetailDrawer', () => { } render(<EventDetailDrawer eventInfo={eventWithArrayOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('events.output')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.events.output')).toBeInTheDocument() }) it('should handle nested properties in output schema', () => { @@ -251,7 +245,7 @@ describe('EventDetailDrawer', () => { } render(<EventDetailDrawer eventInfo={eventWithNestedOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('events.output')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.events.output')).toBeInTheDocument() }) it('should handle enum in output schema', () => { @@ -266,7 +260,7 @@ describe('EventDetailDrawer', () => { } render(<EventDetailDrawer eventInfo={eventWithEnumOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('events.output')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.events.output')).toBeInTheDocument() }) it('should handle array type schema', () => { @@ -281,7 +275,7 @@ describe('EventDetailDrawer', () => { } render(<EventDetailDrawer eventInfo={eventWithArrayType} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('events.output')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.events.output')).toBeInTheDocument() }) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/trigger/event-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-list.spec.tsx similarity index 88% rename from web/app/components/plugins/plugin-detail-panel/trigger/event-list.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-list.spec.tsx index 2687319fbc..3ecd248544 100644 --- a/web/app/components/plugins/plugin-detail-panel/trigger/event-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-list.spec.tsx @@ -1,17 +1,7 @@ import type { TriggerEvent } from '@/app/components/plugins/types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { TriggerEventsList } from './event-list' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: Record<string, unknown>) => { - if (options?.num !== undefined) - return `${options.num} ${options.event || 'events'}` - return key - }, - }), -})) +import { TriggerEventsList } from '../event-list' vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useLanguage: () => 'en_US', @@ -38,7 +28,7 @@ const mockTriggerEvents = [ let mockDetail: { plugin_id: string, provider: string } | undefined let mockProviderInfo: { events: TriggerEvent[] } | undefined -vi.mock('../store', () => ({ +vi.mock('../../store', () => ({ usePluginStore: (selector: (state: { detail: typeof mockDetail }) => typeof mockDetail) => selector({ detail: mockDetail }), })) @@ -47,7 +37,7 @@ vi.mock('@/service/use-triggers', () => ({ useTriggerProviderInfo: () => ({ data: mockProviderInfo }), })) -vi.mock('./event-detail-drawer', () => ({ +vi.mock('../event-detail-drawer', () => ({ EventDetailDrawer: ({ onClose }: { onClose: () => void }) => ( <div data-testid="event-detail-drawer"> <button data-testid="close-drawer" onClick={onClose}>Close</button> @@ -66,7 +56,7 @@ describe('TriggerEventsList', () => { it('should render event count', () => { render(<TriggerEventsList />) - expect(screen.getByText('1 events.event')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.events.actionNum:{"num":1,"event":"pluginTrigger.events.event"}')).toBeInTheDocument() }) it('should render event cards', () => { @@ -140,7 +130,7 @@ describe('TriggerEventsList', () => { expect(screen.getByText('Event One')).toBeInTheDocument() expect(screen.getByText('Event Two')).toBeInTheDocument() - expect(screen.getByText('2 events.events')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.events.actionNum:{"num":2,"event":"pluginTrigger.events.events"}')).toBeInTheDocument() }) }) }) diff --git a/web/app/components/plugins/plugin-item/action.spec.tsx b/web/app/components/plugins/plugin-item/__tests__/action.spec.tsx similarity index 98% rename from web/app/components/plugins/plugin-item/action.spec.tsx rename to web/app/components/plugins/plugin-item/__tests__/action.spec.tsx index 9969357bb6..8467c983d8 100644 --- a/web/app/components/plugins/plugin-item/action.spec.tsx +++ b/web/app/components/plugins/plugin-item/__tests__/action.spec.tsx @@ -1,12 +1,12 @@ -import type { MetaData, PluginCategoryEnum } from '../types' +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' +import { PluginSource } from '../../types' +import Action from '../action' // ==================== Mock Setup ==================== @@ -31,7 +31,7 @@ vi.mock('@/service/plugins', () => ({ })) // Mock GitHub releases hook -vi.mock('../install-plugin/hooks', () => ({ +vi.mock('../../install-plugin/hooks', () => ({ useGitHubReleases: () => ({ fetchReleases: mockFetchReleases, checkForUpdates: mockCheckForUpdates, @@ -51,7 +51,7 @@ vi.mock('@/service/use-plugins', () => ({ })) // Mock PluginInfo component - has complex dependencies (Modal, KeyValueItem) -vi.mock('../plugin-page/plugin-info', () => ({ +vi.mock('../../plugin-page/plugin-info', () => ({ default: ({ repository, release, packageName, onHide }: { repository: string release: string @@ -66,7 +66,7 @@ vi.mock('../plugin-page/plugin-info', () => ({ // Mock Tooltip - uses PortalToFollowElem which requires complex floating UI setup // Simplified mock that just renders children with tooltip content accessible -vi.mock('../../base/tooltip', () => ({ +vi.mock('../../../base/tooltip', () => ({ default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => ( <div data-testid="tooltip" data-popup-content={popupContent}> {children} @@ -75,7 +75,7 @@ vi.mock('../../base/tooltip', () => ({ })) // Mock Confirm - uses createPortal which has issues in test environment -vi.mock('../../base/confirm', () => ({ +vi.mock('../../../base/confirm', () => ({ default: ({ isShow, title, content, onCancel, onConfirm, isLoading, isDisabled }: { isShow: boolean title: string @@ -875,7 +875,7 @@ describe('Action Component', () => { it('should be wrapped with React.memo', () => { // Assert expect(Action).toBeDefined() - expect((Action as any).$$typeof?.toString()).toContain('Symbol') + expect((Action as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) diff --git a/web/app/components/plugins/plugin-item/index.spec.tsx b/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx similarity index 94% rename from web/app/components/plugins/plugin-item/index.spec.tsx rename to web/app/components/plugins/plugin-item/__tests__/index.spec.tsx index ae76e64c46..39f3915f99 100644 --- a/web/app/components/plugins/plugin-item/index.spec.tsx +++ b/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx @@ -1,27 +1,19 @@ -import type { PluginDeclaration, PluginDetail } from '../types' +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' +import { PluginCategoryEnum, PluginSource } from '../../types' +import PluginItem from '../index' -// ==================== 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<string, string>) => obj?.en_US || '') vi.mock('@/hooks/use-i18n', () => ({ useRenderI18nObject: () => mockGetValueFromI18nObject, })) -// Mock categories hook const mockCategoriesMap: Record<string, { name: string, label: string }> = { 'tool': { name: 'tool', label: 'Tools' }, 'model': { name: 'model', label: 'Models' }, @@ -29,18 +21,17 @@ const mockCategoriesMap: Record<string, { name: string, label: string }> = { 'agent-strategy': { name: 'agent-strategy', label: 'Agents' }, 'datasource': { name: 'datasource', label: 'Data Sources' }, } -vi.mock('../hooks', () => ({ +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) => { +vi.mock('../../plugin-page/context', () => ({ + usePluginPageContext: (selector: (v: Record<string, unknown>) => unknown) => { const context = { currentPluginID: mockCurrentPluginID(), setCurrentPluginID: mockSetCurrentPluginID, @@ -49,13 +40,11 @@ vi.mock('../plugin-page/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', })) @@ -65,15 +54,13 @@ vi.mock('@/context/app-context', () => ({ }), })) -// Mock global public store const mockEnableMarketplace = vi.fn(() => true) vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (s: any) => any) => + useGlobalPublicStore: (selector: (s: Record<string, unknown>) => unknown) => selector({ systemFeatures: { enable_marketplace: mockEnableMarketplace() } }), })) -// Mock Action component -vi.mock('./action', () => ({ +vi.mock('../action', () => ({ default: ({ onDelete, pluginName }: { onDelete: () => void, pluginName: string }) => ( <div data-testid="plugin-action" data-plugin-name={pluginName}> <button data-testid="delete-button" onClick={onDelete}>Delete</button> @@ -81,20 +68,19 @@ vi.mock('./action', () => ({ ), })) -// Mock child components -vi.mock('../card/base/corner-mark', () => ({ +vi.mock('../../card/base/corner-mark', () => ({ default: ({ text }: { text: string }) => <div data-testid="corner-mark">{text}</div>, })) -vi.mock('../card/base/title', () => ({ +vi.mock('../../card/base/title', () => ({ default: ({ title }: { title: string }) => <div data-testid="plugin-title">{title}</div>, })) -vi.mock('../card/base/description', () => ({ +vi.mock('../../card/base/description', () => ({ default: ({ text }: { text: string }) => <div data-testid="plugin-description">{text}</div>, })) -vi.mock('../card/base/org-info', () => ({ +vi.mock('../../card/base/org-info', () => ({ default: ({ orgName, packageName }: { orgName: string, packageName: string }) => ( <div data-testid="org-info" data-org={orgName} data-package={packageName}> {orgName} @@ -104,18 +90,16 @@ vi.mock('../card/base/org-info', () => ({ ), })) -vi.mock('../base/badges/verified', () => ({ +vi.mock('../../base/badges/verified', () => ({ default: ({ text }: { text: string }) => <div data-testid="verified-badge">{text}</div>, })) -vi.mock('../../base/badge', () => ({ +vi.mock('../../../base/badge', () => ({ default: ({ text, hasRedCornerMark }: { text: string, hasRedCornerMark?: boolean }) => ( <div data-testid="version-badge" data-has-update={hasRedCornerMark}>{text}</div> ), })) -// ==================== Test Utilities ==================== - const createPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({ plugin_unique_identifier: 'test-plugin-id', version: '1.0.0', @@ -124,13 +108,13 @@ const createPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): Pl 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, + label: { en_US: 'Test Plugin' } as unknown as PluginDeclaration['label'], + description: { en_US: 'Test plugin description' } as unknown as PluginDeclaration['description'], created_at: '2024-01-01', resource: null, plugins: null, verified: false, - endpoint: {} as any, + endpoint: {} as unknown as PluginDeclaration['endpoint'], model: null, tags: [], agent_strategy: null, @@ -138,7 +122,7 @@ const createPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): Pl version: '1.0.0', minimum_dify_version: '0.5.0', }, - trigger: {} as any, + trigger: {} as unknown as PluginDeclaration['trigger'], ...overrides, }) @@ -169,8 +153,6 @@ const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail ...overrides, }) -// ==================== Tests ==================== - describe('PluginItem', () => { beforeEach(() => { vi.clearAllMocks() @@ -181,7 +163,6 @@ describe('PluginItem', () => { mockGetValueFromI18nObject.mockImplementation((obj: Record<string, string>) => obj?.en_US || '') }) - // ==================== Rendering Tests ==================== describe('Rendering', () => { it('should render plugin item with basic info', () => { // Arrange @@ -235,7 +216,6 @@ describe('PluginItem', () => { }) }) - // ==================== Plugin Sources Tests ==================== describe('Plugin Sources', () => { it('should render GitHub source with repo link', () => { // Arrange @@ -333,7 +313,6 @@ describe('PluginItem', () => { }) }) - // ==================== Extension Category Tests ==================== describe('Extension Category', () => { it('should show endpoints info for extension category', () => { // Arrange @@ -364,7 +343,6 @@ describe('PluginItem', () => { }) }) - // ==================== Version Compatibility Tests ==================== describe('Version Compatibility', () => { it('should show warning icon when Dify version is not compatible', () => { // Arrange @@ -430,7 +408,6 @@ describe('PluginItem', () => { }) }) - // ==================== Deprecated Plugin Tests ==================== describe('Deprecated Plugin', () => { it('should show deprecated indicator for deprecated marketplace plugin', () => { // Arrange @@ -842,7 +819,6 @@ describe('PluginItem', () => { }) }) - // ==================== Edge Cases ==================== describe('Edge Cases', () => { it('should handle empty icon gracefully', () => { // Arrange @@ -900,7 +876,7 @@ describe('PluginItem', () => { const plugin = createPluginDetail({ source: PluginSource.marketplace, version: '1.0.0', - latest_version: null as any, + latest_version: null as unknown as string, }) // Act @@ -959,7 +935,6 @@ describe('PluginItem', () => { }) }) - // ==================== Callback Stability Tests ==================== describe('Callback Stability', () => { it('should have stable handleDelete callback', () => { // Arrange @@ -1002,7 +977,6 @@ describe('PluginItem', () => { }) }) - // ==================== React.memo Tests ==================== describe('React.memo Behavior', () => { it('should be wrapped with React.memo', () => { // Arrange & Assert @@ -1010,7 +984,7 @@ describe('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') + expect((PluginItem as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) }) diff --git a/web/app/components/plugins/plugin-mutation-model/index.spec.tsx b/web/app/components/plugins/plugin-mutation-model/__tests__/index.spec.tsx similarity index 92% rename from web/app/components/plugins/plugin-mutation-model/index.spec.tsx rename to web/app/components/plugins/plugin-mutation-model/__tests__/index.spec.tsx index 98be2e4373..d36cf12f11 100644 --- a/web/app/components/plugins/plugin-mutation-model/index.spec.tsx +++ b/web/app/components/plugins/plugin-mutation-model/__tests__/index.spec.tsx @@ -1,32 +1,24 @@ -import type { Plugin } from '../types' +import type { Plugin } from '../../types' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum } from '../types' -import PluginMutationModal from './index' +import { PluginCategoryEnum } from '../../types' +import PluginMutationModal from '../index' -// ================================ -// Mock External Dependencies Only -// ================================ - -// Mock useTheme hook vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: 'light' }), })) -// Mock i18n-config vi.mock('@/i18n-config', () => ({ renderI18nObject: (obj: Record<string, string>, locale: string) => { return obj?.[locale] || obj?.['en-US'] || '' }, })) -// Mock i18n-config/language vi.mock('@/i18n-config/language', () => ({ getLanguage: (locale: string) => locale || 'en-US', })) -// Mock useCategories hook const mockCategoriesMap: Record<string, { label: string }> = { 'tool': { label: 'Tool' }, 'model': { label: 'Model' }, @@ -37,18 +29,16 @@ const mockCategoriesMap: Record<string, { label: string }> = { 'bundle': { label: 'Bundle' }, } -vi.mock('../hooks', () => ({ +vi.mock('../../hooks', () => ({ useCategories: () => ({ categoriesMap: mockCategoriesMap, }), })) -// Mock formatNumber utility vi.mock('@/utils/format', () => ({ formatNumber: (num: number) => num.toLocaleString(), })) -// Mock shouldUseMcpIcon utility vi.mock('@/utils/mcp', () => ({ shouldUseMcpIcon: (src: unknown) => typeof src === 'object' @@ -56,7 +46,6 @@ vi.mock('@/utils/mcp', () => ({ && (src as { content?: string })?.content === '🔗', })) -// Mock AppIcon component vi.mock('@/app/components/base/app-icon', () => ({ default: ({ icon, @@ -83,7 +72,6 @@ vi.mock('@/app/components/base/app-icon', () => ({ ), })) -// Mock Mcp icon component vi.mock('@/app/components/base/icons/src/vender/other', () => ({ Mcp: ({ className }: { className?: string }) => ( <div data-testid="mcp-icon" className={className}> @@ -97,8 +85,7 @@ vi.mock('@/app/components/base/icons/src/vender/other', () => ({ ), })) -// Mock LeftCorner icon component -vi.mock('../../base/icons/src/vender/plugin', () => ({ +vi.mock('../../../base/icons/src/vender/plugin', () => ({ LeftCorner: ({ className }: { className?: string }) => ( <div data-testid="left-corner" className={className}> LeftCorner @@ -106,8 +93,7 @@ vi.mock('../../base/icons/src/vender/plugin', () => ({ ), })) -// Mock Partner badge -vi.mock('../base/badges/partner', () => ({ +vi.mock('../../base/badges/partner', () => ({ default: ({ className, text }: { className?: string, text?: string }) => ( <div data-testid="partner-badge" className={className} title={text}> Partner @@ -115,8 +101,7 @@ vi.mock('../base/badges/partner', () => ({ ), })) -// Mock Verified badge -vi.mock('../base/badges/verified', () => ({ +vi.mock('../../base/badges/verified', () => ({ default: ({ className, text }: { className?: string, text?: string }) => ( <div data-testid="verified-badge" className={className} title={text}> Verified @@ -124,36 +109,6 @@ vi.mock('../base/badges/verified', () => ({ ), })) -// Mock Remix icons -vi.mock('@remixicon/react', () => ({ - RiCheckLine: ({ className }: { className?: string }) => ( - <span data-testid="ri-check-line" className={className}> - ✓ - </span> - ), - RiCloseLine: ({ className }: { className?: string }) => ( - <span data-testid="ri-close-line" className={className}> - ✕ - </span> - ), - RiInstallLine: ({ className }: { className?: string }) => ( - <span data-testid="ri-install-line" className={className}> - ↓ - </span> - ), - RiAlertFill: ({ className }: { className?: string }) => ( - <span data-testid="ri-alert-fill" className={className}> - ⚠ - </span> - ), - RiLoader2Line: ({ className }: { className?: string }) => ( - <span data-testid="ri-loader-line" className={className}> - ⟳ - </span> - ), -})) - -// Mock Skeleton components vi.mock('@/app/components/base/skeleton', () => ({ SkeletonContainer: ({ children }: { children: React.ReactNode }) => ( <div data-testid="skeleton-container">{children}</div> @@ -330,8 +285,7 @@ describe('PluginMutationModal', () => { render(<PluginMutationModal {...props} />) - // The modal should have a close button - expect(screen.getByTestId('ri-close-line')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBeInTheDocument() }) }) @@ -465,9 +419,8 @@ describe('PluginMutationModal', () => { render(<PluginMutationModal {...props} />) - // Find the close icon - the Modal component handles the onClose callback - const closeIcon = screen.getByTestId('ri-close-line') - expect(closeIcon).toBeInTheDocument() + const dialog = screen.getByRole('dialog') + expect(dialog).toBeInTheDocument() }) it('should not call mutate when button is disabled during pending', () => { @@ -563,9 +516,7 @@ describe('PluginMutationModal', () => { render(<PluginMutationModal {...props} />) - // The Card component should receive installed=true - // This will show a check icon - expect(screen.getByTestId('ri-check-line')).toBeInTheDocument() + expect(document.querySelector('.bg-state-success-solid')).toBeInTheDocument() }) }) @@ -577,8 +528,7 @@ describe('PluginMutationModal', () => { render(<PluginMutationModal {...props} />) - // The check icon should not be present (installed=false) - expect(screen.queryByTestId('ri-check-line')).not.toBeInTheDocument() + expect(document.querySelector('.bg-state-success-solid')).not.toBeInTheDocument() }) }) @@ -593,7 +543,7 @@ describe('PluginMutationModal', () => { expect( screen.queryByRole('button', { name: /Cancel/i }), ).not.toBeInTheDocument() - expect(screen.queryByTestId('ri-check-line')).not.toBeInTheDocument() + expect(document.querySelector('.bg-state-success-solid')).not.toBeInTheDocument() }) it('should handle isPending=false and isSuccess=true', () => { @@ -606,7 +556,7 @@ describe('PluginMutationModal', () => { expect( screen.getByRole('button', { name: /Cancel/i }), ).toBeInTheDocument() - expect(screen.getByTestId('ri-check-line')).toBeInTheDocument() + expect(document.querySelector('.bg-state-success-solid')).toBeInTheDocument() }) it('should handle both isPending=true and isSuccess=true', () => { @@ -619,7 +569,7 @@ describe('PluginMutationModal', () => { expect( screen.queryByRole('button', { name: /Cancel/i }), ).not.toBeInTheDocument() - expect(screen.getByTestId('ri-check-line')).toBeInTheDocument() + expect(document.querySelector('.bg-state-success-solid')).toBeInTheDocument() }) }) }) @@ -710,8 +660,8 @@ describe('PluginMutationModal', () => { it('should have displayName set', () => { // The component sets displayName = 'PluginMutationModal' const displayName - = (PluginMutationModal as any).type?.displayName - || (PluginMutationModal as any).displayName + = (PluginMutationModal as unknown as { type?: { displayName?: string }, displayName?: string }).type?.displayName + || (PluginMutationModal as unknown as { displayName?: string }).displayName expect(displayName).toBe('PluginMutationModal') }) @@ -901,8 +851,7 @@ describe('PluginMutationModal', () => { render(<PluginMutationModal {...props} />) - // Close icon should be present - expect(screen.getByTestId('ri-close-line')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBeInTheDocument() }) }) @@ -1118,8 +1067,7 @@ describe('PluginMutationModal', () => { />, ) - // Should show success state - expect(screen.getByTestId('ri-check-line')).toBeInTheDocument() + expect(document.querySelector('.bg-state-success-solid')).toBeInTheDocument() }) it('should handle plugin prop changes', () => { diff --git a/web/app/components/plugins/plugin-page/context.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/context.spec.tsx similarity index 98% rename from web/app/components/plugins/plugin-page/context.spec.tsx rename to web/app/components/plugins/plugin-page/__tests__/context.spec.tsx index ea52ae1dbd..4dd23f53f1 100644 --- a/web/app/components/plugins/plugin-page/context.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/context.spec.tsx @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' // Import mocks import { useGlobalPublicStore } from '@/context/global-public-context' -import { PluginPageContext, PluginPageContextProvider, usePluginPageContext } from './context' +import { PluginPageContext, PluginPageContextProvider, usePluginPageContext } from '../context' // Mock dependencies vi.mock('nuqs', () => ({ @@ -14,7 +14,7 @@ vi.mock('@/context/global-public-context', () => ({ useGlobalPublicStore: vi.fn(), })) -vi.mock('../hooks', () => ({ +vi.mock('../../hooks', () => ({ PLUGIN_PAGE_TABS_MAP: { plugins: 'plugins', marketplace: 'discover', diff --git a/web/app/components/plugins/plugin-page/index.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-page/index.spec.tsx rename to web/app/components/plugins/plugin-page/__tests__/index.spec.tsx index 9b7ada2a87..be9f0b1858 100644 --- a/web/app/components/plugins/plugin-page/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import type { PluginPageProps } from './index' +import type { PluginPageProps } from '../index' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { useQueryState } from 'nuqs' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { usePluginInstallation } from '@/hooks/use-query-params' // Import mocked modules for assertions import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins' -import PluginPageWithContext from './index' +import PluginPageWithContext from '../index' // Mock external dependencies vi.mock('@/service/plugins', () => ({ @@ -83,15 +83,15 @@ vi.mock('nuqs', () => ({ useQueryState: vi.fn(() => ['plugins', vi.fn()]), })) -vi.mock('./plugin-tasks', () => ({ +vi.mock('../plugin-tasks', () => ({ default: () => <div data-testid="plugin-tasks">PluginTasks</div>, })) -vi.mock('./debug-info', () => ({ +vi.mock('../debug-info', () => ({ default: () => <div data-testid="debug-info">DebugInfo</div>, })) -vi.mock('./install-plugin-dropdown', () => ({ +vi.mock('../install-plugin-dropdown', () => ({ default: ({ onSwitchToMarketplaceTab }: { onSwitchToMarketplaceTab: () => void }) => ( <button data-testid="install-dropdown" onClick={onSwitchToMarketplaceTab}> Install @@ -99,7 +99,7 @@ vi.mock('./install-plugin-dropdown', () => ({ ), })) -vi.mock('../install-plugin/install-from-local-package', () => ({ +vi.mock('../../install-plugin/install-from-local-package', () => ({ default: ({ onClose }: { onClose: () => void }) => ( <div data-testid="install-local-modal"> <button onClick={onClose}>Close</button> @@ -107,7 +107,7 @@ vi.mock('../install-plugin/install-from-local-package', () => ({ ), })) -vi.mock('../install-plugin/install-from-marketplace', () => ({ +vi.mock('../../install-plugin/install-from-marketplace', () => ({ default: ({ onClose }: { onClose: () => void }) => ( <div data-testid="install-marketplace-modal"> <button onClick={onClose}>Close</button> diff --git a/web/app/components/plugins/plugin-page/__tests__/plugin-info.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/plugin-info.spec.tsx new file mode 100644 index 0000000000..e95f4686f8 --- /dev/null +++ b/web/app/components/plugins/plugin-page/__tests__/plugin-info.spec.tsx @@ -0,0 +1,75 @@ +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../../base/modal', () => ({ + default: ({ children, title, isShow }: { children: React.ReactNode, title: string, isShow: boolean }) => ( + isShow + ? ( + <div data-testid="modal"> + <div data-testid="modal-title">{title}</div> + {children} + </div> + ) + : null + ), +})) + +vi.mock('../../base/key-value-item', () => ({ + default: ({ label, value }: { label: string, value: string }) => ( + <div data-testid="key-value-item"> + <span data-testid="kv-label">{label}</span> + <span data-testid="kv-value">{value}</span> + </div> + ), +})) + +vi.mock('../../install-plugin/utils', () => ({ + convertRepoToUrl: (repo: string) => `https://github.com/${repo}`, +})) + +describe('PlugInfo', () => { + let PlugInfo: (typeof import('../plugin-info'))['default'] + + beforeEach(async () => { + vi.clearAllMocks() + const mod = await import('../plugin-info') + PlugInfo = mod.default + }) + + it('should render modal with title', () => { + render(<PlugInfo onHide={vi.fn()} />) + + expect(screen.getByTestId('modal')).toBeInTheDocument() + expect(screen.getByTestId('modal-title')).toHaveTextContent('plugin.pluginInfoModal.title') + }) + + it('should display repository info', () => { + render(<PlugInfo repository="org/plugin" onHide={vi.fn()} />) + + const kvItems = screen.getAllByTestId('key-value-item') + expect(kvItems.length).toBeGreaterThanOrEqual(1) + const values = screen.getAllByTestId('kv-value') + expect(values.some(v => v.textContent?.includes('https://github.com/org/plugin'))).toBe(true) + }) + + it('should display release info', () => { + render(<PlugInfo release="v1.0.0" onHide={vi.fn()} />) + + const values = screen.getAllByTestId('kv-value') + expect(values.some(v => v.textContent === 'v1.0.0')).toBe(true) + }) + + it('should display package name', () => { + render(<PlugInfo packageName="my-plugin.difypkg" onHide={vi.fn()} />) + + const values = screen.getAllByTestId('kv-value') + expect(values.some(v => v.textContent === 'my-plugin.difypkg')).toBe(true) + }) + + it('should not show items for undefined props', () => { + render(<PlugInfo onHide={vi.fn()} />) + + expect(screen.queryAllByTestId('key-value-item')).toHaveLength(0) + }) +}) diff --git a/web/app/components/plugins/plugin-page/use-reference-setting.spec.ts b/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts similarity index 97% rename from web/app/components/plugins/plugin-page/use-reference-setting.spec.ts rename to web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts index 9f64d3fac5..d43e0a7b97 100644 --- a/web/app/components/plugins/plugin-page/use-reference-setting.spec.ts +++ b/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts @@ -5,16 +5,9 @@ import { useAppContext } from '@/context/app-context' import { useGlobalPublicStore } from '@/context/global-public-context' import { useInvalidateReferenceSettings, useMutationReferenceSettings, useReferenceSettings } from '@/service/use-plugins' -import Toast from '../../base/toast' -import { PermissionType } from '../types' -import useReferenceSetting, { useCanInstallPluginFromMarketplace } from './use-reference-setting' - -// Mock dependencies -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import Toast from '../../../base/toast' +import { PermissionType } from '../../types' +import useReferenceSetting, { useCanInstallPluginFromMarketplace } from '../use-reference-setting' vi.mock('@/context/app-context', () => ({ useAppContext: vi.fn(), @@ -30,7 +23,7 @@ vi.mock('@/service/use-plugins', () => ({ useInvalidateReferenceSettings: vi.fn(), })) -vi.mock('../../base/toast', () => ({ +vi.mock('../../../base/toast', () => ({ default: { notify: vi.fn(), }, @@ -235,7 +228,7 @@ describe('useReferenceSetting Hook', () => { expect(mockInvalidate).toHaveBeenCalled() expect(Toast.notify).toHaveBeenCalledWith({ type: 'success', - message: 'api.actionSuccess', + message: 'common.api.actionSuccess', }) }) }) diff --git a/web/app/components/plugins/plugin-page/use-uploader.spec.ts b/web/app/components/plugins/plugin-page/__tests__/use-uploader.spec.ts similarity index 99% rename from web/app/components/plugins/plugin-page/use-uploader.spec.ts rename to web/app/components/plugins/plugin-page/__tests__/use-uploader.spec.ts index fa9463b7c0..3936117ead 100644 --- a/web/app/components/plugins/plugin-page/use-uploader.spec.ts +++ b/web/app/components/plugins/plugin-page/__tests__/use-uploader.spec.ts @@ -1,7 +1,7 @@ import type { RefObject } from 'react' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { useUploader } from './use-uploader' +import { useUploader } from '../use-uploader' describe('useUploader Hook', () => { let mockContainerRef: RefObject<HTMLDivElement | null> diff --git a/web/app/components/plugins/plugin-page/empty/index.spec.tsx b/web/app/components/plugins/plugin-page/empty/__tests__/index.spec.tsx similarity index 86% rename from web/app/components/plugins/plugin-page/empty/index.spec.tsx rename to web/app/components/plugins/plugin-page/empty/__tests__/index.spec.tsx index 51d4af919d..933814eca5 100644 --- a/web/app/components/plugins/plugin-page/empty/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/empty/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import type { FilterState } from '../filter-management' +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' @@ -6,7 +6,7 @@ import { defaultSystemFeatures, InstallationScope } from '@/types/feature' // ==================== Imports (after mocks) ==================== -import Empty from './index' +import Empty from '../index' // ==================== Mock Setup ==================== @@ -15,7 +15,6 @@ const { mockSetActiveTab, mockUseInstalledPluginList, mockState, - stableT, } = vi.hoisted(() => { const state = { filters: { @@ -32,20 +31,16 @@ const { } as Partial<SystemFeatures>, 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) => { +vi.mock('../../context', () => ({ + usePluginPageContext: (selector: (value: Record<string, unknown>) => unknown) => { const contextValue = { filters: mockState.filters, setActiveTab: mockSetActiveTab, @@ -56,7 +51,7 @@ vi.mock('../context', () => ({ // Mock global public store (Zustand store) vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (state: any) => any) => { + useGlobalPublicStore: (selector: (state: Record<string, unknown>) => unknown) => { return selector({ systemFeatures: { ...defaultSystemFeatures, @@ -92,22 +87,10 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', () })) // Mock Line component -vi.mock('../../marketplace/empty/line', () => ({ +vi.mock('../../../marketplace/empty/line', () => ({ default: ({ className }: { className?: string }) => <div data-testid="line-component" className={className} />, })) -// 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 = () => { @@ -191,7 +174,7 @@ describe('Empty Component', () => { await flushEffects() // Assert - expect(screen.getByText('list.noInstalled')).toBeInTheDocument() + expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument() }) it('should display "notFound" text when filters are active with plugins', async () => { @@ -202,19 +185,19 @@ describe('Empty Component', () => { setMockFilters({ categories: ['model'] }) const { rerender } = render(<Empty />) await flushEffects() - expect(screen.getByText('list.notFound')).toBeInTheDocument() + expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() // Test tags filter setMockFilters({ categories: [], tags: ['tag1'] }) rerender(<Empty />) await flushEffects() - expect(screen.getByText('list.notFound')).toBeInTheDocument() + expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() // Test searchQuery filter setMockFilters({ tags: [], searchQuery: 'test query' }) rerender(<Empty />) await flushEffects() - expect(screen.getByText('list.notFound')).toBeInTheDocument() + expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() }) it('should prioritize "noInstalled" over "notFound" when no plugins exist', async () => { @@ -227,7 +210,7 @@ describe('Empty Component', () => { await flushEffects() // Assert - expect(screen.getByText('list.noInstalled')).toBeInTheDocument() + expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument() }) }) @@ -250,15 +233,15 @@ describe('Empty Component', () => { // 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() + expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument() + expect(screen.getByText('plugin.source.github')).toBeInTheDocument() + expect(screen.getByText('plugin.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') + expect(buttonTexts[0]).toContain('plugin.source.marketplace') + expect(buttonTexts[1]).toContain('plugin.source.github') + expect(buttonTexts[2]).toContain('plugin.source.local') }) it('should render only marketplace method when restricted to marketplace only', async () => { @@ -278,9 +261,9 @@ describe('Empty Component', () => { // 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() + expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument() + expect(screen.queryByText('plugin.source.github')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.source.local')).not.toBeInTheDocument() }) it('should render github and local methods when marketplace is disabled', async () => { @@ -300,9 +283,9 @@ describe('Empty Component', () => { // 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() + expect(screen.queryByText('plugin.source.marketplace')).not.toBeInTheDocument() + expect(screen.getByText('plugin.source.github')).toBeInTheDocument() + expect(screen.getByText('plugin.source.local')).toBeInTheDocument() }) it('should render no methods when marketplace disabled and restricted', async () => { @@ -333,7 +316,7 @@ describe('Empty Component', () => { await flushEffects() // Act - fireEvent.click(screen.getByText('source.marketplace')) + fireEvent.click(screen.getByText('plugin.source.marketplace')) // Assert expect(mockSetActiveTab).toHaveBeenCalledWith('discover') @@ -348,7 +331,7 @@ describe('Empty Component', () => { expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument() // Act - open modal - fireEvent.click(screen.getByText('source.github')) + fireEvent.click(screen.getByText('plugin.source.github')) // Assert - modal is open expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() @@ -368,7 +351,7 @@ describe('Empty Component', () => { const clickSpy = vi.spyOn(fileInput, 'click') // Act - fireEvent.click(screen.getByText('source.local')) + fireEvent.click(screen.getByText('plugin.source.local')) // Assert expect(clickSpy).toHaveBeenCalled() @@ -422,13 +405,13 @@ describe('Empty Component', () => { await flushEffects() // Act - Open, close, and reopen GitHub modal - fireEvent.click(screen.getByText('source.github')) + fireEvent.click(screen.getByText('plugin.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')) + fireEvent.click(screen.getByText('plugin.source.github')) expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() }) @@ -480,7 +463,7 @@ describe('Empty Component', () => { render(<Empty />) await flushEffects() expect(screen.getAllByRole('button')).toHaveLength(1) - expect(screen.getByText('source.marketplace')).toBeInTheDocument() + expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument() }) it('should render correct text based on plugin list and filters', async () => { @@ -490,7 +473,7 @@ describe('Empty Component', () => { const { unmount: unmount1 } = render(<Empty />) await flushEffects() - expect(screen.getByText('list.noInstalled')).toBeInTheDocument() + expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument() unmount1() // Test 2: notFound when filters are active with plugins @@ -499,7 +482,7 @@ describe('Empty Component', () => { render(<Empty />) await flushEffects() - expect(screen.getByText('list.notFound')).toBeInTheDocument() + expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() }) }) @@ -529,8 +512,8 @@ describe('Empty Component', () => { 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() + expect((Empty as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') + expect((Empty as unknown as { displayName?: string, type?: { displayName?: string } }).displayName || (Empty as unknown as { type?: { displayName?: string } }).type?.displayName).toBeDefined() }) }) @@ -542,7 +525,7 @@ describe('Empty Component', () => { await flushEffects() // Test GitHub modal onSuccess - fireEvent.click(screen.getByText('source.github')) + fireEvent.click(screen.getByText('plugin.source.github')) fireEvent.click(screen.getByTestId('github-modal-success')) expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() @@ -570,12 +553,12 @@ describe('Empty Component', () => { expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() // Open GitHub modal - only GitHub modal visible - fireEvent.click(screen.getByText('source.github')) + fireEvent.click(screen.getByText('plugin.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')) + fireEvent.click(screen.getByText('plugin.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/__tests__/category-filter.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx new file mode 100644 index 0000000000..6c20bb0b28 --- /dev/null +++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx @@ -0,0 +1,100 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( + <div data-testid="portal" data-open={open}>{children}</div> + ), + PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( + <div data-testid="portal-trigger" onClick={onClick}>{children}</div> + ), + PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => ( + <div data-testid="portal-content">{children}</div> + ), +})) + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), +})) + +const mockCategories = [ + { name: 'tool', label: 'Tool' }, + { name: 'model', label: 'Model' }, + { name: 'extension', label: 'Extension' }, +] + +vi.mock('../../../hooks', () => ({ + useCategories: () => ({ + categories: mockCategories, + categoriesMap: { + tool: { label: 'Tool' }, + model: { label: 'Model' }, + extension: { label: 'Extension' }, + }, + }), +})) + +describe('CategoriesFilter', () => { + let CategoriesFilter: (typeof import('../category-filter'))['default'] + + beforeEach(async () => { + vi.clearAllMocks() + const mod = await import('../category-filter') + CategoriesFilter = mod.default + }) + + it('should show "allCategories" when no categories selected', () => { + render(<CategoriesFilter value={[]} onChange={vi.fn()} />) + + expect(screen.getByText('plugin.allCategories')).toBeInTheDocument() + }) + + it('should show selected category labels', () => { + render(<CategoriesFilter value={['tool']} onChange={vi.fn()} />) + + const toolElements = screen.getAllByText('Tool') + expect(toolElements.length).toBeGreaterThanOrEqual(1) + }) + + it('should show +N when more than 2 selected', () => { + render(<CategoriesFilter value={['tool', 'model', 'extension']} onChange={vi.fn()} />) + + expect(screen.getByText('+1')).toBeInTheDocument() + }) + + it('should clear all selections when clear button clicked', () => { + const mockOnChange = vi.fn() + render(<CategoriesFilter value={['tool']} onChange={mockOnChange} />) + + const trigger = screen.getByTestId('portal-trigger') + const clearSvg = trigger.querySelector('svg') + fireEvent.click(clearSvg!) + expect(mockOnChange).toHaveBeenCalledWith([]) + }) + + it('should render category options in dropdown', () => { + render(<CategoriesFilter value={[]} onChange={vi.fn()} />) + + expect(screen.getByText('Tool')).toBeInTheDocument() + expect(screen.getByText('Model')).toBeInTheDocument() + expect(screen.getByText('Extension')).toBeInTheDocument() + }) + + it('should toggle category on option click', () => { + const mockOnChange = vi.fn() + render(<CategoriesFilter value={[]} onChange={mockOnChange} />) + + fireEvent.click(screen.getByText('Tool')) + expect(mockOnChange).toHaveBeenCalledWith(['tool']) + }) + + it('should remove category when clicking already selected', () => { + const mockOnChange = vi.fn() + render(<CategoriesFilter value={['tool']} onChange={mockOnChange} />) + + const toolElements = screen.getAllByText('Tool') + fireEvent.click(toolElements[toolElements.length - 1]) + expect(mockOnChange).toHaveBeenCalledWith([]) + }) +}) diff --git a/web/app/components/plugins/plugin-page/filter-management/index.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/plugins/plugin-page/filter-management/index.spec.tsx rename to web/app/components/plugins/plugin-page/filter-management/__tests__/index.spec.tsx index b942a360b0..95f0c5c120 100644 --- a/web/app/components/plugins/plugin-page/filter-management/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/index.spec.tsx @@ -1,16 +1,16 @@ -import type { Category, Tag } from './constant' -import type { FilterState } from './index' +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 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' +import FilterManagement from '../index' +import SearchBox from '../search-box' +import { useStore } from '../store' +import TagFilter from '../tag-filter' // ==================== Mock Setup ==================== @@ -21,7 +21,7 @@ let mockInitFilters: FilterState = { searchQuery: '', } -vi.mock('../context', () => ({ +vi.mock('../../context', () => ({ usePluginPageContext: (selector: (v: { filters: FilterState }) => FilterState) => selector({ filters: mockInitFilters }), })) @@ -56,7 +56,7 @@ const mockTagsMap: Record<string, { name: string, label: string }> = { image: { name: 'image', label: 'Image' }, } -vi.mock('../../hooks', () => ({ +vi.mock('../../../hooks', () => ({ useCategories: () => ({ categories: mockCategories, categoriesMap: mockCategoriesMap, diff --git a/web/app/components/plugins/plugin-page/filter-management/__tests__/search-box.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/__tests__/search-box.spec.tsx new file mode 100644 index 0000000000..26736227d5 --- /dev/null +++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/search-box.spec.tsx @@ -0,0 +1,32 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('SearchBox', () => { + let SearchBox: (typeof import('../search-box'))['default'] + + beforeEach(async () => { + vi.clearAllMocks() + const mod = await import('../search-box') + SearchBox = mod.default + }) + + it('should render input with placeholder', () => { + render(<SearchBox searchQuery="" onChange={vi.fn()} />) + + expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', 'plugin.search') + }) + + it('should display current search query', () => { + render(<SearchBox searchQuery="test query" onChange={vi.fn()} />) + + expect(screen.getByRole('textbox')).toHaveValue('test query') + }) + + it('should call onChange when input changes', () => { + const mockOnChange = vi.fn() + render(<SearchBox searchQuery="" onChange={mockOnChange} />) + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new query' } }) + expect(mockOnChange).toHaveBeenCalledWith('new query') + }) +}) diff --git a/web/app/components/plugins/plugin-page/filter-management/__tests__/store.spec.ts b/web/app/components/plugins/plugin-page/filter-management/__tests__/store.spec.ts new file mode 100644 index 0000000000..26316e78e8 --- /dev/null +++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/store.spec.ts @@ -0,0 +1,85 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import { useStore } from '../store' + +describe('filter-management store', () => { + beforeEach(() => { + // Reset store to default state + const { result } = renderHook(() => useStore()) + act(() => { + result.current.setTagList([]) + result.current.setCategoryList([]) + result.current.setShowTagManagementModal(false) + result.current.setShowCategoryManagementModal(false) + }) + }) + + it('should initialize with default values', () => { + const { result } = renderHook(() => useStore()) + + expect(result.current.tagList).toEqual([]) + expect(result.current.categoryList).toEqual([]) + expect(result.current.showTagManagementModal).toBe(false) + expect(result.current.showCategoryManagementModal).toBe(false) + }) + + it('should set tag list', () => { + const { result } = renderHook(() => useStore()) + const tags = [{ name: 'tag1', label: { en_US: 'Tag 1' } }] + + act(() => { + result.current.setTagList(tags as never[]) + }) + + expect(result.current.tagList).toEqual(tags) + }) + + it('should set category list', () => { + const { result } = renderHook(() => useStore()) + const categories = [{ name: 'cat1', label: { en_US: 'Cat 1' } }] + + act(() => { + result.current.setCategoryList(categories as never[]) + }) + + expect(result.current.categoryList).toEqual(categories) + }) + + it('should toggle tag management modal', () => { + const { result } = renderHook(() => useStore()) + + act(() => { + result.current.setShowTagManagementModal(true) + }) + expect(result.current.showTagManagementModal).toBe(true) + + act(() => { + result.current.setShowTagManagementModal(false) + }) + expect(result.current.showTagManagementModal).toBe(false) + }) + + it('should toggle category management modal', () => { + const { result } = renderHook(() => useStore()) + + act(() => { + result.current.setShowCategoryManagementModal(true) + }) + expect(result.current.showCategoryManagementModal).toBe(true) + + act(() => { + result.current.setShowCategoryManagementModal(false) + }) + expect(result.current.showCategoryManagementModal).toBe(false) + }) + + it('should handle undefined tag list', () => { + const { result } = renderHook(() => useStore()) + + act(() => { + result.current.setTagList(undefined) + }) + + expect(result.current.tagList).toBeUndefined() + }) +}) diff --git a/web/app/components/plugins/plugin-page/list/index.spec.tsx b/web/app/components/plugins/plugin-page/list/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/plugins/plugin-page/list/index.spec.tsx rename to web/app/components/plugins/plugin-page/list/__tests__/index.spec.tsx index 7709585e8e..c6326461d4 100644 --- a/web/app/components/plugins/plugin-page/list/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/list/__tests__/index.spec.tsx @@ -1,16 +1,16 @@ -import type { PluginDeclaration, PluginDetail } from '../../types' +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' +import { PluginCategoryEnum, PluginSource } from '../../../types' // ==================== Imports (after mocks) ==================== -import PluginList from './index' +import PluginList from '../index' // ==================== Mock Setup ==================== // Mock PluginItem component to avoid complex dependency chain -vi.mock('../../plugin-item', () => ({ +vi.mock('../../../plugin-item', () => ({ default: ({ plugin }: { plugin: PluginDetail }) => ( <div data-testid="plugin-item" @@ -35,13 +35,13 @@ const createPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): Pl 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, + label: { en_US: 'Test Plugin' } as unknown as PluginDeclaration['label'], + description: { en_US: 'Test plugin description' } as unknown as PluginDeclaration['description'], created_at: '2024-01-01', resource: null, plugins: null, verified: false, - endpoint: {} as any, + endpoint: {} as unknown as PluginDeclaration['endpoint'], model: null, tags: [], agent_strategy: null, @@ -49,7 +49,7 @@ const createPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): Pl version: '1.0.0', minimum_dify_version: '0.5.0', }, - trigger: {} as any, + trigger: {} as unknown as PluginDeclaration['trigger'], ...overrides, }) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/hooks.spec.ts b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/hooks.spec.ts new file mode 100644 index 0000000000..3d5269593d --- /dev/null +++ b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/hooks.spec.ts @@ -0,0 +1,77 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TaskStatus } from '@/app/components/plugins/types' +import { usePluginTaskStatus } from '../hooks' + +const mockClearTask = vi.fn().mockResolvedValue({}) +const mockRefetch = vi.fn() + +vi.mock('@/service/use-plugins', () => ({ + usePluginTaskList: () => ({ + pluginTasks: [ + { + id: 'task-1', + plugins: [ + { id: 'plugin-1', status: TaskStatus.success, taskId: 'task-1' }, + { id: 'plugin-2', status: TaskStatus.running, taskId: 'task-1' }, + ], + }, + { + id: 'task-2', + plugins: [ + { id: 'plugin-3', status: TaskStatus.failed, taskId: 'task-2' }, + ], + }, + ], + handleRefetch: mockRefetch, + }), + useMutationClearTaskPlugin: () => ({ + mutateAsync: mockClearTask, + }), +})) + +describe('usePluginTaskStatus', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should categorize plugins by status', () => { + const { result } = renderHook(() => usePluginTaskStatus()) + + expect(result.current.successPlugins).toHaveLength(1) + expect(result.current.runningPlugins).toHaveLength(1) + expect(result.current.errorPlugins).toHaveLength(1) + }) + + it('should compute correct length values', () => { + const { result } = renderHook(() => usePluginTaskStatus()) + + expect(result.current.totalPluginsLength).toBe(3) + expect(result.current.runningPluginsLength).toBe(1) + expect(result.current.errorPluginsLength).toBe(1) + expect(result.current.successPluginsLength).toBe(1) + }) + + it('should detect isInstallingWithError state', () => { + const { result } = renderHook(() => usePluginTaskStatus()) + + // running > 0 && error > 0 + expect(result.current.isInstallingWithError).toBe(true) + expect(result.current.isInstalling).toBe(false) + expect(result.current.isInstallingWithSuccess).toBe(false) + expect(result.current.isSuccess).toBe(false) + expect(result.current.isFailed).toBe(false) + }) + + it('should handle clear error plugin', async () => { + const { result } = renderHook(() => usePluginTaskStatus()) + + await result.current.handleClearErrorPlugin('task-2', 'plugin-3') + + expect(mockClearTask).toHaveBeenCalledWith({ + taskId: 'task-2', + pluginId: 'plugin-3', + }) + expect(mockRefetch).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/index.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/plugins/plugin-page/plugin-tasks/index.spec.tsx rename to web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx index 32892cbe28..85db106646 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx @@ -4,11 +4,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { TaskStatus } from '@/app/components/plugins/types' // Import mocked modules import { useMutationClearTaskPlugin, usePluginTaskList } from '@/service/use-plugins' -import PluginTaskList from './components/plugin-task-list' -import TaskStatusIndicator from './components/task-status-indicator' -import { usePluginTaskStatus } from './hooks' +import PluginTaskList from '../components/plugin-task-list' +import TaskStatusIndicator from '../components/task-status-indicator' +import { usePluginTaskStatus } from '../hooks' -import PluginTasks from './index' +import PluginTasks from '../index' // Mock external dependencies vi.mock('@/service/use-plugins', () => ({ @@ -51,18 +51,15 @@ const setupMocks = (plugins: PluginStatus[] = []) => { ? [{ id: 'task-1', plugins, created_at: '', updated_at: '', status: 'running', total_plugins: plugins.length, completed_plugins: 0 }] : [], handleRefetch: mockHandleRefetch, - } as any) + } as unknown as ReturnType<typeof usePluginTaskList>) vi.mocked(useMutationClearTaskPlugin).mockReturnValue({ mutateAsync: mockMutateAsync, - } as any) + } as unknown as ReturnType<typeof useMutationClearTaskPlugin>) return { mockMutateAsync, mockHandleRefetch } } -// ============================================================================ -// usePluginTaskStatus Hook Tests -// ============================================================================ describe('usePluginTaskStatus Hook', () => { beforeEach(() => { vi.clearAllMocks() @@ -413,9 +410,6 @@ describe('TaskStatusIndicator Component', () => { }) }) -// ============================================================================ -// PluginTaskList Component Tests -// ============================================================================ describe('PluginTaskList Component', () => { const defaultProps = { runningPlugins: [] as PluginStatus[], diff --git a/web/app/components/plugins/readme-panel/__tests__/constants.spec.ts b/web/app/components/plugins/readme-panel/__tests__/constants.spec.ts new file mode 100644 index 0000000000..372211cc77 --- /dev/null +++ b/web/app/components/plugins/readme-panel/__tests__/constants.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { BUILTIN_TOOLS_ARRAY } from '../constants' + +describe('BUILTIN_TOOLS_ARRAY', () => { + it('should contain expected builtin tools', () => { + expect(BUILTIN_TOOLS_ARRAY).toContain('code') + expect(BUILTIN_TOOLS_ARRAY).toContain('audio') + expect(BUILTIN_TOOLS_ARRAY).toContain('time') + expect(BUILTIN_TOOLS_ARRAY).toContain('webscraper') + }) + + it('should have exactly 4 builtin tools', () => { + expect(BUILTIN_TOOLS_ARRAY).toHaveLength(4) + }) + + it('should be an array of strings', () => { + for (const tool of BUILTIN_TOOLS_ARRAY) + expect(typeof tool).toBe('string') + }) +}) diff --git a/web/app/components/plugins/readme-panel/__tests__/entrance.spec.tsx b/web/app/components/plugins/readme-panel/__tests__/entrance.spec.tsx new file mode 100644 index 0000000000..f1e3c548de --- /dev/null +++ b/web/app/components/plugins/readme-panel/__tests__/entrance.spec.tsx @@ -0,0 +1,67 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), +})) + +const mockSetCurrentPluginDetail = vi.fn() + +vi.mock('../store', () => ({ + ReadmeShowType: { drawer: 'drawer', side: 'side', modal: 'modal' }, + useReadmePanelStore: () => ({ + setCurrentPluginDetail: mockSetCurrentPluginDetail, + }), +})) + +vi.mock('../constants', () => ({ + BUILTIN_TOOLS_ARRAY: ['google_search', 'bing_search'], +})) + +describe('ReadmeEntrance', () => { + let ReadmeEntrance: (typeof import('../entrance'))['ReadmeEntrance'] + + beforeEach(async () => { + vi.clearAllMocks() + const mod = await import('../entrance') + ReadmeEntrance = mod.ReadmeEntrance + }) + + it('should render readme button for non-builtin plugin with unique identifier', () => { + const pluginDetail = { id: 'custom-plugin', name: 'custom-plugin', plugin_unique_identifier: 'org/custom-plugin' } as never + render(<ReadmeEntrance pluginDetail={pluginDetail} />) + + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should call setCurrentPluginDetail on button click', () => { + const pluginDetail = { id: 'custom-plugin', name: 'custom-plugin', plugin_unique_identifier: 'org/custom-plugin' } as never + render(<ReadmeEntrance pluginDetail={pluginDetail} />) + + const button = screen.getByRole('button') + fireEvent.click(button) + + expect(mockSetCurrentPluginDetail).toHaveBeenCalledWith(pluginDetail, 'drawer') + }) + + it('should return null for builtin tools', () => { + const pluginDetail = { id: 'google_search', name: 'Google Search', plugin_unique_identifier: 'org/google' } as never + const { container } = render(<ReadmeEntrance pluginDetail={pluginDetail} />) + + expect(container.innerHTML).toBe('') + }) + + it('should return null when plugin_unique_identifier is missing', () => { + const pluginDetail = { id: 'some-plugin', name: 'Some Plugin' } as never + const { container } = render(<ReadmeEntrance pluginDetail={pluginDetail} />) + + expect(container.innerHTML).toBe('') + }) + + it('should return null when pluginDetail is null', () => { + const { container } = render(<ReadmeEntrance pluginDetail={null as never} />) + + expect(container.innerHTML).toBe('') + }) +}) diff --git a/web/app/components/plugins/readme-panel/index.spec.tsx b/web/app/components/plugins/readme-panel/__tests__/index.spec.tsx similarity index 67% rename from web/app/components/plugins/readme-panel/index.spec.tsx rename to web/app/components/plugins/readme-panel/__tests__/index.spec.tsx index 340fe0abcd..d52a22cb61 100644 --- a/web/app/components/plugins/readme-panel/index.spec.tsx +++ b/web/app/components/plugins/readme-panel/__tests__/index.spec.tsx @@ -1,12 +1,11 @@ -import type { PluginDetail } from '../types' +import type { PluginDetail } from '../../types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum, PluginSource } from '../types' -import { BUILTIN_TOOLS_ARRAY } from './constants' -import { ReadmeEntrance } from './entrance' -import ReadmePanel from './index' -import { ReadmeShowType, useReadmePanelStore } from './store' +import { PluginCategoryEnum, PluginSource } from '../../types' +import { ReadmeEntrance } from '../entrance' +import ReadmePanel from '../index' +import { ReadmeShowType, useReadmePanelStore } from '../store' // ================================ // Mock external dependencies only @@ -25,7 +24,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) // Mock DetailHeader component (complex component with many dependencies) -vi.mock('../plugin-detail-panel/detail-header', () => ({ +vi.mock('../../plugin-detail-panel/detail-header', () => ({ default: ({ detail, isReadmeView }: { detail: PluginDetail, isReadmeView: boolean }) => ( <div data-testid="detail-header" data-is-readme-view={isReadmeView}> {detail.name} @@ -115,289 +114,9 @@ const renderWithQueryClient = (ui: React.ReactElement) => { ) } -// ================================ -// Constants Tests -// ================================ -describe('BUILTIN_TOOLS_ARRAY', () => { - it('should contain expected builtin tools', () => { - expect(BUILTIN_TOOLS_ARRAY).toContain('code') - expect(BUILTIN_TOOLS_ARRAY).toContain('audio') - expect(BUILTIN_TOOLS_ARRAY).toContain('time') - expect(BUILTIN_TOOLS_ARRAY).toContain('webscraper') - }) - - it('should have exactly 4 builtin tools', () => { - expect(BUILTIN_TOOLS_ARRAY).toHaveLength(4) - }) -}) - -// ================================ -// Store Tests -// ================================ -describe('useReadmePanelStore', () => { - describe('Initial State', () => { - it('should have undefined currentPluginDetail initially', () => { - const { currentPluginDetail } = useReadmePanelStore.getState() - expect(currentPluginDetail).toBeUndefined() - }) - }) - - describe('setCurrentPluginDetail', () => { - it('should set currentPluginDetail with detail and default showType', () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - - act(() => { - setCurrentPluginDetail(mockDetail) - }) - - const { currentPluginDetail } = useReadmePanelStore.getState() - expect(currentPluginDetail).toEqual({ - detail: mockDetail, - showType: ReadmeShowType.drawer, - }) - }) - - it('should set currentPluginDetail with custom showType', () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - - act(() => { - setCurrentPluginDetail(mockDetail, ReadmeShowType.modal) - }) - - const { currentPluginDetail } = useReadmePanelStore.getState() - expect(currentPluginDetail).toEqual({ - detail: mockDetail, - showType: ReadmeShowType.modal, - }) - }) - - it('should clear currentPluginDetail when called without arguments', () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - - // First set a detail - act(() => { - setCurrentPluginDetail(mockDetail) - }) - - // Then clear it - act(() => { - setCurrentPluginDetail() - }) - - const { currentPluginDetail } = useReadmePanelStore.getState() - expect(currentPluginDetail).toBeUndefined() - }) - - it('should clear currentPluginDetail when called with undefined', () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - - // First set a detail - act(() => { - setCurrentPluginDetail(mockDetail) - }) - - // Then clear it with explicit undefined - act(() => { - setCurrentPluginDetail(undefined) - }) - - const { currentPluginDetail } = useReadmePanelStore.getState() - expect(currentPluginDetail).toBeUndefined() - }) - }) - - describe('ReadmeShowType enum', () => { - it('should have drawer and modal types', () => { - expect(ReadmeShowType.drawer).toBe('drawer') - expect(ReadmeShowType.modal).toBe('modal') - }) - }) -}) - -// ================================ -// ReadmeEntrance Component Tests -// ================================ -describe('ReadmeEntrance', () => { - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render the entrance button with full tip text', () => { - const mockDetail = createMockPluginDetail() - - render(<ReadmeEntrance pluginDetail={mockDetail} />) - - expect(screen.getByRole('button')).toBeInTheDocument() - expect(screen.getByText('plugin.readmeInfo.needHelpCheckReadme')).toBeInTheDocument() - }) - - it('should render with short tip text when showShortTip is true', () => { - const mockDetail = createMockPluginDetail() - - render(<ReadmeEntrance pluginDetail={mockDetail} showShortTip />) - - expect(screen.getByText('plugin.readmeInfo.title')).toBeInTheDocument() - }) - - it('should render divider when showShortTip is false', () => { - const mockDetail = createMockPluginDetail() - - const { container } = render(<ReadmeEntrance pluginDetail={mockDetail} showShortTip={false} />) - - expect(container.querySelector('.bg-divider-regular')).toBeInTheDocument() - }) - - it('should not render divider when showShortTip is true', () => { - const mockDetail = createMockPluginDetail() - - const { container } = render(<ReadmeEntrance pluginDetail={mockDetail} showShortTip />) - - expect(container.querySelector('.bg-divider-regular')).not.toBeInTheDocument() - }) - - it('should apply drawer mode padding class', () => { - const mockDetail = createMockPluginDetail() - - const { container } = render( - <ReadmeEntrance pluginDetail={mockDetail} showType={ReadmeShowType.drawer} />, - ) - - expect(container.querySelector('.px-4')).toBeInTheDocument() - }) - - it('should apply custom className', () => { - const mockDetail = createMockPluginDetail() - - const { container } = render( - <ReadmeEntrance pluginDetail={mockDetail} className="custom-class" />, - ) - - expect(container.querySelector('.custom-class')).toBeInTheDocument() - }) - }) - - // ================================ - // Conditional Rendering / Edge Cases - // ================================ - describe('Conditional Rendering', () => { - it('should return null when pluginDetail is null/undefined', () => { - const { container } = render(<ReadmeEntrance pluginDetail={null as unknown as PluginDetail} />) - - expect(container.firstChild).toBeNull() - }) - - it('should return null when plugin_unique_identifier is missing', () => { - const mockDetail = createMockPluginDetail({ plugin_unique_identifier: '' }) - - const { container } = render(<ReadmeEntrance pluginDetail={mockDetail} />) - - expect(container.firstChild).toBeNull() - }) - - it('should return null for builtin tool: code', () => { - const mockDetail = createMockPluginDetail({ id: 'code' }) - - const { container } = render(<ReadmeEntrance pluginDetail={mockDetail} />) - - expect(container.firstChild).toBeNull() - }) - - it('should return null for builtin tool: audio', () => { - const mockDetail = createMockPluginDetail({ id: 'audio' }) - - const { container } = render(<ReadmeEntrance pluginDetail={mockDetail} />) - - expect(container.firstChild).toBeNull() - }) - - it('should return null for builtin tool: time', () => { - const mockDetail = createMockPluginDetail({ id: 'time' }) - - const { container } = render(<ReadmeEntrance pluginDetail={mockDetail} />) - - expect(container.firstChild).toBeNull() - }) - - it('should return null for builtin tool: webscraper', () => { - const mockDetail = createMockPluginDetail({ id: 'webscraper' }) - - const { container } = render(<ReadmeEntrance pluginDetail={mockDetail} />) - - expect(container.firstChild).toBeNull() - }) - - it('should render for non-builtin plugins', () => { - const mockDetail = createMockPluginDetail({ id: 'custom-plugin' }) - - render(<ReadmeEntrance pluginDetail={mockDetail} />) - - expect(screen.getByRole('button')).toBeInTheDocument() - }) - }) - - // ================================ - // User Interactions / Event Handlers - // ================================ - describe('User Interactions', () => { - it('should call setCurrentPluginDetail with drawer type when clicked', () => { - const mockDetail = createMockPluginDetail() - - render(<ReadmeEntrance pluginDetail={mockDetail} />) - - fireEvent.click(screen.getByRole('button')) - - const { currentPluginDetail } = useReadmePanelStore.getState() - expect(currentPluginDetail).toEqual({ - detail: mockDetail, - showType: ReadmeShowType.drawer, - }) - }) - - it('should call setCurrentPluginDetail with modal type when clicked', () => { - const mockDetail = createMockPluginDetail() - - render(<ReadmeEntrance pluginDetail={mockDetail} showType={ReadmeShowType.modal} />) - - fireEvent.click(screen.getByRole('button')) - - const { currentPluginDetail } = useReadmePanelStore.getState() - expect(currentPluginDetail).toEqual({ - detail: mockDetail, - showType: ReadmeShowType.modal, - }) - }) - }) - - // ================================ - // Prop Variations - // ================================ - describe('Prop Variations', () => { - it('should use default showType when not provided', () => { - const mockDetail = createMockPluginDetail() - - render(<ReadmeEntrance pluginDetail={mockDetail} />) - - fireEvent.click(screen.getByRole('button')) - - const { currentPluginDetail } = useReadmePanelStore.getState() - expect(currentPluginDetail?.showType).toBe(ReadmeShowType.drawer) - }) - - it('should handle modal showType correctly', () => { - const mockDetail = createMockPluginDetail() - - render(<ReadmeEntrance pluginDetail={mockDetail} showType={ReadmeShowType.modal} />) - - // Modal mode should not have px-4 class - const container = screen.getByRole('button').parentElement - expect(container).not.toHaveClass('px-4') - }) - }) -}) +// Constants (BUILTIN_TOOLS_ARRAY) tests moved to constants.spec.ts +// Store (useReadmePanelStore) tests moved to store.spec.ts +// Entrance (ReadmeEntrance) tests moved to entrance.spec.tsx // ================================ // ReadmePanel Component Tests diff --git a/web/app/components/plugins/readme-panel/__tests__/store.spec.ts b/web/app/components/plugins/readme-panel/__tests__/store.spec.ts new file mode 100644 index 0000000000..a349659f42 --- /dev/null +++ b/web/app/components/plugins/readme-panel/__tests__/store.spec.ts @@ -0,0 +1,54 @@ +import type { PluginDetail } from '@/app/components/plugins/types' +import { beforeEach, describe, expect, it } from 'vitest' +import { ReadmeShowType, useReadmePanelStore } from '../store' + +describe('readme-panel/store', () => { + beforeEach(() => { + useReadmePanelStore.setState({ currentPluginDetail: undefined }) + }) + + it('initializes with undefined currentPluginDetail', () => { + const state = useReadmePanelStore.getState() + expect(state.currentPluginDetail).toBeUndefined() + }) + + it('sets current plugin detail with drawer showType by default', () => { + const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail + useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail) + + const state = useReadmePanelStore.getState() + expect(state.currentPluginDetail).toEqual({ + detail: mockDetail, + showType: ReadmeShowType.drawer, + }) + }) + + it('sets current plugin detail with modal showType', () => { + const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail + useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail, ReadmeShowType.modal) + + const state = useReadmePanelStore.getState() + expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.modal) + }) + + it('clears current plugin detail when called with undefined', () => { + const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail + useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail) + expect(useReadmePanelStore.getState().currentPluginDetail).toBeDefined() + + useReadmePanelStore.getState().setCurrentPluginDetail(undefined) + expect(useReadmePanelStore.getState().currentPluginDetail).toBeUndefined() + }) + + it('replaces previous detail with new one', () => { + const detail1 = { id: 'plugin-1', plugin_unique_identifier: 'uid-1' } as PluginDetail + const detail2 = { id: 'plugin-2', plugin_unique_identifier: 'uid-2' } as PluginDetail + + useReadmePanelStore.getState().setCurrentPluginDetail(detail1) + expect(useReadmePanelStore.getState().currentPluginDetail?.detail.id).toBe('plugin-1') + + useReadmePanelStore.getState().setCurrentPluginDetail(detail2, ReadmeShowType.modal) + expect(useReadmePanelStore.getState().currentPluginDetail?.detail.id).toBe('plugin-2') + expect(useReadmePanelStore.getState().currentPluginDetail?.showType).toBe(ReadmeShowType.modal) + }) +}) diff --git a/web/app/components/plugins/reference-setting-modal/index.spec.tsx b/web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx similarity index 75% rename from web/app/components/plugins/reference-setting-modal/index.spec.tsx rename to web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx index 43056b4e86..91986b4b35 100644 --- a/web/app/components/plugins/reference-setting-modal/index.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx @@ -1,37 +1,11 @@ -import type { AutoUpdateConfig } from './auto-update-setting/types' +import type { AutoUpdateConfig } from '../auto-update-setting/types' import type { Permissions, ReferenceSetting } from '@/app/components/plugins/types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PermissionType } from '@/app/components/plugins/types' -import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from './auto-update-setting/types' -import ReferenceSettingModal from './index' -import Label from './label' - -// ================================ -// Mock External Dependencies Only -// ================================ - -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => { - const translations: Record<string, string> = { - 'privilege.title': 'Plugin Permissions', - 'privilege.whoCanInstall': 'Who can install plugins', - 'privilege.whoCanDebug': 'Who can debug plugins', - 'privilege.everyone': 'Everyone', - 'privilege.admins': 'Admins Only', - 'privilege.noone': 'No One', - 'operation.cancel': 'Cancel', - 'operation.save': 'Save', - 'autoUpdate.updateSettings': 'Update Settings', - } - const fullKey = options?.ns ? `${options.ns}.${key}` : key - return translations[fullKey] || translations[key] || key - }, - }), -})) +import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from '../auto-update-setting/types' +import ReferenceSettingModal from '../index' // Mock global public store const mockSystemFeatures = { enable_marketplace: true } @@ -86,7 +60,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/option-card', () => ({ // Mock AutoUpdateSetting component const mockAutoUpdateSettingOnChange = vi.fn() -vi.mock('./auto-update-setting', () => ({ +vi.mock('../auto-update-setting', () => ({ default: ({ payload, onChange }: { payload: AutoUpdateConfig onChange: (payload: AutoUpdateConfig) => void @@ -111,7 +85,7 @@ vi.mock('./auto-update-setting', () => ({ })) // Mock config default value -vi.mock('./auto-update-setting/config', () => ({ +vi.mock('../auto-update-setting/config', () => ({ defaultValue: { strategy_setting: AUTO_UPDATE_STRATEGY.disabled, upgrade_time_of_day: 0, @@ -156,153 +130,7 @@ describe('reference-setting-modal', () => { mockSystemFeatures.enable_marketplace = true }) - // ============================================================ - // Label Component Tests - // ============================================================ - describe('Label (label.tsx)', () => { - describe('Rendering', () => { - it('should render label text', () => { - // Arrange & Act - render(<Label label="Test Label" />) - - // Assert - expect(screen.getByText('Test Label')).toBeInTheDocument() - }) - - it('should render with label only when no description provided', () => { - // Arrange & Act - const { container } = render(<Label label="Simple Label" />) - - // Assert - expect(screen.getByText('Simple Label')).toBeInTheDocument() - // Should have h-6 class when no description - expect(container.querySelector('.h-6')).toBeInTheDocument() - }) - - it('should render label and description when both provided', () => { - // Arrange & Act - render(<Label label="Label Text" description="Description Text" />) - - // Assert - expect(screen.getByText('Label Text')).toBeInTheDocument() - expect(screen.getByText('Description Text')).toBeInTheDocument() - }) - - it('should apply h-4 class to label container when description is provided', () => { - // Arrange & Act - const { container } = render(<Label label="Label" description="Has description" />) - - // Assert - expect(container.querySelector('.h-4')).toBeInTheDocument() - }) - - it('should not render description element when description is undefined', () => { - // Arrange & Act - const { container } = render(<Label label="Only Label" />) - - // Assert - expect(container.querySelectorAll('.body-xs-regular')).toHaveLength(0) - }) - - it('should render description with correct styling', () => { - // Arrange & Act - const { container } = render(<Label label="Label" description="Styled Description" />) - - // Assert - const descriptionElement = container.querySelector('.body-xs-regular') - expect(descriptionElement).toBeInTheDocument() - expect(descriptionElement).toHaveClass('mt-1', 'text-text-tertiary') - }) - }) - - describe('Props Variations', () => { - it('should handle empty label string', () => { - // Arrange & Act - const { container } = render(<Label label="" />) - - // Assert - should render without crashing - expect(container.firstChild).toBeInTheDocument() - }) - - it('should handle empty description string', () => { - // Arrange & Act - render(<Label label="Label" description="" />) - - // Assert - empty description still renders the description container - expect(screen.getByText('Label')).toBeInTheDocument() - }) - - it('should handle long label text', () => { - // Arrange - const longLabel = 'A'.repeat(200) - - // Act - render(<Label label={longLabel} />) - - // Assert - expect(screen.getByText(longLabel)).toBeInTheDocument() - }) - - it('should handle long description text', () => { - // Arrange - const longDescription = 'B'.repeat(500) - - // Act - render(<Label label="Label" description={longDescription} />) - - // Assert - expect(screen.getByText(longDescription)).toBeInTheDocument() - }) - - it('should handle special characters in label', () => { - // Arrange - const specialLabel = '<script>alert("xss")</script>' - - // Act - render(<Label label={specialLabel} />) - - // Assert - should be escaped - expect(screen.getByText(specialLabel)).toBeInTheDocument() - }) - - it('should handle special characters in description', () => { - // Arrange - const specialDescription = '!@#$%^&*()_+-=[]{}|;:,.<>?' - - // Act - render(<Label label="Label" description={specialDescription} />) - - // Assert - expect(screen.getByText(specialDescription)).toBeInTheDocument() - }) - }) - - describe('Component Memoization', () => { - it('should be memoized with React.memo', () => { - // Assert - expect(Label).toBeDefined() - expect((Label as any).$$typeof?.toString()).toContain('Symbol') - }) - }) - - describe('Styling', () => { - it('should apply system-sm-semibold class to label', () => { - // Arrange & Act - const { container } = render(<Label label="Styled Label" />) - - // Assert - expect(container.querySelector('.system-sm-semibold')).toBeInTheDocument() - }) - - it('should apply text-text-secondary class to label', () => { - // Arrange & Act - const { container } = render(<Label label="Styled Label" />) - - // Assert - expect(container.querySelector('.text-text-secondary')).toBeInTheDocument() - }) - }) - }) + // Label component tests moved to label.spec.tsx // ============================================================ // ReferenceSettingModal (PluginSettingModal) Component Tests @@ -320,7 +148,7 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} />) // Assert - expect(screen.getByText('Plugin Permissions')).toBeInTheDocument() + expect(screen.getByText('plugin.privilege.title')).toBeInTheDocument() }) it('should render install permission section', () => { @@ -328,7 +156,7 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} />) // Assert - expect(screen.getByText('Who can install plugins')).toBeInTheDocument() + expect(screen.getByText('plugin.privilege.whoCanInstall')).toBeInTheDocument() }) it('should render debug permission section', () => { @@ -336,7 +164,7 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} />) // Assert - expect(screen.getByText('Who can debug plugins')).toBeInTheDocument() + expect(screen.getByText('plugin.privilege.whoCanDebug')).toBeInTheDocument() }) it('should render all permission options for install', () => { @@ -352,8 +180,8 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} />) // Assert - expect(screen.getByText('Cancel')).toBeInTheDocument() - expect(screen.getByText('Save')).toBeInTheDocument() + expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() + expect(screen.getByText('common.operation.save')).toBeInTheDocument() }) it('should render AutoUpdateSetting when marketplace is enabled', () => { @@ -401,11 +229,11 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} payload={payload} />) // Assert - admin option should be selected for install (first one) - const adminOptions = screen.getAllByTestId('option-card-admins-only') + const adminOptions = screen.getAllByTestId('option-card-plugin.privilege.admins') expect(adminOptions[0]).toHaveAttribute('aria-pressed', 'true') // Install permission // Assert - noOne option should be selected for debug (second one) - const noOneOptions = screen.getAllByTestId('option-card-no-one') + const noOneOptions = screen.getAllByTestId('option-card-plugin.privilege.noone') expect(noOneOptions[1]).toHaveAttribute('aria-pressed', 'true') // Debug permission }) @@ -414,7 +242,7 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} />) // Act - click on "No One" for install permission - const noOneOptions = screen.getAllByTestId('option-card-no-one') + const noOneOptions = screen.getAllByTestId('option-card-plugin.privilege.noone') fireEvent.click(noOneOptions[0]) // First one is for install permission // Assert - the option should now be selected @@ -440,7 +268,7 @@ describe('reference-setting-modal', () => { // Arrange const payload = { permission: createMockPermissions(), - auto_upgrade: undefined as any, + auto_upgrade: undefined as unknown as AutoUpdateConfig, } // Act @@ -458,7 +286,7 @@ describe('reference-setting-modal', () => { // Act render(<ReferenceSettingModal {...defaultProps} onHide={onHide} />) - fireEvent.click(screen.getByText('Cancel')) + fireEvent.click(screen.getByText('common.operation.cancel')) // Assert expect(onHide).toHaveBeenCalledTimes(1) @@ -483,7 +311,7 @@ describe('reference-setting-modal', () => { // Act render(<ReferenceSettingModal {...defaultProps} onSave={onSave} onHide={onHide} />) - fireEvent.click(screen.getByText('Save')) + fireEvent.click(screen.getByText('common.operation.save')) // Assert await waitFor(() => { @@ -501,7 +329,7 @@ describe('reference-setting-modal', () => { // Act render(<ReferenceSettingModal {...defaultProps} onSave={onSave} onHide={onHide} />) - fireEvent.click(screen.getByText('Save')) + fireEvent.click(screen.getByText('common.operation.save')) // Assert await waitFor(() => { @@ -522,7 +350,7 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} payload={payload} />) // Click Everyone for install permission - const everyoneOptions = screen.getAllByTestId('option-card-everyone') + const everyoneOptions = screen.getAllByTestId('option-card-plugin.privilege.everyone') fireEvent.click(everyoneOptions[0]) // Assert @@ -542,7 +370,7 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} payload={payload} />) // Click Admins Only for debug permission (second set of options) - const adminOptions = screen.getAllByTestId('option-card-admins-only') + const adminOptions = screen.getAllByTestId('option-card-plugin.privilege.admins') fireEvent.click(adminOptions[1]) // Second one is for debug permission // Assert @@ -560,7 +388,7 @@ describe('reference-setting-modal', () => { fireEvent.click(screen.getByTestId('auto-update-change')) // Save to verify the change - fireEvent.click(screen.getByText('Save')) + fireEvent.click(screen.getByText('common.operation.save')) // Assert await waitFor(() => { @@ -582,7 +410,7 @@ describe('reference-setting-modal', () => { rerender(<ReferenceSettingModal {...defaultProps} />) // Assert - component should render without issues - expect(screen.getByText('Plugin Permissions')).toBeInTheDocument() + expect(screen.getByText('plugin.privilege.title')).toBeInTheDocument() }) it('handleSave should be memoized with useCallback', async () => { @@ -592,7 +420,7 @@ describe('reference-setting-modal', () => { // Act - rerender and click save rerender(<ReferenceSettingModal {...defaultProps} onSave={onSave} />) - fireEvent.click(screen.getByText('Save')) + fireEvent.click(screen.getByText('common.operation.save')) // Assert await waitFor(() => { @@ -605,7 +433,7 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} />) // Act - click install permission option - const everyoneOptions = screen.getAllByTestId('option-card-everyone') + const everyoneOptions = screen.getAllByTestId('option-card-plugin.privilege.everyone') fireEvent.click(everyoneOptions[0]) // Assert - install permission should be updated @@ -617,24 +445,24 @@ describe('reference-setting-modal', () => { it('should be memoized with React.memo', () => { // Assert expect(ReferenceSettingModal).toBeDefined() - expect((ReferenceSettingModal as any).$$typeof?.toString()).toContain('Symbol') + expect((ReferenceSettingModal as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) describe('Edge Cases and Error Handling', () => { it('should handle null payload gracefully', () => { // Arrange - const payload = null as any + const payload = null as unknown as ReferenceSetting // Act & Assert - should not crash render(<ReferenceSettingModal {...defaultProps} payload={payload} />) - expect(screen.getByText('Plugin Permissions')).toBeInTheDocument() + expect(screen.getByText('plugin.privilege.title')).toBeInTheDocument() }) it('should handle undefined permission values', () => { // Arrange const payload = { - permission: undefined as any, + permission: undefined as unknown as Permissions, auto_upgrade: createMockAutoUpdateConfig(), } @@ -642,7 +470,7 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} payload={payload} />) // Assert - should use default PermissionType.noOne - const noOneOptions = screen.getAllByTestId('option-card-no-one') + const noOneOptions = screen.getAllByTestId('option-card-plugin.privilege.noone') expect(noOneOptions[0]).toHaveAttribute('aria-pressed', 'true') }) @@ -650,7 +478,7 @@ describe('reference-setting-modal', () => { // Arrange const payload = createMockReferenceSetting({ permission: { - install_permission: undefined as any, + install_permission: undefined as unknown as PermissionType, debug_permission: PermissionType.everyone, }, }) @@ -659,7 +487,7 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} payload={payload} />) // Assert - should fall back to PermissionType.noOne - expect(screen.getByText('Plugin Permissions')).toBeInTheDocument() + expect(screen.getByText('plugin.privilege.title')).toBeInTheDocument() }) it('should handle missing debug_permission', () => { @@ -667,7 +495,7 @@ describe('reference-setting-modal', () => { const payload = createMockReferenceSetting({ permission: { install_permission: PermissionType.everyone, - debug_permission: undefined as any, + debug_permission: undefined as unknown as PermissionType, }, }) @@ -675,7 +503,7 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} payload={payload} />) // Assert - should fall back to PermissionType.noOne - expect(screen.getByText('Plugin Permissions')).toBeInTheDocument() + expect(screen.getByText('plugin.privilege.title')).toBeInTheDocument() }) it('should handle slow async onSave gracefully', async () => { @@ -690,7 +518,7 @@ describe('reference-setting-modal', () => { // Act render(<ReferenceSettingModal {...defaultProps} onSave={onSave} onHide={onHide} />) - fireEvent.click(screen.getByText('Save')) + fireEvent.click(screen.getByText('common.operation.save')) // Assert - onSave should be called immediately expect(onSave).toHaveBeenCalledTimes(1) @@ -727,7 +555,7 @@ describe('reference-setting-modal', () => { const { unmount } = render(<ReferenceSettingModal {...defaultProps} payload={payload} />) // Assert - should render without crashing - expect(screen.getByText('Plugin Permissions')).toBeInTheDocument() + expect(screen.getByText('plugin.privilege.title')).toBeInTheDocument() unmount() }) @@ -802,11 +630,11 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} payload={payload} onSave={onSave} />) // Change install permission to noOne - const noOneOptions = screen.getAllByTestId('option-card-no-one') + const noOneOptions = screen.getAllByTestId('option-card-plugin.privilege.noone') fireEvent.click(noOneOptions[0]) // Save - fireEvent.click(screen.getByText('Save')) + fireEvent.click(screen.getByText('common.operation.save')) // Assert - debug_permission should still be admin await waitFor(() => { @@ -833,11 +661,11 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} payload={payload} onSave={onSave} />) // Change debug permission to noOne - const noOneOptions = screen.getAllByTestId('option-card-no-one') + const noOneOptions = screen.getAllByTestId('option-card-plugin.privilege.noone') fireEvent.click(noOneOptions[1]) // Second one is for debug // Save - fireEvent.click(screen.getByText('Save')) + fireEvent.click(screen.getByText('common.operation.save')) // Assert - install_permission should still be admin await waitFor(() => { @@ -862,11 +690,11 @@ describe('reference-setting-modal', () => { fireEvent.click(screen.getByTestId('auto-update-change')) // Change install permission - const everyoneOptions = screen.getAllByTestId('option-card-everyone') + const everyoneOptions = screen.getAllByTestId('option-card-plugin.privilege.everyone') fireEvent.click(everyoneOptions[0]) // Save - fireEvent.click(screen.getByText('Save')) + fireEvent.click(screen.getByText('common.operation.save')) // Assert - both changes should be saved await waitFor(() => { @@ -907,9 +735,9 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} />) // Assert - check order by getting all section labels - const labels = screen.getAllByText(/Who can/) - expect(labels[0]).toHaveTextContent('Who can install plugins') - expect(labels[1]).toHaveTextContent('Who can debug plugins') + const labels = screen.getAllByText(/plugin\.privilege\.whoCan/) + expect(labels[0]).toHaveTextContent('plugin.privilege.whoCanInstall') + expect(labels[1]).toHaveTextContent('plugin.privilege.whoCanDebug') }) it('should render three options per permission section', () => { @@ -917,9 +745,9 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} />) // Assert - const everyoneOptions = screen.getAllByTestId('option-card-everyone') - const adminOptions = screen.getAllByTestId('option-card-admins-only') - const noOneOptions = screen.getAllByTestId('option-card-no-one') + const everyoneOptions = screen.getAllByTestId('option-card-plugin.privilege.everyone') + const adminOptions = screen.getAllByTestId('option-card-plugin.privilege.admins') + const noOneOptions = screen.getAllByTestId('option-card-plugin.privilege.noone') expect(everyoneOptions).toHaveLength(2) // One for install, one for debug expect(adminOptions).toHaveLength(2) @@ -931,8 +759,8 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} />) // Assert - const cancelButton = screen.getByText('Cancel') - const saveButton = screen.getByText('Save') + const cancelButton = screen.getByText('common.operation.cancel') + const saveButton = screen.getByText('common.operation.save') expect(cancelButton).toBeInTheDocument() expect(saveButton).toBeInTheDocument() @@ -968,18 +796,18 @@ describe('reference-setting-modal', () => { ) // Change install permission to Everyone - const everyoneOptions = screen.getAllByTestId('option-card-everyone') + const everyoneOptions = screen.getAllByTestId('option-card-plugin.privilege.everyone') fireEvent.click(everyoneOptions[0]) // Change debug permission to Admins Only - const adminOptions = screen.getAllByTestId('option-card-admins-only') + const adminOptions = screen.getAllByTestId('option-card-plugin.privilege.admins') fireEvent.click(adminOptions[1]) // Change auto-update strategy fireEvent.click(screen.getByTestId('auto-update-change')) // Save - fireEvent.click(screen.getByText('Save')) + fireEvent.click(screen.getByText('common.operation.save')) // Assert await waitFor(() => { @@ -1012,11 +840,11 @@ describe('reference-setting-modal', () => { ) // Make some changes - const noOneOptions = screen.getAllByTestId('option-card-no-one') + const noOneOptions = screen.getAllByTestId('option-card-plugin.privilege.noone') fireEvent.click(noOneOptions[0]) // Cancel - fireEvent.click(screen.getByText('Cancel')) + fireEvent.click(screen.getByText('common.operation.cancel')) // Assert expect(onSave).not.toHaveBeenCalled() @@ -1035,8 +863,8 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...props} />) // Assert - Labels are rendered correctly - expect(screen.getByText('Who can install plugins')).toBeInTheDocument() - expect(screen.getByText('Who can debug plugins')).toBeInTheDocument() + expect(screen.getByText('plugin.privilege.whoCanInstall')).toBeInTheDocument() + expect(screen.getByText('plugin.privilege.whoCanDebug')).toBeInTheDocument() }) }) }) diff --git a/web/app/components/plugins/reference-setting-modal/__tests__/label.spec.tsx b/web/app/components/plugins/reference-setting-modal/__tests__/label.spec.tsx new file mode 100644 index 0000000000..86fcf15a90 --- /dev/null +++ b/web/app/components/plugins/reference-setting-modal/__tests__/label.spec.tsx @@ -0,0 +1,97 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import Label from '../label' + +describe('Label', () => { + describe('Rendering', () => { + it('should render label text', () => { + render(<Label label="Test Label" />) + expect(screen.getByText('Test Label')).toBeInTheDocument() + }) + + it('should render with label only when no description provided', () => { + const { container } = render(<Label label="Simple Label" />) + expect(screen.getByText('Simple Label')).toBeInTheDocument() + expect(container.querySelector('.h-6')).toBeInTheDocument() + }) + + it('should render label and description when both provided', () => { + render(<Label label="Label Text" description="Description Text" />) + expect(screen.getByText('Label Text')).toBeInTheDocument() + expect(screen.getByText('Description Text')).toBeInTheDocument() + }) + + it('should apply h-4 class to label container when description is provided', () => { + const { container } = render(<Label label="Label" description="Has description" />) + expect(container.querySelector('.h-4')).toBeInTheDocument() + }) + + it('should not render description element when description is undefined', () => { + const { container } = render(<Label label="Only Label" />) + expect(container.querySelectorAll('.body-xs-regular')).toHaveLength(0) + }) + + it('should render description with correct styling', () => { + const { container } = render(<Label label="Label" description="Styled Description" />) + const descriptionElement = container.querySelector('.body-xs-regular') + expect(descriptionElement).toBeInTheDocument() + expect(descriptionElement).toHaveClass('mt-1', 'text-text-tertiary') + }) + }) + + describe('Props Variations', () => { + it('should handle empty label string', () => { + const { container } = render(<Label label="" />) + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle empty description string', () => { + render(<Label label="Label" description="" />) + expect(screen.getByText('Label')).toBeInTheDocument() + }) + + it('should handle long label text', () => { + const longLabel = 'A'.repeat(200) + render(<Label label={longLabel} />) + expect(screen.getByText(longLabel)).toBeInTheDocument() + }) + + it('should handle long description text', () => { + const longDescription = 'B'.repeat(500) + render(<Label label="Label" description={longDescription} />) + expect(screen.getByText(longDescription)).toBeInTheDocument() + }) + + it('should handle special characters in label', () => { + const specialLabel = '<script>alert("xss")</script>' + render(<Label label={specialLabel} />) + expect(screen.getByText(specialLabel)).toBeInTheDocument() + }) + + it('should handle special characters in description', () => { + const specialDescription = '!@#$%^&*()_+-=[]{}|;:,.<>?' + render(<Label label="Label" description={specialDescription} />) + expect(screen.getByText(specialDescription)).toBeInTheDocument() + }) + }) + + describe('Component Memoization', () => { + it('should be memoized with React.memo', () => { + expect(Label).toBeDefined() + // eslint-disable-next-line ts/no-explicit-any + expect((Label as any).$$typeof?.toString()).toContain('Symbol') + }) + }) + + describe('Styling', () => { + it('should apply system-sm-semibold class to label', () => { + const { container } = render(<Label label="Styled Label" />) + expect(container.querySelector('.system-sm-semibold')).toBeInTheDocument() + }) + + it('should apply text-text-secondary class to label', () => { + const { container } = render(<Label label="Styled Label" />) + expect(container.querySelector('.text-text-secondary')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx similarity index 85% rename from web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx rename to web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx index 1008ef461d..19ce12b328 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import type { AutoUpdateConfig } from './types' +import type { AutoUpdateConfig } from '../types' import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' @@ -7,91 +7,28 @@ import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum, PluginSource } from '../../types' -import { defaultValue } from './config' -import AutoUpdateSetting from './index' -import NoDataPlaceholder from './no-data-placeholder' -import NoPluginSelected from './no-plugin-selected' -import PluginsPicker from './plugins-picker' -import PluginsSelected from './plugins-selected' -import StrategyPicker from './strategy-picker' -import ToolItem from './tool-item' -import ToolPicker from './tool-picker' -import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from './types' +import { PluginCategoryEnum, PluginSource } from '../../../types' +import { defaultValue } from '../config' +import AutoUpdateSetting from '../index' +import NoDataPlaceholder from '../no-data-placeholder' +import NoPluginSelected from '../no-plugin-selected' +import PluginsPicker from '../plugins-picker' +import PluginsSelected from '../plugins-selected' +import StrategyPicker from '../strategy-picker' +import ToolItem from '../tool-item' +import ToolPicker from '../tool-picker' +import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from '../types' import { convertLocalSecondsToUTCDaySeconds, convertUTCDaySecondsToLocalSeconds, dayjsToTimeOfDay, timeOfDayToDayjs, -} from './utils' +} from '../utils' // Setup dayjs plugins dayjs.extend(utc) dayjs.extend(timezone) -// ================================ -// Mock External Dependencies Only -// ================================ - -// Mock react-i18next -vi.mock('react-i18next', async (importOriginal) => { - const actual = await importOriginal<typeof import('react-i18next')>() - return { - ...actual, - Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record<string, React.ReactNode> }) => { - if (i18nKey === 'autoUpdate.changeTimezone' && components?.setTimezone) { - return ( - <span> - Change in - {components.setTimezone} - </span> - ) - } - return <span>{i18nKey}</span> - }, - useTranslation: () => ({ - t: (key: string, options?: { ns?: string, num?: number }) => { - const translations: Record<string, string> = { - 'autoUpdate.updateSettings': 'Update Settings', - 'autoUpdate.automaticUpdates': 'Automatic Updates', - 'autoUpdate.updateTime': 'Update Time', - 'autoUpdate.specifyPluginsToUpdate': 'Specify Plugins to Update', - 'autoUpdate.strategy.fixOnly.selectedDescription': 'Only apply bug fixes', - 'autoUpdate.strategy.latest.selectedDescription': 'Always update to latest', - 'autoUpdate.strategy.disabled.name': 'Disabled', - 'autoUpdate.strategy.disabled.description': 'No automatic updates', - 'autoUpdate.strategy.fixOnly.name': 'Bug Fixes Only', - 'autoUpdate.strategy.fixOnly.description': 'Only apply bug fixes and patches', - 'autoUpdate.strategy.latest.name': 'Latest Version', - 'autoUpdate.strategy.latest.description': 'Always update to the latest version', - 'autoUpdate.upgradeMode.all': 'All Plugins', - 'autoUpdate.upgradeMode.exclude': 'Exclude Selected', - 'autoUpdate.upgradeMode.partial': 'Selected Only', - 'autoUpdate.excludeUpdate': `Excluding ${options?.num || 0} plugins`, - 'autoUpdate.partialUPdate': `Updating ${options?.num || 0} plugins`, - 'autoUpdate.operation.clearAll': 'Clear All', - 'autoUpdate.operation.select': 'Select Plugins', - 'autoUpdate.upgradeModePlaceholder.partial': 'Select plugins to update', - 'autoUpdate.upgradeModePlaceholder.exclude': 'Select plugins to exclude', - 'autoUpdate.noPluginPlaceholder.noInstalled': 'No plugins installed', - 'autoUpdate.noPluginPlaceholder.noFound': 'No plugins found', - 'category.all': 'All', - 'category.models': 'Models', - 'category.tools': 'Tools', - 'category.agents': 'Agents', - 'category.extensions': 'Extensions', - 'category.datasources': 'Datasources', - 'category.triggers': 'Triggers', - 'category.bundles': 'Bundles', - 'searchTools': 'Search tools...', - } - const fullKey = options?.ns ? `${options.ns}.${key}` : key - return translations[fullKey] || translations[key] || key - }, - }), - } -}) - // Mock app context const mockTimezone = 'America/New_York' vi.mock('@/context/app-context', () => ({ @@ -262,7 +199,7 @@ vi.mock('@/app/components/base/icons/src/vender/other', () => ({ })) // Mock PLUGIN_TYPE_SEARCH_MAP -vi.mock('../../marketplace/constants', () => ({ +vi.mock('../../../marketplace/constants', () => ({ PLUGIN_TYPE_SEARCH_MAP: { all: 'all', model: 'model', @@ -574,7 +511,7 @@ describe('auto-update-setting', () => { // Assert expect(screen.getByTestId('group-icon')).toBeInTheDocument() - expect(screen.getByText('No plugins installed')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.noPluginPlaceholder.noInstalled')).toBeInTheDocument() }) it('should render with noPlugins=false showing search icon', () => { @@ -583,7 +520,7 @@ describe('auto-update-setting', () => { // Assert expect(screen.getByTestId('search-menu-icon')).toBeInTheDocument() - expect(screen.getByText('No plugins found')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.noPluginPlaceholder.noFound')).toBeInTheDocument() }) it('should render with noPlugins=undefined (default) showing search icon', () => { @@ -606,14 +543,11 @@ describe('auto-update-setting', () => { describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(NoDataPlaceholder).toBeDefined() - expect((NoDataPlaceholder as any).$$typeof?.toString()).toContain('Symbol') + expect((NoDataPlaceholder as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) }) - // ============================================================ - // NoPluginSelected Component Tests - // ============================================================ describe('NoPluginSelected (no-plugin-selected.tsx)', () => { describe('Rendering', () => { it('should render partial mode placeholder', () => { @@ -621,7 +555,7 @@ describe('auto-update-setting', () => { render(<NoPluginSelected updateMode={AUTO_UPDATE_MODE.partial} />) // Assert - expect(screen.getByText('Select plugins to update')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.upgradeModePlaceholder.partial')).toBeInTheDocument() }) it('should render exclude mode placeholder', () => { @@ -629,21 +563,18 @@ describe('auto-update-setting', () => { render(<NoPluginSelected updateMode={AUTO_UPDATE_MODE.exclude} />) // Assert - expect(screen.getByText('Select plugins to exclude')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.upgradeModePlaceholder.exclude')).toBeInTheDocument() }) }) describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(NoPluginSelected).toBeDefined() - expect((NoPluginSelected as any).$$typeof?.toString()).toContain('Symbol') + expect((NoPluginSelected as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) }) - // ============================================================ - // PluginsSelected Component Tests - // ============================================================ describe('PluginsSelected (plugins-selected.tsx)', () => { describe('Rendering', () => { it('should render empty when no plugins', () => { @@ -731,14 +662,11 @@ describe('auto-update-setting', () => { describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(PluginsSelected).toBeDefined() - expect((PluginsSelected as any).$$typeof?.toString()).toContain('Symbol') + expect((PluginsSelected as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) }) - // ============================================================ - // ToolItem Component Tests - // ============================================================ describe('ToolItem (tool-item.tsx)', () => { const defaultProps = { payload: createMockPluginDetail(), @@ -825,14 +753,11 @@ describe('auto-update-setting', () => { describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(ToolItem).toBeDefined() - expect((ToolItem as any).$$typeof?.toString()).toContain('Symbol') + expect((ToolItem as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) }) - // ============================================================ - // StrategyPicker Component Tests - // ============================================================ describe('StrategyPicker (strategy-picker.tsx)', () => { const defaultProps = { value: AUTO_UPDATE_STRATEGY.disabled, @@ -845,7 +770,7 @@ describe('auto-update-setting', () => { render(<StrategyPicker {...defaultProps} value={AUTO_UPDATE_STRATEGY.disabled} />) // Assert - expect(screen.getByRole('button', { name: /disabled/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /plugin\.autoUpdate\.strategy\.disabled\.name/i })).toBeInTheDocument() }) it('should not render dropdown content when closed', () => { @@ -866,10 +791,10 @@ describe('auto-update-setting', () => { // Wait for portal to open if (mockPortalOpen) { - // Assert all options visible (use getAllByText for "Disabled" as it appears in both trigger and dropdown) - expect(screen.getAllByText('Disabled').length).toBeGreaterThanOrEqual(1) - expect(screen.getByText('Bug Fixes Only')).toBeInTheDocument() - expect(screen.getByText('Latest Version')).toBeInTheDocument() + // Assert all options visible (use getAllByText for strategy name as it appears in both trigger and dropdown) + expect(screen.getAllByText('plugin.autoUpdate.strategy.disabled.name').length).toBeGreaterThanOrEqual(1) + expect(screen.getByText('plugin.autoUpdate.strategy.fixOnly.name')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.strategy.latest.name')).toBeInTheDocument() } }) }) @@ -898,7 +823,7 @@ describe('auto-update-setting', () => { render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />) // Find and click the "Bug Fixes Only" option - const fixOnlyOption = screen.getByText('Bug Fixes Only').closest('div[class*="cursor-pointer"]') + const fixOnlyOption = screen.getByText('plugin.autoUpdate.strategy.fixOnly.name').closest('div[class*="cursor-pointer"]') expect(fixOnlyOption).toBeInTheDocument() fireEvent.click(fixOnlyOption!) @@ -915,7 +840,7 @@ describe('auto-update-setting', () => { render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />) // Find and click the "Latest Version" option - const latestOption = screen.getByText('Latest Version').closest('div[class*="cursor-pointer"]') + const latestOption = screen.getByText('plugin.autoUpdate.strategy.latest.name').closest('div[class*="cursor-pointer"]') expect(latestOption).toBeInTheDocument() fireEvent.click(latestOption!) @@ -932,7 +857,7 @@ describe('auto-update-setting', () => { render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={onChange} />) // Find and click the "Disabled" option - need to find the one in the dropdown, not the button - const disabledOptions = screen.getAllByText('Disabled') + const disabledOptions = screen.getAllByText('plugin.autoUpdate.strategy.disabled.name') // The second one should be in the dropdown const dropdownOption = disabledOptions.find(el => el.closest('div[class*="cursor-pointer"]')) expect(dropdownOption).toBeInTheDocument() @@ -956,7 +881,7 @@ describe('auto-update-setting', () => { ) // Click an option - const fixOnlyOption = screen.getByText('Bug Fixes Only').closest('div[class*="cursor-pointer"]') + const fixOnlyOption = screen.getByText('plugin.autoUpdate.strategy.fixOnly.name').closest('div[class*="cursor-pointer"]') fireEvent.click(fixOnlyOption!) // Assert - onChange is called but parent click handler should not propagate @@ -972,7 +897,7 @@ describe('auto-update-setting', () => { // Assert - RiCheckLine should be rendered (check icon) // Find all "Bug Fixes Only" texts and get the one in the dropdown (has cursor-pointer parent) - const allFixOnlyTexts = screen.getAllByText('Bug Fixes Only') + const allFixOnlyTexts = screen.getAllByText('plugin.autoUpdate.strategy.fixOnly.name') const dropdownOption = allFixOnlyTexts.find(el => el.closest('div[class*="cursor-pointer"]')) const optionContainer = dropdownOption?.closest('div[class*="cursor-pointer"]') expect(optionContainer).toBeInTheDocument() @@ -988,7 +913,7 @@ describe('auto-update-setting', () => { render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />) // Assert - check the Latest Version option should not have check icon - const latestOption = screen.getByText('Latest Version').closest('div[class*="cursor-pointer"]') + const latestOption = screen.getByText('plugin.autoUpdate.strategy.latest.name').closest('div[class*="cursor-pointer"]') // The svg should only be in selected option, not in non-selected const checkIconContainer = latestOption?.querySelector('div.mr-1') // Non-selected option should have empty check icon container @@ -997,9 +922,6 @@ describe('auto-update-setting', () => { }) }) - // ============================================================ - // ToolPicker Component Tests - // ============================================================ describe('ToolPicker (tool-picker.tsx)', () => { const defaultProps = { trigger: <button>Select Plugins</button>, @@ -1199,7 +1121,7 @@ describe('auto-update-setting', () => { describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(ToolPicker).toBeDefined() - expect((ToolPicker as any).$$typeof?.toString()).toContain('Symbol') + expect((ToolPicker as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) }) @@ -1220,7 +1142,7 @@ describe('auto-update-setting', () => { render(<PluginsPicker {...defaultProps} />) // Assert - expect(screen.getByText('Select plugins to update')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.upgradeModePlaceholder.partial')).toBeInTheDocument() }) it('should render selected plugins count and clear button when plugins selected', () => { @@ -1228,8 +1150,8 @@ describe('auto-update-setting', () => { render(<PluginsPicker {...defaultProps} value={['plugin-1', 'plugin-2']} />) // Assert - expect(screen.getByText(/Updating 2 plugins/i)).toBeInTheDocument() - expect(screen.getByText('Clear All')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.partialUPdate:{"num":2}')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.operation.clearAll')).toBeInTheDocument() }) it('should render select button', () => { @@ -1237,7 +1159,7 @@ describe('auto-update-setting', () => { render(<PluginsPicker {...defaultProps} />) // Assert - expect(screen.getByText('Select Plugins')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.operation.select')).toBeInTheDocument() }) it('should show exclude mode text when in exclude mode', () => { @@ -1251,7 +1173,7 @@ describe('auto-update-setting', () => { ) // Assert - expect(screen.getByText(/Excluding 1 plugins/i)).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.excludeUpdate:{"num":1}')).toBeInTheDocument() }) }) @@ -1268,7 +1190,7 @@ describe('auto-update-setting', () => { onChange={onChange} />, ) - fireEvent.click(screen.getByText('Clear All')) + fireEvent.click(screen.getByText('plugin.autoUpdate.operation.clearAll')) // Assert expect(onChange).toHaveBeenCalledWith([]) @@ -1278,7 +1200,7 @@ describe('auto-update-setting', () => { describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(PluginsPicker).toBeDefined() - expect((PluginsPicker as any).$$typeof?.toString()).toContain('Symbol') + expect((PluginsPicker as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) }) @@ -1298,7 +1220,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} />) // Assert - expect(screen.getByText('Update Settings')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.updateSettings')).toBeInTheDocument() }) it('should render automatic updates label', () => { @@ -1306,7 +1228,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} />) // Assert - expect(screen.getByText('Automatic Updates')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.automaticUpdates')).toBeInTheDocument() }) it('should render strategy picker', () => { @@ -1325,7 +1247,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.getByText('Update Time')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.updateTime')).toBeInTheDocument() expect(screen.getByTestId('time-picker')).toBeInTheDocument() }) @@ -1337,7 +1259,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.queryByText('Update Time')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.autoUpdate.updateTime')).not.toBeInTheDocument() expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument() }) @@ -1352,7 +1274,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.getByText('Select Plugins')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.operation.select')).toBeInTheDocument() }) it('should hide plugins picker when mode is update_all', () => { @@ -1366,7 +1288,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.queryByText('Select Plugins')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.autoUpdate.operation.select')).not.toBeInTheDocument() }) }) @@ -1379,7 +1301,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.getByText('Only apply bug fixes')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.strategy.fixOnly.selectedDescription')).toBeInTheDocument() }) it('should show latest description when strategy is latest', () => { @@ -1390,7 +1312,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.getByText('Always update to latest')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.strategy.latest.selectedDescription')).toBeInTheDocument() }) it('should show no description when strategy is disabled', () => { @@ -1401,8 +1323,8 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.queryByText('Only apply bug fixes')).not.toBeInTheDocument() - expect(screen.queryByText('Always update to latest')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.autoUpdate.strategy.fixOnly.selectedDescription')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.autoUpdate.strategy.latest.selectedDescription')).not.toBeInTheDocument() }) }) @@ -1420,7 +1342,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.getByText(/Updating 2 plugins/i)).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.partialUPdate:{"num":2}')).toBeInTheDocument() }) it('should show exclude_plugins when mode is exclude', () => { @@ -1436,7 +1358,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.getByText(/Excluding 3 plugins/i)).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.excludeUpdate:{"num":3}')).toBeInTheDocument() }) }) @@ -1502,7 +1424,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting payload={payload} onChange={onChange} />) // Click clear all - fireEvent.click(screen.getByText('Clear All')) + fireEvent.click(screen.getByText('plugin.autoUpdate.operation.clearAll')) // Assert expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ @@ -1523,7 +1445,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting payload={payload} onChange={onChange} />) // Click clear all - fireEvent.click(screen.getByText('Clear All')) + fireEvent.click(screen.getByText('plugin.autoUpdate.operation.clearAll')) // Assert expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ @@ -1538,8 +1460,8 @@ describe('auto-update-setting', () => { // Act render(<AutoUpdateSetting {...defaultProps} payload={payload} />) - // Assert - timezone text is rendered - expect(screen.getByText(/Change in/i)).toBeInTheDocument() + // Assert - timezone Trans component is rendered + expect(screen.getByText('autoUpdate.changeTimezone')).toBeInTheDocument() }) }) @@ -1571,7 +1493,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting payload={payload} onChange={onChange} />) // Trigger a change (clear plugins) - fireEvent.click(screen.getByText('Clear All')) + fireEvent.click(screen.getByText('plugin.autoUpdate.operation.clearAll')) // Assert - other values should be preserved expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ @@ -1593,7 +1515,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting payload={payload} onChange={onChange} />) // Plugin picker should not be visible in update_all mode - expect(screen.queryByText('Clear All')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.autoUpdate.operation.clearAll')).not.toBeInTheDocument() }) }) @@ -1604,14 +1526,14 @@ describe('auto-update-setting', () => { const { rerender } = render(<AutoUpdateSetting {...defaultProps} payload={payload1} />) // Assert initial - expect(screen.getByText('Only apply bug fixes')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.strategy.fixOnly.selectedDescription')).toBeInTheDocument() // Act - change strategy const payload2 = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.latest }) rerender(<AutoUpdateSetting {...defaultProps} payload={payload2} />) // Assert updated - expect(screen.getByText('Always update to latest')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.strategy.latest.selectedDescription')).toBeInTheDocument() }) it('plugins should reflect correct list based on upgrade_mode', () => { @@ -1625,7 +1547,7 @@ describe('auto-update-setting', () => { const { rerender } = render(<AutoUpdateSetting {...defaultProps} payload={partialPayload} />) // Assert - partial mode shows include_plugins count - expect(screen.getByText(/Updating 2 plugins/i)).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.partialUPdate:{"num":2}')).toBeInTheDocument() // Act - change to exclude mode const excludePayload = createMockAutoUpdateConfig({ @@ -1637,14 +1559,14 @@ describe('auto-update-setting', () => { rerender(<AutoUpdateSetting {...defaultProps} payload={excludePayload} />) // Assert - exclude mode shows exclude_plugins count - expect(screen.getByText(/Excluding 1 plugins/i)).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.excludeUpdate:{"num":1}')).toBeInTheDocument() }) }) describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(AutoUpdateSetting).toBeDefined() - expect((AutoUpdateSetting as any).$$typeof?.toString()).toContain('Symbol') + expect((AutoUpdateSetting as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) @@ -1661,7 +1583,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.getByText('Update Settings')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.updateSettings')).toBeInTheDocument() }) it('should handle null timezone gracefully', () => { @@ -1697,9 +1619,9 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.getByText('All Plugins')).toBeInTheDocument() - expect(screen.getByText('Exclude Selected')).toBeInTheDocument() - expect(screen.getByText('Selected Only')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.upgradeMode.all')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.upgradeMode.exclude')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.upgradeMode.partial')).toBeInTheDocument() }) it('should highlight selected upgrade mode', () => { @@ -1713,9 +1635,9 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - OptionCard component will be rendered for each mode - expect(screen.getByText('All Plugins')).toBeInTheDocument() - expect(screen.getByText('Exclude Selected')).toBeInTheDocument() - expect(screen.getByText('Selected Only')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.upgradeMode.all')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.upgradeMode.exclude')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.upgradeMode.partial')).toBeInTheDocument() }) it('should call onChange when upgrade mode is changed', () => { @@ -1730,7 +1652,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting payload={payload} onChange={onChange} />) // Click on partial mode - find the option card for partial - const partialOption = screen.getByText('Selected Only') + const partialOption = screen.getByText('plugin.autoUpdate.upgradeMode.partial') fireEvent.click(partialOption) // Assert @@ -1769,7 +1691,7 @@ describe('auto-update-setting', () => { // Assert - time picker and plugins visible expect(screen.getByTestId('time-picker')).toBeInTheDocument() - expect(screen.getByText('Select Plugins')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.operation.select')).toBeInTheDocument() }) it('should maintain state consistency when switching modes', () => { @@ -1786,7 +1708,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting payload={payload} onChange={onChange} />) // Assert - partial mode shows include_plugins - expect(screen.getByText(/Updating 1 plugins/i)).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.partialUPdate:{"num":1}')).toBeInTheDocument() }) }) }) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/utils.spec.ts b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/utils.spec.ts similarity index 94% rename from web/app/components/plugins/reference-setting-modal/auto-update-setting/utils.spec.ts rename to web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/utils.spec.ts index f813338c98..c23072021e 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/utils.spec.ts +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/utils.spec.ts @@ -1,4 +1,4 @@ -import { convertLocalSecondsToUTCDaySeconds, convertUTCDaySecondsToLocalSeconds } from './utils' +import { convertLocalSecondsToUTCDaySeconds, convertUTCDaySecondsToLocalSeconds } from '../utils' describe('convertLocalSecondsToUTCDaySeconds', () => { it('should convert local seconds to UTC day seconds correctly', () => { diff --git a/web/app/components/plugins/update-plugin/__tests__/downgrade-warning.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/downgrade-warning.spec.tsx new file mode 100644 index 0000000000..be446f98d1 --- /dev/null +++ b/web/app/components/plugins/update-plugin/__tests__/downgrade-warning.spec.tsx @@ -0,0 +1,78 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import DowngradeWarningModal from '../downgrade-warning' + +describe('DowngradeWarningModal', () => { + const mockOnCancel = vi.fn() + const mockOnJustDowngrade = vi.fn() + const mockOnExcludeAndDowngrade = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + cleanup() + }) + + it('renders title and description', () => { + render( + <DowngradeWarningModal + onCancel={mockOnCancel} + onJustDowngrade={mockOnJustDowngrade} + onExcludeAndDowngrade={mockOnExcludeAndDowngrade} + />, + ) + expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.title')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.description')).toBeInTheDocument() + }) + + it('renders three action buttons', () => { + render( + <DowngradeWarningModal + onCancel={mockOnCancel} + onJustDowngrade={mockOnJustDowngrade} + onExcludeAndDowngrade={mockOnExcludeAndDowngrade} + />, + ) + expect(screen.getByText('app.newApp.Cancel')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.downgrade')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.exclude')).toBeInTheDocument() + }) + + it('calls onCancel when Cancel is clicked', () => { + render( + <DowngradeWarningModal + onCancel={mockOnCancel} + onJustDowngrade={mockOnJustDowngrade} + onExcludeAndDowngrade={mockOnExcludeAndDowngrade} + />, + ) + fireEvent.click(screen.getByText('app.newApp.Cancel')) + expect(mockOnCancel).toHaveBeenCalledTimes(1) + }) + + it('calls onJustDowngrade when downgrade button is clicked', () => { + render( + <DowngradeWarningModal + onCancel={mockOnCancel} + onJustDowngrade={mockOnJustDowngrade} + onExcludeAndDowngrade={mockOnExcludeAndDowngrade} + />, + ) + fireEvent.click(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.downgrade')) + expect(mockOnJustDowngrade).toHaveBeenCalledTimes(1) + }) + + it('calls onExcludeAndDowngrade when exclude button is clicked', () => { + render( + <DowngradeWarningModal + onCancel={mockOnCancel} + onJustDowngrade={mockOnJustDowngrade} + onExcludeAndDowngrade={mockOnExcludeAndDowngrade} + />, + ) + fireEvent.click(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.exclude')) + expect(mockOnExcludeAndDowngrade).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/plugins/update-plugin/__tests__/from-github.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/from-github.spec.tsx new file mode 100644 index 0000000000..1ce1a1a0af --- /dev/null +++ b/web/app/components/plugins/update-plugin/__tests__/from-github.spec.tsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/app/components/plugins/install-plugin/install-from-github', () => ({ + default: ({ updatePayload, onClose, onSuccess }: { + updatePayload?: Record<string, unknown> + onClose: () => void + onSuccess: () => void + }) => ( + <div data-testid="install-from-github"> + <span data-testid="update-payload">{JSON.stringify(updatePayload)}</span> + <button data-testid="close-btn" onClick={onClose}>Close</button> + <button data-testid="success-btn" onClick={onSuccess}>Success</button> + </div> + ), +})) + +describe('FromGitHub', () => { + let FromGitHub: (typeof import('../from-github'))['default'] + + beforeEach(async () => { + vi.clearAllMocks() + const mod = await import('../from-github') + FromGitHub = mod.default + }) + + it('should render InstallFromGitHub with update payload', () => { + const payload = { id: '1', owner: 'test', repo: 'plugin' } as never + render(<FromGitHub payload={payload} onSave={vi.fn()} onCancel={vi.fn()} />) + + expect(screen.getByTestId('install-from-github')).toBeInTheDocument() + expect(screen.getByTestId('update-payload')).toHaveTextContent(JSON.stringify(payload)) + }) + + it('should call onCancel when close is triggered', () => { + const mockOnCancel = vi.fn() + render(<FromGitHub payload={{} as never} onSave={vi.fn()} onCancel={mockOnCancel} />) + + screen.getByTestId('close-btn').click() + expect(mockOnCancel).toHaveBeenCalled() + }) + + it('should call onSave on success', () => { + const mockOnSave = vi.fn() + render(<FromGitHub payload={{} as never} onSave={mockOnSave} onCancel={vi.fn()} />) + + screen.getByTestId('success-btn').click() + expect(mockOnSave).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/update-plugin/index.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx similarity index 87% rename from web/app/components/plugins/update-plugin/index.spec.tsx rename to web/app/components/plugins/update-plugin/__tests__/index.spec.tsx index 2d4635f83b..8a4b2187b5 100644 --- a/web/app/components/plugins/update-plugin/index.spec.tsx +++ b/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx @@ -3,50 +3,17 @@ import type { UpdateFromGitHubPayload, UpdateFromMarketPlacePayload, UpdatePluginModalType, -} from '../types' +} from '../../types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum, PluginSource, TaskStatus } from '../types' -import DowngradeWarningModal from './downgrade-warning' -import FromGitHub from './from-github' -import UpdateFromMarketplace from './from-market-place' -import UpdatePlugin from './index' -import PluginVersionPicker from './plugin-version-picker' - -// ================================ -// Mock External Dependencies Only -// ================================ - -// Mock react-i18next -vi.mock('react-i18next', async (importOriginal) => { - const actual = await importOriginal<typeof import('react-i18next')>() - return { - ...actual, - useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => { - const translations: Record<string, string> = { - 'upgrade.title': 'Update Plugin', - 'upgrade.successfulTitle': 'Plugin Updated', - 'upgrade.description': 'This plugin will be updated to the new version.', - 'upgrade.upgrade': 'Update', - 'upgrade.upgrading': 'Updating...', - 'upgrade.close': 'Close', - 'operation.cancel': 'Cancel', - 'newApp.Cancel': 'Cancel', - 'autoUpdate.pluginDowngradeWarning.title': 'Downgrade Warning', - 'autoUpdate.pluginDowngradeWarning.description': 'You are about to downgrade this plugin.', - 'autoUpdate.pluginDowngradeWarning.downgrade': 'Just Downgrade', - 'autoUpdate.pluginDowngradeWarning.exclude': 'Exclude and Downgrade', - 'detailPanel.switchVersion': 'Switch Version', - } - const fullKey = options?.ns ? `${options.ns}.${key}` : key - return translations[fullKey] || translations[key] || key - }, - }), - } -}) +import { PluginCategoryEnum, PluginSource, TaskStatus } from '../../types' +import DowngradeWarningModal from '../downgrade-warning' +import FromGitHub from '../from-github' +import UpdateFromMarketplace from '../from-market-place' +import UpdatePlugin from '../index' +import PluginVersionPicker from '../plugin-version-picker' // Mock useGetLanguage context vi.mock('@/context/i18n', () => ({ @@ -108,7 +75,7 @@ vi.mock('@/service/use-plugins', () => ({ // Mock checkTaskStatus const mockCheck = vi.fn() const mockStop = vi.fn() -vi.mock('../install-plugin/base/check-task-status', () => ({ +vi.mock('../../install-plugin/base/check-task-status', () => ({ default: () => ({ check: mockCheck, stop: mockStop, @@ -116,14 +83,14 @@ vi.mock('../install-plugin/base/check-task-status', () => ({ })) // Mock Toast -vi.mock('../../base/toast', () => ({ +vi.mock('../../../base/toast', () => ({ default: { notify: vi.fn(), }, })) // Mock InstallFromGitHub component -vi.mock('../install-plugin/install-from-github', () => ({ +vi.mock('../../install-plugin/install-from-github', () => ({ default: ({ updatePayload, onClose, onSuccess }: { updatePayload: UpdateFromGitHubPayload onClose: () => void @@ -320,7 +287,7 @@ describe('update-plugin', () => { renderWithQueryClient(<UpdatePlugin {...props} />) // Assert - expect(screen.getByText('Update Plugin')).toBeInTheDocument() + expect(screen.getByText('plugin.upgrade.title')).toBeInTheDocument() }) it('should render UpdateFromMarketplace for other plugin sources', () => { @@ -337,7 +304,7 @@ describe('update-plugin', () => { renderWithQueryClient(<UpdatePlugin {...props} />) // Assert - expect(screen.getByText('Update Plugin')).toBeInTheDocument() + expect(screen.getByText('plugin.upgrade.title')).toBeInTheDocument() }) }) @@ -346,7 +313,7 @@ describe('update-plugin', () => { // Verify the component is wrapped with React.memo expect(UpdatePlugin).toBeDefined() // The component should have $$typeof indicating it's a memo component - expect((UpdatePlugin as any).$$typeof?.toString()).toContain('Symbol') + expect((UpdatePlugin as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) @@ -440,7 +407,7 @@ describe('update-plugin', () => { describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(FromGitHub).toBeDefined() - expect((FromGitHub as any).$$typeof?.toString()).toContain('Symbol') + expect((FromGitHub as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) @@ -502,8 +469,8 @@ describe('update-plugin', () => { ) // Assert - expect(screen.getByText('Update Plugin')).toBeInTheDocument() - expect(screen.getByText('This plugin will be updated to the new version.')).toBeInTheDocument() + expect(screen.getByText('plugin.upgrade.title')).toBeInTheDocument() + expect(screen.getByText('plugin.upgrade.description')).toBeInTheDocument() }) it('should render version badge with version transition', () => { @@ -546,8 +513,8 @@ describe('update-plugin', () => { ) // Assert - expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument() }) }) @@ -567,8 +534,8 @@ describe('update-plugin', () => { ) // Assert - expect(screen.getByText('Downgrade Warning')).toBeInTheDocument() - expect(screen.getByText('You are about to downgrade this plugin.')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.title')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.description')).toBeInTheDocument() }) it('should not show downgrade warning modal when isShowDowngradeWarningModal is false', () => { @@ -586,8 +553,8 @@ describe('update-plugin', () => { ) // Assert - expect(screen.queryByText('Downgrade Warning')).not.toBeInTheDocument() - expect(screen.getByText('Update Plugin')).toBeInTheDocument() + expect(screen.queryByText('plugin.autoUpdate.pluginDowngradeWarning.title')).not.toBeInTheDocument() + expect(screen.getByText('plugin.upgrade.title')).toBeInTheDocument() }) }) @@ -605,7 +572,7 @@ describe('update-plugin', () => { onCancel={onCancel} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) // Assert expect(onCancel).toHaveBeenCalledTimes(1) @@ -628,7 +595,7 @@ describe('update-plugin', () => { onCancel={vi.fn()} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Update' })) + fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })) // Assert await waitFor(() => { @@ -654,14 +621,14 @@ describe('update-plugin', () => { ) // Assert - button should show Update before clicking - expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })).toBeInTheDocument() // Act - click update button - fireEvent.click(screen.getByRole('button', { name: 'Update' })) + fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })) // Assert - Cancel button should be hidden during upgrade await waitFor(() => { - expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.cancel' })).not.toBeInTheDocument() }) }) @@ -682,7 +649,7 @@ describe('update-plugin', () => { onCancel={vi.fn()} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Update' })) + fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })) // Assert await waitFor(() => { @@ -708,7 +675,7 @@ describe('update-plugin', () => { onCancel={vi.fn()} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Update' })) + fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })) // Assert await waitFor(() => { @@ -735,7 +702,7 @@ describe('update-plugin', () => { onCancel={onCancel} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) // Assert expect(mockStop).toHaveBeenCalled() @@ -757,18 +724,18 @@ describe('update-plugin', () => { onCancel={vi.fn()} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Update' })) + fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })) // Assert await waitFor(() => { - expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })).toBeInTheDocument() }) }) it('should show error toast when task status is failed', async () => { // Arrange - covers lines 99-100 const mockToastNotify = vi.fn() - vi.mocked(await import('../../base/toast')).default.notify = mockToastNotify + vi.mocked(await import('../../../base/toast')).default.notify = mockToastNotify mockUpdateFromMarketPlace.mockResolvedValue({ all_installed: false, @@ -789,7 +756,7 @@ describe('update-plugin', () => { onCancel={vi.fn()} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Update' })) + fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })) // Assert await waitFor(() => { @@ -809,7 +776,7 @@ describe('update-plugin', () => { describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(UpdateFromMarketplace).toBeDefined() - expect((UpdateFromMarketplace as any).$$typeof?.toString()).toContain('Symbol') + expect((UpdateFromMarketplace as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) @@ -833,7 +800,7 @@ describe('update-plugin', () => { isShowDowngradeWarningModal={true} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Exclude and Downgrade' })) + fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.exclude' })) // Assert await waitFor(() => { @@ -865,7 +832,7 @@ describe('update-plugin', () => { isShowDowngradeWarningModal={true} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Exclude and Downgrade' })) + fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.exclude' })) // Assert - mutateAsync should NOT be called when pluginId is undefined await waitFor(() => { @@ -892,8 +859,8 @@ describe('update-plugin', () => { ) // Assert - expect(screen.getByText('Downgrade Warning')).toBeInTheDocument() - expect(screen.getByText('You are about to downgrade this plugin.')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.title')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.description')).toBeInTheDocument() }) it('should render all three action buttons', () => { @@ -907,9 +874,9 @@ describe('update-plugin', () => { ) // Assert - expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Just Downgrade' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Exclude and Downgrade' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'app.newApp.Cancel' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.downgrade' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.exclude' })).toBeInTheDocument() }) }) @@ -926,7 +893,7 @@ describe('update-plugin', () => { onExcludeAndDowngrade={vi.fn()} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Cancel' })) // Assert expect(onCancel).toHaveBeenCalledTimes(1) @@ -944,7 +911,7 @@ describe('update-plugin', () => { onExcludeAndDowngrade={vi.fn()} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Just Downgrade' })) + fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.downgrade' })) // Assert expect(onJustDowngrade).toHaveBeenCalledTimes(1) @@ -962,7 +929,7 @@ describe('update-plugin', () => { onExcludeAndDowngrade={onExcludeAndDowngrade} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Exclude and Downgrade' })) + fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.exclude' })) // Assert expect(onExcludeAndDowngrade).toHaveBeenCalledTimes(1) @@ -1006,7 +973,7 @@ describe('update-plugin', () => { // Assert expect(screen.getByTestId('portal-content')).toBeInTheDocument() - expect(screen.getByText('Switch Version')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.switchVersion')).toBeInTheDocument() }) it('should render all versions from API', () => { @@ -1170,7 +1137,7 @@ describe('update-plugin', () => { describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(PluginVersionPicker).toBeDefined() - expect((PluginVersionPicker as any).$$typeof?.toString()).toContain('Symbol') + expect((PluginVersionPicker as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) }) @@ -1212,7 +1179,7 @@ describe('update-plugin', () => { it('should handle empty version list in PluginVersionPicker', () => { // Override the mock temporarily - vi.mocked(vi.importActual('@/service/use-plugins') as any).useVersionListOfPlugin = () => ({ + vi.mocked(vi.importActual('@/service/use-plugins') as unknown as Record<string, unknown>).useVersionListOfPlugin = () => ({ data: { data: { versions: [] } }, }) @@ -1230,7 +1197,7 @@ describe('update-plugin', () => { ) // Assert - expect(screen.getByText('Switch Version')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.switchVersion')).toBeInTheDocument() }) }) }) diff --git a/web/app/components/tools/__tests__/provider-list.spec.tsx b/web/app/components/tools/__tests__/provider-list.spec.tsx new file mode 100644 index 0000000000..ad703bf43a --- /dev/null +++ b/web/app/components/tools/__tests__/provider-list.spec.tsx @@ -0,0 +1,263 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import ProviderList from '../provider-list' + +let mockActiveTab = 'builtin' +const mockSetActiveTab = vi.fn((val: string) => { + mockActiveTab = val +}) +vi.mock('nuqs', () => ({ + useQueryState: () => [mockActiveTab, mockSetActiveTab], +})) + +vi.mock('@/app/components/plugins/hooks', () => ({ + useTags: () => ({ + tags: [], + tagsMap: {}, + getTagLabel: (name: string) => name, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: () => ({ enable_marketplace: false }), +})) + +const mockCollections = [ + { + id: 'builtin-1', + name: 'google-search', + author: 'Dify', + description: { en_US: 'Google Search', zh_Hans: '谷歌搜索' }, + icon: 'icon-google', + label: { en_US: 'Google Search', zh_Hans: '谷歌搜索' }, + type: 'builtin', + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: ['search'], + }, + { + id: 'api-1', + name: 'my-api', + author: 'User', + description: { en_US: 'My API tool', zh_Hans: '我的 API 工具' }, + icon: { background: '#fff', content: '🔧' }, + label: { en_US: 'My API Tool', zh_Hans: '我的 API 工具' }, + type: 'api', + team_credentials: {}, + is_team_authorization: false, + allow_delete: true, + labels: [], + }, + { + id: 'workflow-1', + name: 'wf-tool', + author: 'User', + description: { en_US: 'Workflow Tool', zh_Hans: '工作流工具' }, + icon: { background: '#fff', content: '⚡' }, + label: { en_US: 'Workflow Tool', zh_Hans: '工作流工具' }, + type: 'workflow', + team_credentials: {}, + is_team_authorization: false, + allow_delete: true, + labels: [], + }, +] + +const mockRefetch = vi.fn() +vi.mock('@/service/use-tools', () => ({ + useAllToolProviders: () => ({ + data: mockCollections, + refetch: mockRefetch, + }), +})) + +vi.mock('@/service/use-plugins', () => ({ + useCheckInstalled: () => ({ data: null }), + useInvalidateInstalledPluginList: () => vi.fn(), +})) + +vi.mock('@/app/components/base/tab-slider-new', () => ({ + default: ({ value, onChange, options }: { + value: string + onChange: (val: string) => void + options: { value: string, text: string }[] + }) => ( + <div data-testid="tab-slider"> + {options.map(opt => ( + <button + key={opt.value} + data-testid={`tab-${opt.value}`} + data-active={value === opt.value} + onClick={() => onChange(opt.value)} + > + {opt.text} + </button> + ))} + </div> + ), +})) + +vi.mock('@/app/components/plugins/card', () => ({ + default: ({ payload, className }: { payload: { name: string }, className?: string }) => ( + <div data-testid={`card-${payload.name}`} className={className}>{payload.name}</div> + ), +})) + +vi.mock('@/app/components/plugins/card/card-more-info', () => ({ + default: ({ tags }: { tags: string[] }) => <div data-testid="card-more-info">{tags.join(', ')}</div>, +})) + +vi.mock('@/app/components/tools/labels/filter', () => ({ + default: ({ value, onChange }: { value: string[], onChange: (v: string[]) => void }) => ( + <div data-testid="label-filter"> + <button data-testid="add-filter" onClick={() => onChange(['search'])}>Add filter</button> + <button data-testid="clear-filter" onClick={() => onChange([])}>Clear filter</button> + <span>{value.join(', ')}</span> + </div> + ), +})) + +vi.mock('@/app/components/tools/provider/custom-create-card', () => ({ + default: () => <div data-testid="custom-create-card">Create Custom Tool</div>, +})) + +vi.mock('@/app/components/tools/provider/detail', () => ({ + default: ({ collection, onHide }: { collection: { name: string }, onHide: () => void }) => ( + <div data-testid="provider-detail"> + <span>{collection.name}</span> + <button data-testid="detail-close" onClick={onHide}>Close</button> + </div> + ), +})) + +vi.mock('@/app/components/tools/provider/empty', () => ({ + default: () => <div data-testid="workflow-empty">No workflow tools</div>, +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({ + default: ({ detail }: { detail: unknown }) => + detail ? <div data-testid="plugin-detail-panel" /> : null, +})) + +vi.mock('@/app/components/plugins/marketplace/empty', () => ({ + default: ({ text }: { text: string }) => <div data-testid="empty">{text}</div>, +})) + +vi.mock('../marketplace', () => ({ + default: () => <div data-testid="marketplace">Marketplace</div>, +})) + +vi.mock('../marketplace/hooks', () => ({ + useMarketplace: () => ({ + isLoading: false, + marketplaceCollections: [], + marketplaceCollectionPluginsMap: {}, + plugins: [], + handleScroll: vi.fn(), + page: 1, + }), +})) + +vi.mock('../mcp', () => ({ + default: ({ searchText }: { searchText: string }) => ( + <div data-testid="mcp-list"> + MCP List: + {searchText} + </div> + ), +})) + +describe('ProviderList', () => { + beforeEach(() => { + vi.clearAllMocks() + mockActiveTab = 'builtin' + }) + + afterEach(() => { + cleanup() + }) + + describe('Tab Navigation', () => { + it('renders all four tabs', () => { + render(<ProviderList />) + expect(screen.getByTestId('tab-builtin')).toHaveTextContent('tools.type.builtIn') + expect(screen.getByTestId('tab-api')).toHaveTextContent('tools.type.custom') + expect(screen.getByTestId('tab-workflow')).toHaveTextContent('tools.type.workflow') + expect(screen.getByTestId('tab-mcp')).toHaveTextContent('MCP') + }) + + it('switches tab when clicked', () => { + render(<ProviderList />) + fireEvent.click(screen.getByTestId('tab-api')) + expect(mockSetActiveTab).toHaveBeenCalledWith('api') + }) + }) + + describe('Filtering', () => { + it('shows only builtin collections by default', () => { + render(<ProviderList />) + expect(screen.getByTestId('card-google-search')).toBeInTheDocument() + expect(screen.queryByTestId('card-my-api')).not.toBeInTheDocument() + }) + + it('filters by search keyword', () => { + render(<ProviderList />) + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'nonexistent' } }) + expect(screen.queryByTestId('card-google-search')).not.toBeInTheDocument() + }) + + it('shows label filter for non-MCP tabs', () => { + render(<ProviderList />) + expect(screen.getByTestId('label-filter')).toBeInTheDocument() + }) + + it('renders search input', () => { + render(<ProviderList />) + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + }) + + describe('Custom Tab', () => { + it('shows custom create card when on api tab', () => { + mockActiveTab = 'api' + render(<ProviderList />) + expect(screen.getByTestId('custom-create-card')).toBeInTheDocument() + }) + }) + + describe('Workflow Tab', () => { + it('shows empty state when no workflow collections', () => { + mockActiveTab = 'workflow' + render(<ProviderList />) + // Only one workflow collection exists, so it should show + expect(screen.getByTestId('card-wf-tool')).toBeInTheDocument() + }) + }) + + describe('MCP Tab', () => { + it('renders MCPList component', () => { + mockActiveTab = 'mcp' + render(<ProviderList />) + expect(screen.getByTestId('mcp-list')).toBeInTheDocument() + }) + }) + + describe('Provider Detail', () => { + it('opens provider detail when a non-plugin collection is clicked', () => { + render(<ProviderList />) + fireEvent.click(screen.getByTestId('card-google-search')) + expect(screen.getByTestId('provider-detail')).toBeInTheDocument() + expect(screen.getByTestId('provider-detail')).toHaveTextContent('google-search') + }) + + it('closes provider detail when close button is clicked', () => { + render(<ProviderList />) + fireEvent.click(screen.getByTestId('card-google-search')) + expect(screen.getByTestId('provider-detail')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('detail-close')) + expect(screen.queryByTestId('provider-detail')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/tools/edit-custom-collection-modal/config-credentials.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/__tests__/config-credentials.spec.tsx similarity index 99% rename from web/app/components/tools/edit-custom-collection-modal/config-credentials.spec.tsx rename to web/app/components/tools/edit-custom-collection-modal/__tests__/config-credentials.spec.tsx index 31cda9b459..ec4866b212 100644 --- a/web/app/components/tools/edit-custom-collection-modal/config-credentials.spec.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/config-credentials.spec.tsx @@ -2,7 +2,7 @@ import type { Credential } from '@/app/components/tools/types' import { act, fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types' -import ConfigCredential from './config-credentials' +import ConfigCredential from '../config-credentials' describe('ConfigCredential', () => { const baseCredential: Credential = { diff --git a/web/app/components/tools/edit-custom-collection-modal/get-schema.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/__tests__/get-schema.spec.tsx similarity index 94% rename from web/app/components/tools/edit-custom-collection-modal/get-schema.spec.tsx rename to web/app/components/tools/edit-custom-collection-modal/__tests__/get-schema.spec.tsx index fa316c4aab..edd2d3dc43 100644 --- a/web/app/components/tools/edit-custom-collection-modal/get-schema.spec.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/get-schema.spec.tsx @@ -1,8 +1,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { importSchemaFromURL } from '@/service/tools' -import Toast from '../../base/toast' -import examples from './examples' -import GetSchema from './get-schema' +import Toast from '../../../base/toast' +import examples from '../examples' +import GetSchema from '../get-schema' vi.mock('@/service/tools', () => ({ importSchemaFromURL: vi.fn(), diff --git a/web/app/components/tools/edit-custom-collection-modal/index.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/tools/edit-custom-collection-modal/index.spec.tsx rename to web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx index 97fc03175d..3b821080e4 100644 --- a/web/app/components/tools/edit-custom-collection-modal/index.spec.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx @@ -6,7 +6,7 @@ import Toast from '@/app/components/base/toast' import { Plan } from '@/app/components/billing/type' import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types' import { parseParamsSchema } from '@/service/tools' -import EditCustomCollectionModal from './index' +import EditCustomCollectionModal from '../index' vi.mock('ahooks', async () => { const actual = await vi.importActual<typeof import('ahooks')>('ahooks') diff --git a/web/app/components/tools/edit-custom-collection-modal/test-api.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/__tests__/test-api.spec.tsx similarity index 99% rename from web/app/components/tools/edit-custom-collection-modal/test-api.spec.tsx rename to web/app/components/tools/edit-custom-collection-modal/__tests__/test-api.spec.tsx index 5cf07c9b19..df35ace68d 100644 --- a/web/app/components/tools/edit-custom-collection-modal/test-api.spec.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/test-api.spec.tsx @@ -3,7 +3,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types' import { testAPIAvailable } from '@/service/tools' -import TestApi from './test-api' +import TestApi from '../test-api' vi.mock('@/service/tools', () => ({ testAPIAvailable: vi.fn(), diff --git a/web/app/components/tools/labels/filter.spec.tsx b/web/app/components/tools/labels/__tests__/filter.spec.tsx similarity index 99% rename from web/app/components/tools/labels/filter.spec.tsx rename to web/app/components/tools/labels/__tests__/filter.spec.tsx index eeacff30a9..7b88cb1bbd 100644 --- a/web/app/components/tools/labels/filter.spec.tsx +++ b/web/app/components/tools/labels/__tests__/filter.spec.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import LabelFilter from './filter' +import LabelFilter from '../filter' // Mock useTags hook with controlled test data const mockTags = [ diff --git a/web/app/components/tools/labels/selector.spec.tsx b/web/app/components/tools/labels/__tests__/selector.spec.tsx similarity index 99% rename from web/app/components/tools/labels/selector.spec.tsx rename to web/app/components/tools/labels/__tests__/selector.spec.tsx index ebe273abf9..b495d2d227 100644 --- a/web/app/components/tools/labels/selector.spec.tsx +++ b/web/app/components/tools/labels/__tests__/selector.spec.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import LabelSelector from './selector' +import LabelSelector from '../selector' // Mock useTags hook with controlled test data const mockTags = [ diff --git a/web/app/components/tools/labels/__tests__/store.spec.ts b/web/app/components/tools/labels/__tests__/store.spec.ts new file mode 100644 index 0000000000..c5d6a174cc --- /dev/null +++ b/web/app/components/tools/labels/__tests__/store.spec.ts @@ -0,0 +1,41 @@ +import type { Label } from '../constant' +import { beforeEach, describe, expect, it } from 'vitest' +import { useStore } from '../store' + +describe('labels/store', () => { + beforeEach(() => { + // Reset store to initial state before each test + useStore.setState({ labelList: [] }) + }) + + it('initializes with empty labelList', () => { + const state = useStore.getState() + expect(state.labelList).toEqual([]) + }) + + it('sets labelList via setLabelList', () => { + const labels: Label[] = [ + { name: 'search', label: 'Search' }, + { name: 'agent', label: { en_US: 'Agent', zh_Hans: '代理' } }, + ] + useStore.getState().setLabelList(labels) + expect(useStore.getState().labelList).toEqual(labels) + }) + + it('replaces existing labels with new list', () => { + const initial: Label[] = [{ name: 'old', label: 'Old' }] + useStore.getState().setLabelList(initial) + expect(useStore.getState().labelList).toEqual(initial) + + const updated: Label[] = [{ name: 'new', label: 'New' }] + useStore.getState().setLabelList(updated) + expect(useStore.getState().labelList).toEqual(updated) + }) + + it('handles undefined argument (sets labelList to undefined)', () => { + const labels: Label[] = [{ name: 'test', label: 'Test' }] + useStore.getState().setLabelList(labels) + useStore.getState().setLabelList(undefined) + expect(useStore.getState().labelList).toBeUndefined() + }) +}) diff --git a/web/app/components/tools/marketplace/__tests__/hooks.spec.ts b/web/app/components/tools/marketplace/__tests__/hooks.spec.ts new file mode 100644 index 0000000000..14244f763c --- /dev/null +++ b/web/app/components/tools/marketplace/__tests__/hooks.spec.ts @@ -0,0 +1,201 @@ +import type { Plugin } from '@/app/components/plugins/types' +import type { Collection } from '@/app/components/tools/types' +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SCROLL_BOTTOM_THRESHOLD } from '@/app/components/plugins/marketplace/constants' +import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils' +import { PluginCategoryEnum } from '@/app/components/plugins/types' +import { CollectionType } from '@/app/components/tools/types' +import { useMarketplace } from '../hooks' + +// ==================== Mock Setup ==================== + +const mockQueryMarketplaceCollectionsAndPlugins = vi.fn() +const mockQueryPlugins = vi.fn() +const mockQueryPluginsWithDebounced = vi.fn() +const mockResetPlugins = vi.fn() +const mockFetchNextPage = vi.fn() + +const mockUseMarketplaceCollectionsAndPlugins = vi.fn() +const mockUseMarketplacePlugins = vi.fn() +vi.mock('@/app/components/plugins/marketplace/hooks', () => ({ + useMarketplaceCollectionsAndPlugins: (...args: unknown[]) => mockUseMarketplaceCollectionsAndPlugins(...args), + useMarketplacePlugins: (...args: unknown[]) => mockUseMarketplacePlugins(...args), +})) + +const mockUseAllToolProviders = vi.fn() +vi.mock('@/service/use-tools', () => ({ + useAllToolProviders: (...args: unknown[]) => mockUseAllToolProviders(...args), +})) + +vi.mock('@/utils/var', () => ({ + getMarketplaceUrl: vi.fn(() => 'https://marketplace.test/market'), +})) + +// ==================== Test Utilities ==================== + +const createToolProvider = (overrides: Partial<Collection> = {}): Collection => ({ + id: 'provider-1', + name: 'Provider 1', + author: 'Author', + description: { en_US: 'desc', zh_Hans: '描述' }, + icon: 'icon', + label: { en_US: 'label', zh_Hans: '标签' }, + type: CollectionType.custom, + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: [], + ...overrides, +}) + +const setupHookMocks = (overrides?: { + isLoading?: boolean + isPluginsLoading?: boolean + pluginsPage?: number + hasNextPage?: boolean + plugins?: Plugin[] | undefined +}) => { + mockUseMarketplaceCollectionsAndPlugins.mockReturnValue({ + isLoading: overrides?.isLoading ?? false, + marketplaceCollections: [], + marketplaceCollectionPluginsMap: {}, + queryMarketplaceCollectionsAndPlugins: mockQueryMarketplaceCollectionsAndPlugins, + }) + mockUseMarketplacePlugins.mockReturnValue({ + plugins: overrides?.plugins, + resetPlugins: mockResetPlugins, + queryPlugins: mockQueryPlugins, + queryPluginsWithDebounced: mockQueryPluginsWithDebounced, + isLoading: overrides?.isPluginsLoading ?? false, + fetchNextPage: mockFetchNextPage, + hasNextPage: overrides?.hasNextPage ?? false, + page: overrides?.pluginsPage, + }) +} + +// ==================== Tests ==================== + +describe('useMarketplace', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseAllToolProviders.mockReturnValue({ + data: [], + isSuccess: true, + }) + setupHookMocks() + }) + + describe('Queries', () => { + it('should query plugins with debounce when search text is provided', async () => { + mockUseAllToolProviders.mockReturnValue({ + data: [ + createToolProvider({ plugin_id: 'plugin-a' }), + createToolProvider({ plugin_id: undefined }), + ], + isSuccess: true, + }) + + renderHook(() => useMarketplace('alpha', [])) + + await waitFor(() => { + expect(mockQueryPluginsWithDebounced).toHaveBeenCalledWith({ + category: PluginCategoryEnum.tool, + query: 'alpha', + tags: [], + exclude: ['plugin-a'], + type: 'plugin', + }) + }) + expect(mockQueryMarketplaceCollectionsAndPlugins).not.toHaveBeenCalled() + expect(mockResetPlugins).not.toHaveBeenCalled() + }) + + it('should query plugins immediately when only tags are provided', async () => { + mockUseAllToolProviders.mockReturnValue({ + data: [createToolProvider({ plugin_id: 'plugin-b' })], + isSuccess: true, + }) + + renderHook(() => useMarketplace('', ['tag-1'])) + + await waitFor(() => { + expect(mockQueryPlugins).toHaveBeenCalledWith({ + category: PluginCategoryEnum.tool, + query: '', + tags: ['tag-1'], + exclude: ['plugin-b'], + type: 'plugin', + }) + }) + }) + + it('should query collections and reset plugins when no filters are provided', async () => { + mockUseAllToolProviders.mockReturnValue({ + data: [createToolProvider({ plugin_id: 'plugin-c' })], + isSuccess: true, + }) + + renderHook(() => useMarketplace('', [])) + + await waitFor(() => { + expect(mockQueryMarketplaceCollectionsAndPlugins).toHaveBeenCalledWith({ + category: PluginCategoryEnum.tool, + condition: getMarketplaceListCondition(PluginCategoryEnum.tool), + exclude: ['plugin-c'], + type: 'plugin', + }) + }) + expect(mockResetPlugins).toHaveBeenCalledTimes(1) + }) + }) + + describe('State', () => { + it('should expose combined loading state and fallback page value', () => { + setupHookMocks({ isLoading: true, isPluginsLoading: false, pluginsPage: undefined }) + + const { result } = renderHook(() => useMarketplace('', [])) + + expect(result.current.isLoading).toBe(true) + expect(result.current.page).toBe(1) + }) + }) + + describe('Scroll', () => { + it('should fetch next page when scrolling near bottom with filters', () => { + setupHookMocks({ hasNextPage: true }) + const { result } = renderHook(() => useMarketplace('search', [])) + const event = { + target: { + scrollTop: 100, + scrollHeight: 200, + clientHeight: 100 + SCROLL_BOTTOM_THRESHOLD, + }, + } as unknown as Event + + act(() => { + result.current.handleScroll(event) + }) + + expect(mockFetchNextPage).toHaveBeenCalledTimes(1) + }) + + it('should not fetch next page when no filters are applied', () => { + setupHookMocks({ hasNextPage: true }) + const { result } = renderHook(() => useMarketplace('', [])) + const event = { + target: { + scrollTop: 100, + scrollHeight: 200, + clientHeight: 100 + SCROLL_BOTTOM_THRESHOLD, + }, + } as unknown as Event + + act(() => { + result.current.handleScroll(event) + }) + + expect(mockFetchNextPage).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/tools/marketplace/__tests__/index.spec.tsx b/web/app/components/tools/marketplace/__tests__/index.spec.tsx new file mode 100644 index 0000000000..43c303b075 --- /dev/null +++ b/web/app/components/tools/marketplace/__tests__/index.spec.tsx @@ -0,0 +1,180 @@ +import type { useMarketplace } from '../hooks' +import type { Plugin } from '@/app/components/plugins/types' +import type { Collection } from '@/app/components/tools/types' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { PluginCategoryEnum } from '@/app/components/plugins/types' +import { CollectionType } from '@/app/components/tools/types' +import { getMarketplaceUrl } from '@/utils/var' + +import Marketplace from '../index' + +const listRenderSpy = vi.fn() +vi.mock('@/app/components/plugins/marketplace/list', () => ({ + default: (props: { + marketplaceCollections: unknown[] + marketplaceCollectionPluginsMap: Record<string, unknown[]> + plugins?: unknown[] + showInstallButton?: boolean + }) => { + listRenderSpy(props) + return <div data-testid="marketplace-list" /> + }, +})) + +const mockUseMarketplaceCollectionsAndPlugins = vi.fn() +const mockUseMarketplacePlugins = vi.fn() +vi.mock('@/app/components/plugins/marketplace/hooks', () => ({ + useMarketplaceCollectionsAndPlugins: (...args: unknown[]) => mockUseMarketplaceCollectionsAndPlugins(...args), + useMarketplacePlugins: (...args: unknown[]) => mockUseMarketplacePlugins(...args), +})) + +const mockUseAllToolProviders = vi.fn() +vi.mock('@/service/use-tools', () => ({ + useAllToolProviders: (...args: unknown[]) => mockUseAllToolProviders(...args), +})) + +vi.mock('@/utils/var', () => ({ + getMarketplaceUrl: vi.fn(() => 'https://marketplace.test/market'), +})) + +const mockGetMarketplaceUrl = vi.mocked(getMarketplaceUrl) + +const _createToolProvider = (overrides: Partial<Collection> = {}): Collection => ({ + id: 'provider-1', + name: 'Provider 1', + author: 'Author', + description: { en_US: 'desc', zh_Hans: '描述' }, + icon: 'icon', + label: { en_US: 'label', zh_Hans: '标签' }, + type: CollectionType.custom, + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: [], + ...overrides, +}) + +const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({ + type: 'plugin', + org: 'org', + author: 'author', + name: 'Plugin One', + plugin_id: 'plugin-1', + version: '1.0.0', + latest_version: '1.0.0', + latest_package_identifier: 'plugin-1@1.0.0', + icon: 'icon', + verified: true, + label: { en_US: 'Plugin One' }, + brief: { en_US: 'Brief' }, + description: { en_US: 'Plugin description' }, + introduction: 'Intro', + repository: 'https://example.com', + category: PluginCategoryEnum.tool, + install_count: 0, + endpoint: { settings: [] }, + tags: [{ name: 'tag' }], + badges: [], + verification: { authorized_category: 'community' }, + from: 'marketplace', + ...overrides, +}) + +const createMarketplaceContext = (overrides: Partial<ReturnType<typeof useMarketplace>> = {}) => ({ + isLoading: false, + marketplaceCollections: [], + marketplaceCollectionPluginsMap: {}, + plugins: [], + handleScroll: vi.fn(), + page: 1, + ...overrides, +}) + +describe('Marketplace', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering the marketplace panel based on loading and visibility state. + describe('Rendering', () => { + it('should show loading indicator when loading first page', () => { + // Arrange + const marketplaceContext = createMarketplaceContext({ isLoading: true, page: 1 }) + render( + <Marketplace + searchPluginText="" + filterPluginTags={[]} + isMarketplaceArrowVisible={false} + showMarketplacePanel={vi.fn()} + marketplaceContext={marketplaceContext} + />, + ) + + // Assert + expect(document.querySelector('svg.spin-animation')).toBeInTheDocument() + expect(screen.queryByTestId('marketplace-list')).not.toBeInTheDocument() + }) + + it('should render list when not loading', () => { + // Arrange + const marketplaceContext = createMarketplaceContext({ + isLoading: false, + plugins: [createPlugin()], + }) + render( + <Marketplace + searchPluginText="" + filterPluginTags={[]} + isMarketplaceArrowVisible={false} + showMarketplacePanel={vi.fn()} + marketplaceContext={marketplaceContext} + />, + ) + + // Assert + expect(screen.getByTestId('marketplace-list')).toBeInTheDocument() + expect(listRenderSpy).toHaveBeenCalledWith(expect.objectContaining({ + showInstallButton: true, + })) + }) + }) + + // Prop-driven UI output such as links and action triggers. + describe('Props', () => { + it('should build marketplace link and trigger panel when arrow is clicked', async () => { + const user = userEvent.setup() + // Arrange + const marketplaceContext = createMarketplaceContext() + const showMarketplacePanel = vi.fn() + const { container } = render( + <Marketplace + searchPluginText="vector" + filterPluginTags={['tag-a', 'tag-b']} + isMarketplaceArrowVisible + showMarketplacePanel={showMarketplacePanel} + marketplaceContext={marketplaceContext} + />, + ) + + // Act + const arrowIcon = container.querySelector('svg.cursor-pointer') + expect(arrowIcon).toBeTruthy() + await user.click(arrowIcon as SVGElement) + + // Assert + expect(showMarketplacePanel).toHaveBeenCalledTimes(1) + expect(mockGetMarketplaceUrl).toHaveBeenCalledWith('', { + language: 'en', + q: 'vector', + tags: 'tag-a,tag-b', + theme: undefined, + }) + const marketplaceLink = screen.getByRole('link', { name: /plugin.marketplace.difyMarketplace/i }) + expect(marketplaceLink).toHaveAttribute('href', 'https://marketplace.test/market') + }) + }) +}) + +// useMarketplace hook tests moved to hooks.spec.ts diff --git a/web/app/components/tools/marketplace/index.spec.tsx b/web/app/components/tools/marketplace/index.spec.tsx deleted file mode 100644 index 493d960e2a..0000000000 --- a/web/app/components/tools/marketplace/index.spec.tsx +++ /dev/null @@ -1,360 +0,0 @@ -import type { Plugin } from '@/app/components/plugins/types' -import type { Collection } from '@/app/components/tools/types' -import { act, render, renderHook, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import * as React from 'react' -import { SCROLL_BOTTOM_THRESHOLD } from '@/app/components/plugins/marketplace/constants' -import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils' -import { PluginCategoryEnum } from '@/app/components/plugins/types' -import { CollectionType } from '@/app/components/tools/types' -import { getMarketplaceUrl } from '@/utils/var' -import { useMarketplace } from './hooks' - -import Marketplace from './index' - -const listRenderSpy = vi.fn() -vi.mock('@/app/components/plugins/marketplace/list', () => ({ - default: (props: { - marketplaceCollections: unknown[] - marketplaceCollectionPluginsMap: Record<string, unknown[]> - plugins?: unknown[] - showInstallButton?: boolean - }) => { - listRenderSpy(props) - return <div data-testid="marketplace-list" /> - }, -})) - -const mockUseMarketplaceCollectionsAndPlugins = vi.fn() -const mockUseMarketplacePlugins = vi.fn() -vi.mock('@/app/components/plugins/marketplace/hooks', () => ({ - useMarketplaceCollectionsAndPlugins: (...args: unknown[]) => mockUseMarketplaceCollectionsAndPlugins(...args), - useMarketplacePlugins: (...args: unknown[]) => mockUseMarketplacePlugins(...args), -})) - -const mockUseAllToolProviders = vi.fn() -vi.mock('@/service/use-tools', () => ({ - useAllToolProviders: (...args: unknown[]) => mockUseAllToolProviders(...args), -})) - -vi.mock('@/utils/var', () => ({ - getMarketplaceUrl: vi.fn(() => 'https://marketplace.test/market'), -})) - -vi.mock('next-themes', () => ({ - useTheme: () => ({ theme: 'light' }), -})) - -const mockGetMarketplaceUrl = vi.mocked(getMarketplaceUrl) - -const createToolProvider = (overrides: Partial<Collection> = {}): Collection => ({ - id: 'provider-1', - name: 'Provider 1', - author: 'Author', - description: { en_US: 'desc', zh_Hans: '描述' }, - icon: 'icon', - label: { en_US: 'label', zh_Hans: '标签' }, - type: CollectionType.custom, - team_credentials: {}, - is_team_authorization: false, - allow_delete: false, - labels: [], - ...overrides, -}) - -const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({ - type: 'plugin', - org: 'org', - author: 'author', - name: 'Plugin One', - plugin_id: 'plugin-1', - version: '1.0.0', - latest_version: '1.0.0', - latest_package_identifier: 'plugin-1@1.0.0', - icon: 'icon', - verified: true, - label: { en_US: 'Plugin One' }, - brief: { en_US: 'Brief' }, - description: { en_US: 'Plugin description' }, - introduction: 'Intro', - repository: 'https://example.com', - category: PluginCategoryEnum.tool, - install_count: 0, - endpoint: { settings: [] }, - tags: [{ name: 'tag' }], - badges: [], - verification: { authorized_category: 'community' }, - from: 'marketplace', - ...overrides, -}) - -const createMarketplaceContext = (overrides: Partial<ReturnType<typeof useMarketplace>> = {}) => ({ - isLoading: false, - marketplaceCollections: [], - marketplaceCollectionPluginsMap: {}, - plugins: [], - handleScroll: vi.fn(), - page: 1, - ...overrides, -}) - -describe('Marketplace', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // Rendering the marketplace panel based on loading and visibility state. - describe('Rendering', () => { - it('should show loading indicator when loading first page', () => { - // Arrange - const marketplaceContext = createMarketplaceContext({ isLoading: true, page: 1 }) - render( - <Marketplace - searchPluginText="" - filterPluginTags={[]} - isMarketplaceArrowVisible={false} - showMarketplacePanel={vi.fn()} - marketplaceContext={marketplaceContext} - />, - ) - - // Assert - expect(document.querySelector('svg.spin-animation')).toBeInTheDocument() - expect(screen.queryByTestId('marketplace-list')).not.toBeInTheDocument() - }) - - it('should render list when not loading', () => { - // Arrange - const marketplaceContext = createMarketplaceContext({ - isLoading: false, - plugins: [createPlugin()], - }) - render( - <Marketplace - searchPluginText="" - filterPluginTags={[]} - isMarketplaceArrowVisible={false} - showMarketplacePanel={vi.fn()} - marketplaceContext={marketplaceContext} - />, - ) - - // Assert - expect(screen.getByTestId('marketplace-list')).toBeInTheDocument() - expect(listRenderSpy).toHaveBeenCalledWith(expect.objectContaining({ - showInstallButton: true, - })) - }) - }) - - // Prop-driven UI output such as links and action triggers. - describe('Props', () => { - it('should build marketplace link and trigger panel when arrow is clicked', async () => { - const user = userEvent.setup() - // Arrange - const marketplaceContext = createMarketplaceContext() - const showMarketplacePanel = vi.fn() - const { container } = render( - <Marketplace - searchPluginText="vector" - filterPluginTags={['tag-a', 'tag-b']} - isMarketplaceArrowVisible - showMarketplacePanel={showMarketplacePanel} - marketplaceContext={marketplaceContext} - />, - ) - - // Act - const arrowIcon = container.querySelector('svg.cursor-pointer') - expect(arrowIcon).toBeTruthy() - await user.click(arrowIcon as SVGElement) - - // Assert - expect(showMarketplacePanel).toHaveBeenCalledTimes(1) - expect(mockGetMarketplaceUrl).toHaveBeenCalledWith('', { - language: 'en', - q: 'vector', - tags: 'tag-a,tag-b', - theme: 'light', - }) - const marketplaceLink = screen.getByRole('link', { name: /plugin.marketplace.difyMarketplace/i }) - expect(marketplaceLink).toHaveAttribute('href', 'https://marketplace.test/market') - }) - }) -}) - -describe('useMarketplace', () => { - const mockQueryMarketplaceCollectionsAndPlugins = vi.fn() - const mockQueryPlugins = vi.fn() - const mockQueryPluginsWithDebounced = vi.fn() - const mockResetPlugins = vi.fn() - const mockFetchNextPage = vi.fn() - - const setupHookMocks = (overrides?: { - isLoading?: boolean - isPluginsLoading?: boolean - pluginsPage?: number - hasNextPage?: boolean - plugins?: Plugin[] | undefined - }) => { - mockUseMarketplaceCollectionsAndPlugins.mockReturnValue({ - isLoading: overrides?.isLoading ?? false, - marketplaceCollections: [], - marketplaceCollectionPluginsMap: {}, - queryMarketplaceCollectionsAndPlugins: mockQueryMarketplaceCollectionsAndPlugins, - }) - mockUseMarketplacePlugins.mockReturnValue({ - plugins: overrides?.plugins, - resetPlugins: mockResetPlugins, - queryPlugins: mockQueryPlugins, - queryPluginsWithDebounced: mockQueryPluginsWithDebounced, - isLoading: overrides?.isPluginsLoading ?? false, - fetchNextPage: mockFetchNextPage, - hasNextPage: overrides?.hasNextPage ?? false, - page: overrides?.pluginsPage, - }) - } - - beforeEach(() => { - vi.clearAllMocks() - mockUseAllToolProviders.mockReturnValue({ - data: [], - isSuccess: true, - }) - setupHookMocks() - }) - - // Query behavior driven by search filters and provider exclusions. - describe('Queries', () => { - it('should query plugins with debounce when search text is provided', async () => { - // Arrange - mockUseAllToolProviders.mockReturnValue({ - data: [ - createToolProvider({ plugin_id: 'plugin-a' }), - createToolProvider({ plugin_id: undefined }), - ], - isSuccess: true, - }) - - // Act - renderHook(() => useMarketplace('alpha', [])) - - // Assert - await waitFor(() => { - expect(mockQueryPluginsWithDebounced).toHaveBeenCalledWith({ - category: PluginCategoryEnum.tool, - query: 'alpha', - tags: [], - exclude: ['plugin-a'], - type: 'plugin', - }) - }) - expect(mockQueryMarketplaceCollectionsAndPlugins).not.toHaveBeenCalled() - expect(mockResetPlugins).not.toHaveBeenCalled() - }) - - it('should query plugins immediately when only tags are provided', async () => { - // Arrange - mockUseAllToolProviders.mockReturnValue({ - data: [createToolProvider({ plugin_id: 'plugin-b' })], - isSuccess: true, - }) - - // Act - renderHook(() => useMarketplace('', ['tag-1'])) - - // Assert - await waitFor(() => { - expect(mockQueryPlugins).toHaveBeenCalledWith({ - category: PluginCategoryEnum.tool, - query: '', - tags: ['tag-1'], - exclude: ['plugin-b'], - type: 'plugin', - }) - }) - }) - - it('should query collections and reset plugins when no filters are provided', async () => { - // Arrange - mockUseAllToolProviders.mockReturnValue({ - data: [createToolProvider({ plugin_id: 'plugin-c' })], - isSuccess: true, - }) - - // Act - renderHook(() => useMarketplace('', [])) - - // Assert - await waitFor(() => { - expect(mockQueryMarketplaceCollectionsAndPlugins).toHaveBeenCalledWith({ - category: PluginCategoryEnum.tool, - condition: getMarketplaceListCondition(PluginCategoryEnum.tool), - exclude: ['plugin-c'], - type: 'plugin', - }) - }) - expect(mockResetPlugins).toHaveBeenCalledTimes(1) - }) - }) - - // State derived from hook inputs and loading signals. - describe('State', () => { - it('should expose combined loading state and fallback page value', () => { - // Arrange - setupHookMocks({ isLoading: true, isPluginsLoading: false, pluginsPage: undefined }) - - // Act - const { result } = renderHook(() => useMarketplace('', [])) - - // Assert - expect(result.current.isLoading).toBe(true) - expect(result.current.page).toBe(1) - }) - }) - - // Scroll handling that triggers pagination when appropriate. - describe('Scroll', () => { - it('should fetch next page when scrolling near bottom with filters', () => { - // Arrange - setupHookMocks({ hasNextPage: true }) - const { result } = renderHook(() => useMarketplace('search', [])) - const event = { - target: { - scrollTop: 100, - scrollHeight: 200, - clientHeight: 100 + SCROLL_BOTTOM_THRESHOLD, - }, - } as unknown as Event - - // Act - act(() => { - result.current.handleScroll(event) - }) - - // Assert - expect(mockFetchNextPage).toHaveBeenCalledTimes(1) - }) - - it('should not fetch next page when no filters are applied', () => { - // Arrange - setupHookMocks({ hasNextPage: true }) - const { result } = renderHook(() => useMarketplace('', [])) - const event = { - target: { - scrollTop: 100, - scrollHeight: 200, - clientHeight: 100 + SCROLL_BOTTOM_THRESHOLD, - }, - } as unknown as Event - - // Act - act(() => { - result.current.handleScroll(event) - }) - - // Assert - expect(mockFetchNextPage).not.toHaveBeenCalled() - }) - }) -}) diff --git a/web/app/components/tools/mcp/create-card.spec.tsx b/web/app/components/tools/mcp/__tests__/create-card.spec.tsx similarity index 98% rename from web/app/components/tools/mcp/create-card.spec.tsx rename to web/app/components/tools/mcp/__tests__/create-card.spec.tsx index 9ddee00460..6e5b4038f4 100644 --- a/web/app/components/tools/mcp/create-card.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/create-card.spec.tsx @@ -3,7 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import NewMCPCard from './create-card' +import NewMCPCard from '../create-card' // Track the mock functions const mockCreateMCP = vi.fn().mockResolvedValue({ id: 'new-mcp-id', name: 'New MCP' }) @@ -22,7 +22,7 @@ type MockMCPModalProps = { onHide: () => void } -vi.mock('./modal', () => ({ +vi.mock('../modal', () => ({ default: ({ show, onConfirm, onHide }: MockMCPModalProps) => { if (!show) return null diff --git a/web/app/components/tools/mcp/headers-input.spec.tsx b/web/app/components/tools/mcp/__tests__/headers-input.spec.tsx similarity index 99% rename from web/app/components/tools/mcp/headers-input.spec.tsx rename to web/app/components/tools/mcp/__tests__/headers-input.spec.tsx index c271268f5f..881beb00f1 100644 --- a/web/app/components/tools/mcp/headers-input.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/headers-input.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import HeadersInput from './headers-input' +import HeadersInput from '../headers-input' describe('HeadersInput', () => { const defaultProps = { diff --git a/web/app/components/tools/mcp/index.spec.tsx b/web/app/components/tools/mcp/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/tools/mcp/index.spec.tsx rename to web/app/components/tools/mcp/__tests__/index.spec.tsx index d48f7efe14..58510dab4c 100644 --- a/web/app/components/tools/mcp/index.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import MCPList from './index' +import MCPList from '../index' type MockProvider = { id: string @@ -22,7 +22,7 @@ vi.mock('@/service/use-tools', () => ({ })) // Mock child components -vi.mock('./create-card', () => ({ +vi.mock('../create-card', () => ({ default: ({ handleCreate }: { handleCreate: (provider: { id: string, name: string }) => void }) => ( <div data-testid="create-card" onClick={() => handleCreate({ id: 'new-id', name: 'New Provider' })}> Create Card @@ -30,7 +30,7 @@ vi.mock('./create-card', () => ({ ), })) -vi.mock('./provider-card', () => ({ +vi.mock('../provider-card', () => ({ default: ({ data, handleSelect, onUpdate, onDeleted }: { data: MockProvider, handleSelect: (id: string) => void, onUpdate: (id: string) => void, onDeleted: () => void }) => { const displayName = typeof data.name === 'string' ? data.name : Object.values(data.name)[0] return ( @@ -43,7 +43,7 @@ vi.mock('./provider-card', () => ({ }, })) -vi.mock('./detail/provider-detail', () => ({ +vi.mock('../detail/provider-detail', () => ({ default: ({ detail, onHide, onUpdate, isTriggerAuthorize, onFirstCreate }: { detail: MockDetail, onHide: () => void, onUpdate: () => void, isTriggerAuthorize: boolean, onFirstCreate: () => void }) => { const displayName = detail?.name ? (typeof detail.name === 'string' ? detail.name : Object.values(detail.name)[0]) diff --git a/web/app/components/tools/mcp/mcp-server-modal.spec.tsx b/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx similarity index 99% rename from web/app/components/tools/mcp/mcp-server-modal.spec.tsx rename to web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx index 62eabd0690..6f5c548ec3 100644 --- a/web/app/components/tools/mcp/mcp-server-modal.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { describe, expect, it, vi } from 'vitest' -import MCPServerModal from './mcp-server-modal' +import MCPServerModal from '../mcp-server-modal' // Mock the services vi.mock('@/service/use-tools', () => ({ diff --git a/web/app/components/tools/mcp/mcp-server-param-item.spec.tsx b/web/app/components/tools/mcp/__tests__/mcp-server-param-item.spec.tsx similarity index 99% rename from web/app/components/tools/mcp/mcp-server-param-item.spec.tsx rename to web/app/components/tools/mcp/__tests__/mcp-server-param-item.spec.tsx index 6e3a48e330..d7de650df8 100644 --- a/web/app/components/tools/mcp/mcp-server-param-item.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/mcp-server-param-item.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import MCPServerParamItem from './mcp-server-param-item' +import MCPServerParamItem from '../mcp-server-param-item' describe('MCPServerParamItem', () => { const defaultProps = { diff --git a/web/app/components/tools/mcp/mcp-service-card.spec.tsx b/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx similarity index 99% rename from web/app/components/tools/mcp/mcp-service-card.spec.tsx rename to web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx index 25e5d6d570..bc170ad2cd 100644 --- a/web/app/components/tools/mcp/mcp-service-card.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx @@ -7,7 +7,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AppModeEnum } from '@/types/app' -import MCPServiceCard from './mcp-service-card' +import MCPServiceCard from '../mcp-service-card' // Mock MCPServerModal vi.mock('@/app/components/tools/mcp/mcp-server-modal', () => ({ @@ -96,7 +96,7 @@ const createDefaultHookState = (): MockHookState => ({ let mockHookState = createDefaultHookState() // Mock the hook - uses mockHookState which can be modified per test -vi.mock('./hooks/use-mcp-service-card', () => ({ +vi.mock('../hooks/use-mcp-service-card', () => ({ useMCPServiceCardState: () => ({ ...mockHookState, handleStatusChange: mockHandleStatusChange, diff --git a/web/app/components/tools/mcp/modal.spec.tsx b/web/app/components/tools/mcp/__tests__/modal.spec.tsx similarity index 99% rename from web/app/components/tools/mcp/modal.spec.tsx rename to web/app/components/tools/mcp/__tests__/modal.spec.tsx index c2fe8b46c3..af24ba6061 100644 --- a/web/app/components/tools/mcp/modal.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/modal.spec.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { describe, expect, it, vi } from 'vitest' -import MCPModal from './modal' +import MCPModal from '../modal' // Mock the service API vi.mock('@/service/common', () => ({ diff --git a/web/app/components/tools/mcp/provider-card.spec.tsx b/web/app/components/tools/mcp/__tests__/provider-card.spec.tsx similarity index 99% rename from web/app/components/tools/mcp/provider-card.spec.tsx rename to web/app/components/tools/mcp/__tests__/provider-card.spec.tsx index 216607ce5a..d8f644112e 100644 --- a/web/app/components/tools/mcp/provider-card.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/provider-card.spec.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import MCPCard from './provider-card' +import MCPCard from '../provider-card' // Mutable mock functions const mockUpdateMCP = vi.fn().mockResolvedValue({ result: 'success' }) @@ -32,7 +32,7 @@ type MCPModalProps = { onHide: () => void } -vi.mock('./modal', () => ({ +vi.mock('../modal', () => ({ default: ({ show, onConfirm, onHide }: MCPModalProps) => { if (!show) return null @@ -81,7 +81,7 @@ type OperationDropdownProps = { onOpenChange: (open: boolean) => void } -vi.mock('./detail/operation-dropdown', () => ({ +vi.mock('../detail/operation-dropdown', () => ({ default: ({ onEdit, onRemove, onOpenChange }: OperationDropdownProps) => ( <div data-testid="operation-dropdown"> <button diff --git a/web/app/components/tools/mcp/detail/content.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx similarity index 99% rename from web/app/components/tools/mcp/detail/content.spec.tsx rename to web/app/components/tools/mcp/detail/__tests__/content.spec.tsx index fe3fbd2bc3..20a590459b 100644 --- a/web/app/components/tools/mcp/detail/content.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import MCPDetailContent from './content' +import MCPDetailContent from '../content' // Mutable mock functions const mockUpdateTools = vi.fn().mockResolvedValue({}) @@ -67,7 +67,7 @@ type MCPModalProps = { onHide: () => void } -vi.mock('../modal', () => ({ +vi.mock('../../modal', () => ({ default: ({ show, onConfirm, onHide }: MCPModalProps) => { if (!show) return null @@ -99,7 +99,7 @@ vi.mock('@/app/components/base/confirm', () => ({ })) // Mock OperationDropdown -vi.mock('./operation-dropdown', () => ({ +vi.mock('../operation-dropdown', () => ({ default: ({ onEdit, onRemove }: { onEdit: () => void, onRemove: () => void }) => ( <div data-testid="operation-dropdown"> <button data-testid="edit-btn" onClick={onEdit}>Edit</button> @@ -113,7 +113,7 @@ type ToolItemData = { name: string } -vi.mock('./tool-item', () => ({ +vi.mock('../tool-item', () => ({ default: ({ tool }: { tool: ToolItemData }) => ( <div data-testid="tool-item">{tool.name}</div> ), diff --git a/web/app/components/tools/mcp/detail/list-loading.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/list-loading.spec.tsx similarity index 98% rename from web/app/components/tools/mcp/detail/list-loading.spec.tsx rename to web/app/components/tools/mcp/detail/__tests__/list-loading.spec.tsx index 679d4322d9..79fb8282b0 100644 --- a/web/app/components/tools/mcp/detail/list-loading.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/list-loading.spec.tsx @@ -1,6 +1,6 @@ import { render } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import ListLoading from './list-loading' +import ListLoading from '../list-loading' describe('ListLoading', () => { describe('Rendering', () => { diff --git a/web/app/components/tools/mcp/detail/operation-dropdown.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/operation-dropdown.spec.tsx similarity index 99% rename from web/app/components/tools/mcp/detail/operation-dropdown.spec.tsx rename to web/app/components/tools/mcp/detail/__tests__/operation-dropdown.spec.tsx index 077bdc3efe..0b4773f796 100644 --- a/web/app/components/tools/mcp/detail/operation-dropdown.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/operation-dropdown.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import OperationDropdown from './operation-dropdown' +import OperationDropdown from '../operation-dropdown' describe('OperationDropdown', () => { const defaultProps = { diff --git a/web/app/components/tools/mcp/detail/provider-detail.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/provider-detail.spec.tsx similarity index 98% rename from web/app/components/tools/mcp/detail/provider-detail.spec.tsx rename to web/app/components/tools/mcp/detail/__tests__/provider-detail.spec.tsx index dc8a427498..05380916b2 100644 --- a/web/app/components/tools/mcp/detail/provider-detail.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/provider-detail.spec.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { describe, expect, it, vi } from 'vitest' -import MCPDetailPanel from './provider-detail' +import MCPDetailPanel from '../provider-detail' // Mock the drawer component vi.mock('@/app/components/base/drawer', () => ({ @@ -16,7 +16,7 @@ vi.mock('@/app/components/base/drawer', () => ({ })) // Mock the content component to expose onUpdate callback -vi.mock('./content', () => ({ +vi.mock('../content', () => ({ default: ({ detail, onUpdate }: { detail: ToolWithProvider, onUpdate: (isDelete?: boolean) => void }) => ( <div data-testid="mcp-detail-content"> {detail.name} diff --git a/web/app/components/tools/mcp/detail/tool-item.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/tool-item.spec.tsx similarity index 99% rename from web/app/components/tools/mcp/detail/tool-item.spec.tsx rename to web/app/components/tools/mcp/detail/__tests__/tool-item.spec.tsx index aa04422b48..edbbf3e9a3 100644 --- a/web/app/components/tools/mcp/detail/tool-item.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/tool-item.spec.tsx @@ -1,7 +1,7 @@ import type { Tool } from '@/app/components/tools/types' import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import MCPToolItem from './tool-item' +import MCPToolItem from '../tool-item' describe('MCPToolItem', () => { const createMockTool = (overrides = {}): Tool => ({ diff --git a/web/app/components/tools/mcp/hooks/use-mcp-modal-form.spec.ts b/web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts similarity index 99% rename from web/app/components/tools/mcp/hooks/use-mcp-modal-form.spec.ts rename to web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts index 72520e11d1..f44e14d608 100644 --- a/web/app/components/tools/mcp/hooks/use-mcp-modal-form.spec.ts +++ b/web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts @@ -3,7 +3,7 @@ import type { ToolWithProvider } from '@/app/components/workflow/types' import { act, renderHook } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { MCPAuthMethod } from '@/app/components/tools/types' -import { isValidServerID, isValidUrl, useMCPModalForm } from './use-mcp-modal-form' +import { isValidServerID, isValidUrl, useMCPModalForm } from '../use-mcp-modal-form' // Mock the API service vi.mock('@/service/common', () => ({ diff --git a/web/app/components/tools/mcp/hooks/use-mcp-service-card.spec.ts b/web/app/components/tools/mcp/hooks/__tests__/use-mcp-service-card.spec.ts similarity index 99% rename from web/app/components/tools/mcp/hooks/use-mcp-service-card.spec.ts rename to web/app/components/tools/mcp/hooks/__tests__/use-mcp-service-card.spec.ts index b36f724857..a11365e445 100644 --- a/web/app/components/tools/mcp/hooks/use-mcp-service-card.spec.ts +++ b/web/app/components/tools/mcp/hooks/__tests__/use-mcp-service-card.spec.ts @@ -6,7 +6,7 @@ import { act, renderHook } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AppModeEnum } from '@/types/app' -import { useMCPServiceCardState } from './use-mcp-service-card' +import { useMCPServiceCardState } from '../use-mcp-service-card' // Mutable mock data for MCP server detail let mockMCPServerDetailData: { diff --git a/web/app/components/tools/mcp/sections/authentication-section.spec.tsx b/web/app/components/tools/mcp/sections/__tests__/authentication-section.spec.tsx similarity index 98% rename from web/app/components/tools/mcp/sections/authentication-section.spec.tsx rename to web/app/components/tools/mcp/sections/__tests__/authentication-section.spec.tsx index ec5c8f0443..f5ed16f21d 100644 --- a/web/app/components/tools/mcp/sections/authentication-section.spec.tsx +++ b/web/app/components/tools/mcp/sections/__tests__/authentication-section.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import AuthenticationSection from './authentication-section' +import AuthenticationSection from '../authentication-section' describe('AuthenticationSection', () => { const defaultProps = { diff --git a/web/app/components/tools/mcp/sections/configurations-section.spec.tsx b/web/app/components/tools/mcp/sections/__tests__/configurations-section.spec.tsx similarity index 98% rename from web/app/components/tools/mcp/sections/configurations-section.spec.tsx rename to web/app/components/tools/mcp/sections/__tests__/configurations-section.spec.tsx index 16e64d206e..4b6bc4009e 100644 --- a/web/app/components/tools/mcp/sections/configurations-section.spec.tsx +++ b/web/app/components/tools/mcp/sections/__tests__/configurations-section.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import ConfigurationsSection from './configurations-section' +import ConfigurationsSection from '../configurations-section' describe('ConfigurationsSection', () => { const defaultProps = { diff --git a/web/app/components/tools/mcp/sections/headers-section.spec.tsx b/web/app/components/tools/mcp/sections/__tests__/headers-section.spec.tsx similarity index 99% rename from web/app/components/tools/mcp/sections/headers-section.spec.tsx rename to web/app/components/tools/mcp/sections/__tests__/headers-section.spec.tsx index ae58e6cec5..b71ba0ca04 100644 --- a/web/app/components/tools/mcp/sections/headers-section.spec.tsx +++ b/web/app/components/tools/mcp/sections/__tests__/headers-section.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import HeadersSection from './headers-section' +import HeadersSection from '../headers-section' describe('HeadersSection', () => { const defaultProps = { diff --git a/web/app/components/tools/provider/custom-create-card.spec.tsx b/web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx similarity index 98% rename from web/app/components/tools/provider/custom-create-card.spec.tsx rename to web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx index 5bfe3c00c0..3643b769f7 100644 --- a/web/app/components/tools/provider/custom-create-card.spec.tsx +++ b/web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx @@ -1,8 +1,8 @@ -import type { CustomCollectionBackend } from '../types' +import type { CustomCollectionBackend } from '../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { AuthType } from '../types' -import CustomCreateCard from './custom-create-card' +import { AuthType } from '../../types' +import CustomCreateCard from '../custom-create-card' // Mock workspace manager state let mockIsWorkspaceManager = true diff --git a/web/app/components/tools/provider/__tests__/detail.spec.tsx b/web/app/components/tools/provider/__tests__/detail.spec.tsx new file mode 100644 index 0000000000..f2d47f8e43 --- /dev/null +++ b/web/app/components/tools/provider/__tests__/detail.spec.tsx @@ -0,0 +1,713 @@ +import type { Collection } from '../../types' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AuthType, CollectionType } from '../../types' +import ProviderDetail from '../detail' + +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'en-US', +})) + +vi.mock('@/i18n-config/language', () => ({ + getLanguage: () => 'en_US', +})) + +const mockIsCurrentWorkspaceManager = vi.fn(() => true) +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(), + }), +})) + +const mockSetShowModelModal = vi.fn() +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowModelModal: mockSetShowModelModal, + }), +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + modelProviders: [ + { provider: 'model-collection-id', name: 'TestModel' }, + ], + }), +})) + +const mockFetchBuiltInToolList = vi.fn().mockResolvedValue([]) +const mockFetchCustomToolList = vi.fn().mockResolvedValue([]) +const mockFetchModelToolList = vi.fn().mockResolvedValue([]) +const mockFetchCustomCollection = vi.fn().mockResolvedValue({ + credentials: { auth_type: 'none' }, +}) +const mockFetchWorkflowToolDetail = vi.fn().mockResolvedValue({ + workflow_app_id: 'wf-123', + workflow_tool_id: 'wt-456', + tool: { parameters: [], labels: [] }, +}) +const mockUpdateBuiltInToolCredential = vi.fn().mockResolvedValue({}) +const mockRemoveBuiltInToolCredential = vi.fn().mockResolvedValue({}) +const mockUpdateCustomCollection = vi.fn().mockResolvedValue({}) +const mockRemoveCustomCollection = vi.fn().mockResolvedValue({}) +const mockDeleteWorkflowTool = vi.fn().mockResolvedValue({}) +const mockSaveWorkflowToolProvider = vi.fn().mockResolvedValue({}) + +vi.mock('@/service/tools', () => ({ + fetchBuiltInToolList: (...args: unknown[]) => mockFetchBuiltInToolList(...args), + fetchCustomToolList: (...args: unknown[]) => mockFetchCustomToolList(...args), + fetchModelToolList: (...args: unknown[]) => mockFetchModelToolList(...args), + fetchCustomCollection: (...args: unknown[]) => mockFetchCustomCollection(...args), + fetchWorkflowToolDetail: (...args: unknown[]) => mockFetchWorkflowToolDetail(...args), + updateBuiltInToolCredential: (...args: unknown[]) => mockUpdateBuiltInToolCredential(...args), + removeBuiltInToolCredential: (...args: unknown[]) => mockRemoveBuiltInToolCredential(...args), + updateCustomCollection: (...args: unknown[]) => mockUpdateCustomCollection(...args), + removeCustomCollection: (...args: unknown[]) => mockRemoveCustomCollection(...args), + deleteWorkflowTool: (...args: unknown[]) => mockDeleteWorkflowTool(...args), + saveWorkflowToolProvider: (...args: unknown[]) => mockSaveWorkflowToolProvider(...args), +})) + +vi.mock('@/service/use-tools', () => ({ + useInvalidateAllWorkflowTools: () => vi.fn(), +})) + +vi.mock('@/utils/var', () => ({ + basePath: '', +})) + +vi.mock('@/app/components/base/drawer', () => ({ + default: ({ children, isOpen }: { children: React.ReactNode, isOpen: boolean }) => + isOpen ? <div data-testid="drawer">{children}</div> : null, +})) + +vi.mock('@/app/components/base/confirm', () => ({ + default: ({ isShow, onConfirm, onCancel, title }: { isShow: boolean, onConfirm: () => void, onCancel: () => void, title: string }) => + isShow + ? ( + <div data-testid="confirm-dialog"> + <span>{title}</span> + <button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button> + <button data-testid="cancel-btn" onClick={onCancel}>Cancel</button> + </div> + ) + : null, +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: vi.fn() }, +})) + +vi.mock('@/app/components/header/indicator', () => ({ + default: () => <span data-testid="indicator" />, +})) + +vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ + default: () => <span data-testid="card-icon" />, +})) + +vi.mock('@/app/components/plugins/card/base/description', () => ({ + default: ({ text }: { text: string }) => <div data-testid="description">{text}</div>, +})) + +vi.mock('@/app/components/plugins/card/base/org-info', () => ({ + default: ({ orgName }: { orgName: string }) => <span data-testid="org-info">{orgName}</span>, +})) + +vi.mock('@/app/components/plugins/card/base/title', () => ({ + default: ({ title }: { title: string }) => <span data-testid="title">{title}</span>, +})) + +vi.mock('../tool-item', () => ({ + default: ({ tool }: { tool: { name: string } }) => <div data-testid={`tool-${tool.name}`}>{tool.name}</div>, +})) + +vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({ + default: ({ onHide, onEdit, onRemove }: { onHide: () => void, onEdit: (data: unknown) => void, onRemove: () => void }) => ( + <div data-testid="edit-custom-modal"> + <button data-testid="edit-save" onClick={() => onEdit({ labels: ['test'] })}>Save</button> + <button data-testid="edit-remove" onClick={onRemove}>Remove</button> + <button data-testid="edit-close" onClick={onHide}>Close</button> + </div> + ), +})) + +vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({ + default: ({ onCancel, onSaved, onRemove }: { onCancel: () => void, onSaved: (val: Record<string, string>) => Promise<void>, onRemove: () => Promise<void> }) => ( + <div data-testid="config-credential"> + <button data-testid="credential-save" onClick={() => onSaved({ key: 'val' })}>Save</button> + <button data-testid="credential-remove" onClick={onRemove}>Remove</button> + <button data-testid="credential-cancel" onClick={onCancel}>Cancel</button> + </div> + ), +})) + +vi.mock('@/app/components/tools/workflow-tool', () => ({ + default: ({ onHide, onSave, onRemove }: { onHide: () => void, onSave: (data: unknown) => void, onRemove: () => void }) => ( + <div data-testid="workflow-tool-modal"> + <button data-testid="wf-save" onClick={() => onSave({ name: 'test' })}>Save</button> + <button data-testid="wf-remove" onClick={onRemove}>Remove</button> + <button data-testid="wf-close" onClick={onHide}>Close</button> + </div> + ), +})) + +const createMockCollection = (overrides?: Partial<Collection>): Collection => ({ + id: 'test-id', + name: 'test-collection', + author: 'Test Author', + description: { en_US: 'A test collection', zh_Hans: '测试集合' }, + icon: 'icon-url', + label: { en_US: 'Test Collection', zh_Hans: '测试集合' }, + type: CollectionType.builtIn, + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: ['search'], + ...overrides, +}) + +describe('ProviderDetail', () => { + const mockOnHide = vi.fn() + const mockOnRefreshData = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockFetchBuiltInToolList.mockResolvedValue([ + { name: 'tool-1', label: { en_US: 'Tool 1' }, description: { en_US: 'desc' }, parameters: [], labels: [], author: '', output_schema: {} }, + { name: 'tool-2', label: { en_US: 'Tool 2' }, description: { en_US: 'desc' }, parameters: [], labels: [], author: '', output_schema: {} }, + ]) + mockFetchCustomToolList.mockResolvedValue([]) + mockFetchModelToolList.mockResolvedValue([]) + }) + + afterEach(() => { + cleanup() + }) + + describe('Rendering', () => { + it('renders title, org info and description for a builtIn collection', async () => { + render( + <ProviderDetail + collection={createMockCollection()} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + expect(screen.getByTestId('title')).toHaveTextContent('Test Collection') + expect(screen.getByTestId('org-info')).toHaveTextContent('Test Author') + expect(screen.getByTestId('description')).toHaveTextContent('A test collection') + }) + + it('shows loading state initially', () => { + render( + <ProviderDetail + collection={createMockCollection()} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('renders tool list after loading for builtIn type', async () => { + render( + <ProviderDetail + collection={createMockCollection()} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByTestId('tool-tool-1')).toBeInTheDocument() + expect(screen.getByTestId('tool-tool-2')).toBeInTheDocument() + }) + }) + + it('hides description when description is empty', () => { + render( + <ProviderDetail + collection={createMockCollection({ description: { en_US: '', zh_Hans: '' } })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + expect(screen.queryByTestId('description')).not.toBeInTheDocument() + }) + }) + + describe('BuiltIn Collection Auth', () => { + it('shows "Set up credentials" button when not authorized and allow_delete', async () => { + render( + <ProviderDetail + collection={createMockCollection({ allow_delete: true, is_team_authorization: false })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument() + }) + }) + + it('shows "Authorized" button when authorized and allow_delete', async () => { + render( + <ProviderDetail + collection={createMockCollection({ allow_delete: true, is_team_authorization: true })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.auth.authorized')).toBeInTheDocument() + }) + }) + }) + + describe('Custom Collection', () => { + it('fetches custom collection and shows edit button', async () => { + mockFetchCustomCollection.mockResolvedValue({ + credentials: { auth_type: 'none' }, + }) + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.custom })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(mockFetchCustomCollection).toHaveBeenCalledWith('test-collection') + }) + await waitFor(() => { + expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument() + }) + }) + }) + + describe('Workflow Collection', () => { + it('fetches workflow tool detail and shows workflow buttons', async () => { + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.workflow })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(mockFetchWorkflowToolDetail).toHaveBeenCalledWith('test-id') + }) + await waitFor(() => { + expect(screen.getByText('tools.openInStudio')).toBeInTheDocument() + expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument() + }) + }) + }) + + describe('Model Collection', () => { + it('opens model modal when clicking auth button for model type', async () => { + mockFetchModelToolList.mockResolvedValue([ + { name: 'model-tool-1', label: { en_US: 'MT1' }, description: { en_US: '' }, parameters: [], labels: [], author: '', output_schema: {} }, + ]) + render( + <ProviderDetail + collection={createMockCollection({ + id: 'model-collection-id', + type: CollectionType.model, + is_team_authorization: false, + allow_delete: true, + })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.auth.unauthorized')) + expect(mockSetShowModelModal).toHaveBeenCalled() + }) + }) + + describe('Close Action', () => { + it('calls onHide when close button is clicked', () => { + render( + <ProviderDetail + collection={createMockCollection()} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[0]) + expect(mockOnHide).toHaveBeenCalled() + }) + }) + + describe('API calls by collection type', () => { + it('calls fetchBuiltInToolList for builtIn type', async () => { + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.builtIn })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(mockFetchBuiltInToolList).toHaveBeenCalledWith('test-collection') + }) + }) + + it('calls fetchModelToolList for model type', async () => { + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.model })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(mockFetchModelToolList).toHaveBeenCalledWith('test-collection') + }) + }) + + it('calls fetchCustomToolList for custom type', async () => { + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.custom })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(mockFetchCustomToolList).toHaveBeenCalledWith('test-collection') + }) + }) + }) + + describe('BuiltIn Auth Flow', () => { + it('opens ConfigCredential when clicking auth button for builtIn type', async () => { + render( + <ProviderDetail + collection={createMockCollection({ allow_delete: true, is_team_authorization: false })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.auth.unauthorized')) + expect(screen.getByTestId('config-credential')).toBeInTheDocument() + }) + + it('saves credentials and refreshes data', async () => { + render( + <ProviderDetail + collection={createMockCollection({ allow_delete: true, is_team_authorization: false })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.auth.unauthorized')) + await act(async () => { + fireEvent.click(screen.getByTestId('credential-save')) + }) + await waitFor(() => { + expect(mockUpdateBuiltInToolCredential).toHaveBeenCalledWith('test-collection', { key: 'val' }) + expect(mockOnRefreshData).toHaveBeenCalled() + }) + }) + + it('removes credentials and refreshes data', async () => { + render( + <ProviderDetail + collection={createMockCollection({ allow_delete: true, is_team_authorization: false })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.auth.unauthorized')) + await act(async () => { + fireEvent.click(screen.getByTestId('credential-remove')) + }) + await waitFor(() => { + expect(mockRemoveBuiltInToolCredential).toHaveBeenCalledWith('test-collection') + expect(mockOnRefreshData).toHaveBeenCalled() + }) + }) + + it('opens auth modal from Authorized button for builtIn type', async () => { + render( + <ProviderDetail + collection={createMockCollection({ allow_delete: true, is_team_authorization: true })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.auth.authorized')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.auth.authorized')) + expect(screen.getByTestId('config-credential')).toBeInTheDocument() + }) + }) + + describe('Model Auth Flow', () => { + it('calls onRefreshData via model modal onSaveCallback', async () => { + render( + <ProviderDetail + collection={createMockCollection({ + id: 'model-collection-id', + type: CollectionType.model, + is_team_authorization: false, + allow_delete: true, + })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.auth.unauthorized')) + const call = mockSetShowModelModal.mock.calls[0][0] + act(() => { + call.onSaveCallback() + }) + expect(mockOnRefreshData).toHaveBeenCalled() + }) + }) + + describe('Custom Collection Operations', () => { + it('sets api_key_header_prefix when auth_type is apiKey and has value', async () => { + mockFetchCustomCollection.mockResolvedValue({ + credentials: { + auth_type: AuthType.apiKey, + api_key_value: 'secret-key', + }, + }) + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.custom })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(mockFetchCustomCollection).toHaveBeenCalled() + }) + await waitFor(() => { + expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument() + }) + }) + + it('opens edit modal and saves custom collection', async () => { + mockFetchCustomCollection.mockResolvedValue({ + credentials: { auth_type: 'none' }, + }) + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.custom })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.createTool.editAction')) + expect(screen.getByTestId('edit-custom-modal')).toBeInTheDocument() + await act(async () => { + fireEvent.click(screen.getByTestId('edit-save')) + }) + await waitFor(() => { + expect(mockUpdateCustomCollection).toHaveBeenCalledWith({ labels: ['test'] }) + expect(mockOnRefreshData).toHaveBeenCalled() + }) + }) + + it('removes custom collection via delete confirmation', async () => { + mockFetchCustomCollection.mockResolvedValue({ + credentials: { auth_type: 'none' }, + }) + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.custom })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.createTool.editAction')) + fireEvent.click(screen.getByTestId('edit-remove')) + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument() + await act(async () => { + fireEvent.click(screen.getByTestId('confirm-btn')) + }) + await waitFor(() => { + expect(mockRemoveCustomCollection).toHaveBeenCalledWith('test-collection') + expect(mockOnRefreshData).toHaveBeenCalled() + }) + }) + }) + + describe('Workflow Collection Operations', () => { + it('displays workflow tool parameters', async () => { + mockFetchWorkflowToolDetail.mockResolvedValue({ + workflow_app_id: 'wf-123', + workflow_tool_id: 'wt-456', + tool: { + parameters: [ + { name: 'query', type: 'string', llm_description: 'Search query', form: 'llm', required: true }, + { name: 'limit', type: 'number', llm_description: 'Max results', form: 'form', required: false }, + ], + labels: ['search'], + }, + }) + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.workflow })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('query')).toBeInTheDocument() + expect(screen.getByText('string')).toBeInTheDocument() + expect(screen.getByText('Search query')).toBeInTheDocument() + expect(screen.getByText('limit')).toBeInTheDocument() + }) + }) + + it('saves workflow tool via workflow modal', async () => { + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.workflow })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.createTool.editAction')) + expect(screen.getByTestId('workflow-tool-modal')).toBeInTheDocument() + await act(async () => { + fireEvent.click(screen.getByTestId('wf-save')) + }) + await waitFor(() => { + expect(mockSaveWorkflowToolProvider).toHaveBeenCalledWith({ name: 'test' }) + expect(mockOnRefreshData).toHaveBeenCalled() + }) + }) + + it('removes workflow tool via delete confirmation', async () => { + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.workflow })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.createTool.editAction')) + fireEvent.click(screen.getByTestId('wf-remove')) + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument() + await act(async () => { + fireEvent.click(screen.getByTestId('confirm-btn')) + }) + await waitFor(() => { + expect(mockDeleteWorkflowTool).toHaveBeenCalledWith('test-id') + expect(mockOnRefreshData).toHaveBeenCalled() + }) + }) + }) + + describe('Modal Close Actions', () => { + it('closes ConfigCredential when cancel is clicked', async () => { + render( + <ProviderDetail + collection={createMockCollection({ allow_delete: true, is_team_authorization: false })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.auth.unauthorized')) + expect(screen.getByTestId('config-credential')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('credential-cancel')) + expect(screen.queryByTestId('config-credential')).not.toBeInTheDocument() + }) + + it('closes EditCustomToolModal via onHide', async () => { + mockFetchCustomCollection.mockResolvedValue({ + credentials: { auth_type: 'none' }, + }) + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.custom })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.createTool.editAction')) + expect(screen.getByTestId('edit-custom-modal')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('edit-close')) + expect(screen.queryByTestId('edit-custom-modal')).not.toBeInTheDocument() + }) + + it('closes WorkflowToolModal via onHide', async () => { + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.workflow })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.createTool.editAction')) + expect(screen.getByTestId('workflow-tool-modal')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('wf-close')) + expect(screen.queryByTestId('workflow-tool-modal')).not.toBeInTheDocument() + }) + }) + + describe('Delete Confirmation', () => { + it('cancels delete confirmation', async () => { + mockFetchCustomCollection.mockResolvedValue({ + credentials: { auth_type: 'none' }, + }) + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.custom })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.createTool.editAction')) + fireEvent.click(screen.getByTestId('edit-remove')) + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('cancel-btn')) + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/tools/provider/empty.spec.tsx b/web/app/components/tools/provider/__tests__/empty.spec.tsx similarity index 98% rename from web/app/components/tools/provider/empty.spec.tsx rename to web/app/components/tools/provider/__tests__/empty.spec.tsx index 7d0bedbd12..7484f99895 100644 --- a/web/app/components/tools/provider/empty.spec.tsx +++ b/web/app/components/tools/provider/__tests__/empty.spec.tsx @@ -2,9 +2,9 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' // Import the mock to control it in tests import useTheme from '@/hooks/use-theme' -import { ToolTypeEnum } from '../../workflow/block-selector/types' +import { ToolTypeEnum } from '../../../workflow/block-selector/types' -import Empty from './empty' +import Empty from '../empty' // Mock useTheme hook vi.mock('@/hooks/use-theme', () => ({ diff --git a/web/app/components/tools/provider/tool-item.spec.tsx b/web/app/components/tools/provider/__tests__/tool-item.spec.tsx similarity index 99% rename from web/app/components/tools/provider/tool-item.spec.tsx rename to web/app/components/tools/provider/__tests__/tool-item.spec.tsx index e2771a0086..d32cf80807 100644 --- a/web/app/components/tools/provider/tool-item.spec.tsx +++ b/web/app/components/tools/provider/__tests__/tool-item.spec.tsx @@ -1,7 +1,7 @@ -import type { Collection, Tool } from '../types' +import type { Collection, Tool } from '../../types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import ToolItem from './tool-item' +import ToolItem from '../tool-item' // Mock useLocale hook vi.mock('@/context/i18n', () => ({ diff --git a/web/app/components/tools/setting/build-in/__tests__/config-credentials.spec.tsx b/web/app/components/tools/setting/build-in/__tests__/config-credentials.spec.tsx new file mode 100644 index 0000000000..00b583b32c --- /dev/null +++ b/web/app/components/tools/setting/build-in/__tests__/config-credentials.spec.tsx @@ -0,0 +1,188 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import ConfigCredential from '../config-credentials' + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useLanguage: () => 'en_US', +})) + +const mockFetchCredentialSchema = vi.fn() +const mockFetchCredentialValue = vi.fn() + +vi.mock('@/service/tools', () => ({ + fetchBuiltInToolCredentialSchema: (...args: unknown[]) => mockFetchCredentialSchema(...args), + fetchBuiltInToolCredential: (...args: unknown[]) => mockFetchCredentialValue(...args), +})) + +vi.mock('../../../utils/to-form-schema', () => ({ + toolCredentialToFormSchemas: (schemas: unknown[]) => (schemas as Record<string, unknown>[]).map(s => ({ + ...s, + variable: s.name, + show_on: [], + })), + addDefaultValue: (value: Record<string, unknown>, _schemas: unknown[]) => ({ ...value }), +})) + +vi.mock('@/app/components/base/drawer-plus', () => ({ + default: ({ body, title, onHide }: { body: React.ReactNode, title: string, onHide: () => void }) => ( + <div data-testid="drawer"> + <span data-testid="drawer-title">{title}</span> + <button data-testid="drawer-close" onClick={onHide}>Close</button> + {body} + </div> + ), +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: vi.fn() }, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({ + default: ({ value, onChange }: { value: Record<string, string>, onChange: (v: Record<string, string>) => void }) => ( + <div data-testid="form"> + <input + data-testid="form-input" + value={value.api_key || ''} + onChange={e => onChange({ ...value, api_key: e.target.value })} + /> + </div> + ), +})) + +const createMockCollection = (overrides?: Record<string, unknown>) => ({ + id: 'test-collection', + name: 'test-tool', + author: 'Test', + description: { en_US: 'Test', zh_Hans: '测试' }, + icon: '', + label: { en_US: 'Test', zh_Hans: '测试' }, + type: 'builtin', + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: [], + ...overrides, +}) + +describe('ConfigCredential', () => { + const mockOnCancel = vi.fn() + const mockOnSaved = vi.fn().mockResolvedValue(undefined) + const mockOnRemove = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockFetchCredentialSchema.mockResolvedValue([ + { name: 'api_key', label: { en_US: 'API Key' }, type: 'secret-input', required: true }, + ]) + mockFetchCredentialValue.mockResolvedValue({ api_key: 'sk-existing' }) + }) + + afterEach(() => { + cleanup() + }) + + it('shows loading state initially then renders form', async () => { + render( + <ConfigCredential + collection={createMockCollection() as never} + onCancel={mockOnCancel} + onSaved={mockOnSaved} + />, + ) + expect(screen.getByRole('status')).toBeInTheDocument() + + await waitFor(() => { + expect(screen.getByTestId('form')).toBeInTheDocument() + }) + }) + + it('renders drawer with correct title', async () => { + render( + <ConfigCredential + collection={createMockCollection() as never} + onCancel={mockOnCancel} + onSaved={mockOnSaved} + />, + ) + expect(screen.getByTestId('drawer-title')).toHaveTextContent('tools.auth.setupModalTitle') + }) + + it('calls onCancel when cancel button is clicked', async () => { + render( + <ConfigCredential + collection={createMockCollection() as never} + onCancel={mockOnCancel} + onSaved={mockOnSaved} + />, + ) + await waitFor(() => { + expect(screen.getByTestId('form')).toBeInTheDocument() + }) + const cancelBtn = screen.getByText('common.operation.cancel') + fireEvent.click(cancelBtn) + expect(mockOnCancel).toHaveBeenCalled() + }) + + it('calls onSaved with credential values when save is clicked', async () => { + render( + <ConfigCredential + collection={createMockCollection() as never} + onCancel={mockOnCancel} + onSaved={mockOnSaved} + />, + ) + await waitFor(() => { + expect(screen.getByTestId('form')).toBeInTheDocument() + }) + const saveBtn = screen.getByText('common.operation.save') + fireEvent.click(saveBtn) + await waitFor(() => { + expect(mockOnSaved).toHaveBeenCalledWith(expect.objectContaining({ api_key: 'sk-existing' })) + }) + }) + + it('shows remove button when team is authorized and isHideRemoveBtn is false', async () => { + render( + <ConfigCredential + collection={createMockCollection({ is_team_authorization: true }) as never} + onCancel={mockOnCancel} + onSaved={mockOnSaved} + onRemove={mockOnRemove} + />, + ) + await waitFor(() => { + expect(screen.getByTestId('form')).toBeInTheDocument() + }) + expect(screen.getByText('common.operation.remove')).toBeInTheDocument() + }) + + it('hides remove button when isHideRemoveBtn is true', async () => { + render( + <ConfigCredential + collection={createMockCollection({ is_team_authorization: true }) as never} + onCancel={mockOnCancel} + onSaved={mockOnSaved} + onRemove={mockOnRemove} + isHideRemoveBtn + />, + ) + await waitFor(() => { + expect(screen.getByTestId('form')).toBeInTheDocument() + }) + expect(screen.queryByText('common.operation.remove')).not.toBeInTheDocument() + }) + + it('fetches credential schema for the collection name', async () => { + render( + <ConfigCredential + collection={createMockCollection() as never} + onCancel={mockOnCancel} + onSaved={mockOnSaved} + />, + ) + await waitFor(() => { + expect(mockFetchCredentialSchema).toHaveBeenCalledWith('test-tool') + expect(mockFetchCredentialValue).toHaveBeenCalledWith('test-tool') + }) + }) +}) diff --git a/web/app/components/tools/utils/__tests__/index.spec.ts b/web/app/components/tools/utils/__tests__/index.spec.ts new file mode 100644 index 0000000000..829846bc86 --- /dev/null +++ b/web/app/components/tools/utils/__tests__/index.spec.ts @@ -0,0 +1,82 @@ +import type { ThoughtItem } from '@/app/components/base/chat/chat/type' +import type { FileEntity } from '@/app/components/base/file-uploader/types' +import { describe, expect, it } from 'vitest' +import { addFileInfos, sortAgentSorts } from '../index' + +describe('tools/utils', () => { + describe('sortAgentSorts', () => { + it('returns null/undefined input as-is', () => { + expect(sortAgentSorts(null as unknown as ThoughtItem[])).toBeNull() + expect(sortAgentSorts(undefined as unknown as ThoughtItem[])).toBeUndefined() + }) + + it('returns unsorted when some items lack position', () => { + const items = [ + { id: '1', position: 2 }, + { id: '2' }, + ] as unknown as ThoughtItem[] + const result = sortAgentSorts(items) + expect(result[0]).toEqual(expect.objectContaining({ id: '1' })) + expect(result[1]).toEqual(expect.objectContaining({ id: '2' })) + }) + + it('sorts items by position ascending', () => { + const items = [ + { id: 'c', position: 3 }, + { id: 'a', position: 1 }, + { id: 'b', position: 2 }, + ] as unknown as ThoughtItem[] + const result = sortAgentSorts(items) + expect(result.map((item: ThoughtItem & { id: string }) => item.id)).toEqual(['a', 'b', 'c']) + }) + + it('does not mutate the original array', () => { + const items = [ + { id: 'b', position: 2 }, + { id: 'a', position: 1 }, + ] as unknown as ThoughtItem[] + const result = sortAgentSorts(items) + expect(result).not.toBe(items) + }) + }) + + describe('addFileInfos', () => { + it('returns null/undefined input as-is', () => { + expect(addFileInfos(null as unknown as ThoughtItem[], [])).toBeNull() + expect(addFileInfos(undefined as unknown as ThoughtItem[], [])).toBeUndefined() + }) + + it('returns items when messageFiles is null', () => { + const items = [{ id: '1' }] as unknown as ThoughtItem[] + expect(addFileInfos(items, null as unknown as FileEntity[])).toEqual(items) + }) + + it('adds message_files by matching file IDs', () => { + const file1 = { id: 'file-1', name: 'doc.pdf' } as FileEntity + const file2 = { id: 'file-2', name: 'img.png' } as FileEntity + const items = [ + { id: '1', files: ['file-1', 'file-2'] }, + { id: '2', files: [] }, + ] as unknown as ThoughtItem[] + + const result = addFileInfos(items, [file1, file2]) + expect((result[0] as ThoughtItem & { message_files: FileEntity[] }).message_files).toEqual([file1, file2]) + }) + + it('returns items without files unchanged', () => { + const items = [ + { id: '1' }, + { id: '2', files: null }, + ] as unknown as ThoughtItem[] + const result = addFileInfos(items, []) + expect(result[0]).toEqual(expect.objectContaining({ id: '1' })) + }) + + it('does not mutate original items', () => { + const file1 = { id: 'file-1', name: 'doc.pdf' } as FileEntity + const items = [{ id: '1', files: ['file-1'] }] as unknown as ThoughtItem[] + const result = addFileInfos(items, [file1]) + expect(result[0]).not.toBe(items[0]) + }) + }) +}) diff --git a/web/app/components/tools/utils/__tests__/to-form-schema.spec.ts b/web/app/components/tools/utils/__tests__/to-form-schema.spec.ts new file mode 100644 index 0000000000..19ae318b84 --- /dev/null +++ b/web/app/components/tools/utils/__tests__/to-form-schema.spec.ts @@ -0,0 +1,408 @@ +import type { TriggerEventParameter } from '../../../plugins/types' +import type { ToolCredential, ToolParameter } from '../../types' +import { describe, expect, it } from 'vitest' +import { + addDefaultValue, + generateAgentToolValue, + generateFormValue, + getConfiguredValue, + getPlainValue, + getStructureValue, + toolCredentialToFormSchemas, + toolParametersToFormSchemas, + toType, + triggerEventParametersToFormSchemas, +} from '../to-form-schema' + +describe('to-form-schema utilities', () => { + describe('toType', () => { + it('converts "string" to "text-input"', () => { + expect(toType('string')).toBe('text-input') + }) + + it('converts "number" to "number-input"', () => { + expect(toType('number')).toBe('number-input') + }) + + it('converts "boolean" to "checkbox"', () => { + expect(toType('boolean')).toBe('checkbox') + }) + + it('returns the original type for unknown types', () => { + expect(toType('select')).toBe('select') + expect(toType('secret-input')).toBe('secret-input') + expect(toType('file')).toBe('file') + }) + }) + + describe('triggerEventParametersToFormSchemas', () => { + it('returns empty array for null/undefined parameters', () => { + expect(triggerEventParametersToFormSchemas(null as unknown as TriggerEventParameter[])).toEqual([]) + expect(triggerEventParametersToFormSchemas([])).toEqual([]) + }) + + it('maps parameters with type conversion and tooltip from description', () => { + const params = [ + { + name: 'query', + type: 'string', + description: { en_US: 'Search query', zh_Hans: '搜索查询' }, + label: { en_US: 'Query', zh_Hans: '查询' }, + required: true, + form: 'llm', + }, + ] as unknown as TriggerEventParameter[] + const result = triggerEventParametersToFormSchemas(params) + expect(result).toHaveLength(1) + expect(result[0].type).toBe('text-input') + expect(result[0]._type).toBe('string') + expect(result[0].tooltip).toEqual({ en_US: 'Search query', zh_Hans: '搜索查询' }) + }) + + it('preserves all original fields via spread', () => { + const params = [ + { + name: 'count', + type: 'number', + description: { en_US: 'Count', zh_Hans: '数量' }, + label: { en_US: 'Count', zh_Hans: '数量' }, + required: false, + form: 'form', + }, + ] as unknown as TriggerEventParameter[] + const result = triggerEventParametersToFormSchemas(params) + expect(result[0].name).toBe('count') + expect(result[0].label).toEqual({ en_US: 'Count', zh_Hans: '数量' }) + expect(result[0].required).toBe(false) + }) + }) + + describe('toolParametersToFormSchemas', () => { + it('returns empty array for null parameters', () => { + expect(toolParametersToFormSchemas(null as unknown as ToolParameter[])).toEqual([]) + }) + + it('converts parameters with variable = name and type conversion', () => { + const params: ToolParameter[] = [ + { + name: 'input_text', + label: { en_US: 'Input', zh_Hans: '输入' }, + human_description: { en_US: 'Enter text', zh_Hans: '输入文本' }, + type: 'string', + form: 'llm', + llm_description: 'The input text', + required: true, + multiple: false, + default: 'hello', + }, + ] + const result = toolParametersToFormSchemas(params) + expect(result).toHaveLength(1) + expect(result[0].variable).toBe('input_text') + expect(result[0].type).toBe('text-input') + expect(result[0]._type).toBe('string') + expect(result[0].show_on).toEqual([]) + expect(result[0].tooltip).toEqual({ en_US: 'Enter text', zh_Hans: '输入文本' }) + }) + + it('maps options with show_on = []', () => { + const params: ToolParameter[] = [ + { + name: 'mode', + label: { en_US: 'Mode', zh_Hans: '模式' }, + human_description: { en_US: 'Select mode', zh_Hans: '选择模式' }, + type: 'select', + form: 'form', + llm_description: '', + required: false, + multiple: false, + default: 'fast', + options: [ + { label: { en_US: 'Fast', zh_Hans: '快速' }, value: 'fast' }, + { label: { en_US: 'Accurate', zh_Hans: '精确' }, value: 'accurate' }, + ], + }, + ] + const result = toolParametersToFormSchemas(params) + expect(result[0].options).toHaveLength(2) + expect(result[0].options![0].show_on).toEqual([]) + expect(result[0].options![1].show_on).toEqual([]) + }) + + it('handles parameters without options', () => { + const params: ToolParameter[] = [ + { + name: 'flag', + label: { en_US: 'Flag', zh_Hans: '标记' }, + human_description: { en_US: 'Enable', zh_Hans: '启用' }, + type: 'boolean', + form: 'form', + llm_description: '', + required: false, + multiple: false, + default: 'false', + }, + ] + const result = toolParametersToFormSchemas(params) + expect(result[0].options).toBeUndefined() + }) + }) + + describe('toolCredentialToFormSchemas', () => { + it('returns empty array for null parameters', () => { + expect(toolCredentialToFormSchemas(null as unknown as ToolCredential[])).toEqual([]) + }) + + it('converts credentials with variable = name and tooltip from help', () => { + const creds: ToolCredential[] = [ + { + name: 'api_key', + label: { en_US: 'API Key', zh_Hans: 'API 密钥' }, + help: { en_US: 'Enter your API key', zh_Hans: '输入你的 API 密钥' }, + placeholder: { en_US: 'sk-xxx', zh_Hans: 'sk-xxx' }, + type: 'secret-input', + required: true, + default: '', + }, + ] + const result = toolCredentialToFormSchemas(creds) + expect(result).toHaveLength(1) + expect(result[0].variable).toBe('api_key') + expect(result[0].type).toBe('secret-input') + expect(result[0].tooltip).toEqual({ en_US: 'Enter your API key', zh_Hans: '输入你的 API 密钥' }) + expect(result[0].show_on).toEqual([]) + }) + + it('handles null help field → tooltip becomes undefined', () => { + const creds: ToolCredential[] = [ + { + name: 'token', + label: { en_US: 'Token', zh_Hans: '令牌' }, + help: null, + placeholder: { en_US: '', zh_Hans: '' }, + type: 'string', + required: false, + default: '', + }, + ] + const result = toolCredentialToFormSchemas(creds) + expect(result[0].tooltip).toBeUndefined() + }) + + it('maps credential options with show_on = []', () => { + const creds: ToolCredential[] = [ + { + name: 'auth_method', + label: { en_US: 'Auth', zh_Hans: '认证' }, + help: null, + placeholder: { en_US: '', zh_Hans: '' }, + type: 'select', + required: true, + default: 'bearer', + options: [ + { label: { en_US: 'Bearer', zh_Hans: 'Bearer' }, value: 'bearer' }, + { label: { en_US: 'Basic', zh_Hans: 'Basic' }, value: 'basic' }, + ], + }, + ] + const result = toolCredentialToFormSchemas(creds) + expect(result[0].options).toHaveLength(2) + result[0].options!.forEach(opt => expect(opt.show_on).toEqual([])) + }) + }) + + describe('addDefaultValue', () => { + it('fills in default when value is empty/null/undefined', () => { + const schemas = [ + { variable: 'name', type: 'text-input', default: 'default-name' }, + { variable: 'count', type: 'number-input', default: 10 }, + ] + const result = addDefaultValue({}, schemas) + expect(result.name).toBe('default-name') + expect(result.count).toBe(10) + }) + + it('does not override existing values', () => { + const schemas = [{ variable: 'name', type: 'text-input', default: 'default' }] + const result = addDefaultValue({ name: 'existing' }, schemas) + expect(result.name).toBe('existing') + }) + + it('fills default for empty string value', () => { + const schemas = [{ variable: 'name', type: 'text-input', default: 'default' }] + const result = addDefaultValue({ name: '' }, schemas) + expect(result.name).toBe('default') + }) + + it('converts string boolean values to proper boolean type', () => { + const schemas = [{ variable: 'flag', type: 'boolean' }] + expect(addDefaultValue({ flag: 'true' }, schemas).flag).toBe(true) + expect(addDefaultValue({ flag: 'false' }, schemas).flag).toBe(false) + expect(addDefaultValue({ flag: '1' }, schemas).flag).toBe(true) + expect(addDefaultValue({ flag: 'True' }, schemas).flag).toBe(true) + expect(addDefaultValue({ flag: '0' }, schemas).flag).toBe(false) + }) + + it('converts number boolean values to proper boolean type', () => { + const schemas = [{ variable: 'flag', type: 'boolean' }] + expect(addDefaultValue({ flag: 1 }, schemas).flag).toBe(true) + expect(addDefaultValue({ flag: 0 }, schemas).flag).toBe(false) + }) + + it('preserves actual boolean values', () => { + const schemas = [{ variable: 'flag', type: 'boolean' }] + expect(addDefaultValue({ flag: true }, schemas).flag).toBe(true) + expect(addDefaultValue({ flag: false }, schemas).flag).toBe(false) + }) + }) + + describe('generateFormValue', () => { + it('generates constant-type value wrapper for defaults', () => { + const schemas = [{ variable: 'name', type: 'text-input', default: 'hello' }] + const result = generateFormValue({}, schemas) + expect(result.name).toBeDefined() + const wrapper = result.name as { value: { type: string, value: unknown } } + // correctInitialData sets type to 'mixed' for text-input but preserves default value + expect(wrapper.value.type).toBe('mixed') + expect(wrapper.value.value).toBe('hello') + }) + + it('skips values that already exist', () => { + const schemas = [{ variable: 'name', type: 'text-input', default: 'hello' }] + const result = generateFormValue({ name: 'existing' }, schemas) + expect(result.name).toBeUndefined() + }) + + it('generates auto:1 for reasoning mode', () => { + const schemas = [{ variable: 'name', type: 'text-input', default: 'hello' }] + const result = generateFormValue({}, schemas, true) + expect(result.name).toEqual({ auto: 1, value: null }) + }) + + it('handles boolean default conversion in non-reasoning mode', () => { + const schemas = [{ variable: 'flag', type: 'boolean', default: 'true' }] + const result = generateFormValue({}, schemas) + const wrapper = result.flag as { value: { type: string, value: unknown } } + expect(wrapper.value.value).toBe(true) + }) + + it('handles number-input default conversion', () => { + const schemas = [{ variable: 'count', type: 'number-input', default: '42' }] + const result = generateFormValue({}, schemas) + const wrapper = result.count as { value: { type: string, value: unknown } } + expect(wrapper.value.value).toBe(42) + }) + }) + + describe('getPlainValue', () => { + it('unwraps { value: ... } structure to plain values', () => { + const input = { + a: { value: { type: 'constant', val: 1 } }, + b: { value: { type: 'mixed', val: 'text' } }, + } + const result = getPlainValue(input) + expect(result.a).toEqual({ type: 'constant', val: 1 }) + expect(result.b).toEqual({ type: 'mixed', val: 'text' }) + }) + + it('returns empty object for empty input', () => { + expect(getPlainValue({})).toEqual({}) + }) + }) + + describe('getStructureValue', () => { + it('wraps plain values into { value: ... } structure', () => { + const input = { a: 'hello', b: 42 } + const result = getStructureValue(input) + expect(result).toEqual({ a: { value: 'hello' }, b: { value: 42 } }) + }) + + it('returns empty object for empty input', () => { + expect(getStructureValue({})).toEqual({}) + }) + }) + + describe('getConfiguredValue', () => { + it('fills defaults with correctInitialData for missing values', () => { + const schemas = [{ variable: 'name', type: 'text-input', default: 'hello' }] + const result = getConfiguredValue({}, schemas) + const val = result.name as { type: string, value: unknown } + expect(val.type).toBe('mixed') + }) + + it('does not override existing values', () => { + const schemas = [{ variable: 'name', type: 'text-input', default: 'hello' }] + const result = getConfiguredValue({ name: 'existing' }, schemas) + expect(result.name).toBe('existing') + }) + + it('escapes newlines in string defaults', () => { + const schemas = [{ variable: 'prompt', type: 'text-input', default: 'line1\nline2' }] + const result = getConfiguredValue({}, schemas) + const val = result.prompt as { type: string, value: unknown } + expect(val.type).toBe('mixed') + expect(val.value).toBe('line1\\nline2') + }) + + it('handles boolean default conversion', () => { + const schemas = [{ variable: 'flag', type: 'boolean', default: 'true' }] + const result = getConfiguredValue({}, schemas) + const val = result.flag as { type: string, value: unknown } + expect(val.value).toBe(true) + }) + + it('handles app-selector type', () => { + const schemas = [{ variable: 'app', type: 'app-selector', default: 'app-id-123' }] + const result = getConfiguredValue({}, schemas) + const val = result.app as { type: string, value: unknown } + expect(val.value).toBe('app-id-123') + }) + }) + + describe('generateAgentToolValue', () => { + it('generates constant-type values in non-reasoning mode', () => { + const schemas = [{ variable: 'name', type: 'text-input', default: 'hello' }] + const value = { name: { value: 'world' } } + const result = generateAgentToolValue(value, schemas) + expect(result.name.value).toBeDefined() + expect(result.name.value!.type).toBe('mixed') + }) + + it('generates auto:1 for auto-mode parameters in reasoning mode', () => { + const schemas = [{ variable: 'name', type: 'text-input' }] + const value = { name: { auto: 1 as const, value: undefined } } + const result = generateAgentToolValue(value, schemas, true) + expect(result.name).toEqual({ auto: 1, value: null }) + }) + + it('generates auto:0 with value for manual parameters in reasoning mode', () => { + const schemas = [{ variable: 'name', type: 'text-input' }] + const value = { name: { auto: 0 as const, value: { type: 'constant', value: 'manual' } } } + const result = generateAgentToolValue(value, schemas, true) + expect(result.name.auto).toBe(0) + expect(result.name.value).toEqual({ type: 'constant', value: 'manual' }) + }) + + it('handles undefined value in reasoning mode with fallback', () => { + const schemas = [{ variable: 'name', type: 'select' }] + const value = { name: { auto: 0 as const, value: undefined } } + const result = generateAgentToolValue(value, schemas, true) + expect(result.name.auto).toBe(0) + expect(result.name.value).toEqual({ type: 'constant', value: null }) + }) + + it('applies correctInitialData for text-input type', () => { + const schemas = [{ variable: 'query', type: 'text-input' }] + const value = { query: { value: 'search term' } } + const result = generateAgentToolValue(value, schemas) + expect(result.query.value!.type).toBe('mixed') + }) + + it('applies correctInitialData for boolean type conversion', () => { + const schemas = [{ variable: 'flag', type: 'boolean' }] + const value = { flag: { value: 'true' } } + const result = generateAgentToolValue(value, schemas) + expect(result.flag.value!.value).toBe(true) + }) + }) +}) diff --git a/web/app/components/tools/workflow-tool/configure-button.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx similarity index 99% rename from web/app/components/tools/workflow-tool/configure-button.spec.tsx rename to web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx index 659aeb4a49..eb646fd8c3 100644 --- a/web/app/components/tools/workflow-tool/configure-button.spec.tsx +++ b/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx @@ -1,13 +1,13 @@ -import type { WorkflowToolModalPayload } from './index' +import type { WorkflowToolModalPayload } from '../index' import type { WorkflowToolProviderResponse } from '@/app/components/tools/types' import type { InputVar, Variable } from '@/app/components/workflow/types' import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { InputVarType, VarType } from '@/app/components/workflow/types' -import WorkflowToolConfigureButton from './configure-button' -import WorkflowToolAsModal from './index' -import MethodSelector from './method-selector' +import WorkflowToolConfigureButton from '../configure-button' +import WorkflowToolAsModal from '../index' +import MethodSelector from '../method-selector' // Mock Next.js navigation const mockPush = vi.fn() diff --git a/web/app/components/tools/workflow-tool/method-selector.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/method-selector.spec.tsx similarity index 99% rename from web/app/components/tools/workflow-tool/method-selector.spec.tsx rename to web/app/components/tools/workflow-tool/__tests__/method-selector.spec.tsx index bbdbe5b629..8fe4037231 100644 --- a/web/app/components/tools/workflow-tool/method-selector.spec.tsx +++ b/web/app/components/tools/workflow-tool/__tests__/method-selector.spec.tsx @@ -2,7 +2,7 @@ import type { ComponentProps } from 'react' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -import MethodSelector from './method-selector' +import MethodSelector from '../method-selector' // Test utilities const defaultProps: ComponentProps<typeof MethodSelector> = { diff --git a/web/app/components/tools/workflow-tool/confirm-modal/index.spec.tsx b/web/app/components/tools/workflow-tool/confirm-modal/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/tools/workflow-tool/confirm-modal/index.spec.tsx rename to web/app/components/tools/workflow-tool/confirm-modal/__tests__/index.spec.tsx index a03860d952..d28064ef0c 100644 --- a/web/app/components/tools/workflow-tool/confirm-modal/index.spec.tsx +++ b/web/app/components/tools/workflow-tool/confirm-modal/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' -import ConfirmModal from './index' +import ConfirmModal from '../index' // Test utilities const defaultProps = { diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index e49d1d8d23..a2c0cb0d94 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -4827,14 +4827,6 @@ "count": 1 } }, - "app/components/plugins/install-plugin/install-from-marketplace/steps/install.spec.tsx": { - "ts/no-explicit-any": { - "count": 3 - }, - "unused-imports/no-unused-vars": { - "count": 2 - } - }, "app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 2 @@ -4893,11 +4885,6 @@ "count": 2 } }, - "app/components/plugins/marketplace/sort-dropdown/index.spec.tsx": { - "unused-imports/no-unused-vars": { - "count": 1 - } - }, "app/components/plugins/marketplace/sort-dropdown/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 3 @@ -5079,14 +5066,6 @@ "count": 2 } }, - "app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 5 - }, - "unused-imports/no-unused-vars": { - "count": 2 - } - }, "app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 3 @@ -5217,16 +5196,6 @@ "count": 3 } }, - "app/components/plugins/plugin-item/action.spec.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, - "app/components/plugins/plugin-item/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 10 - } - }, "app/components/plugins/plugin-item/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 7 @@ -5235,11 +5204,6 @@ "count": 1 } }, - "app/components/plugins/plugin-mutation-model/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 2 - } - }, "app/components/plugins/plugin-mutation-model/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 @@ -5258,11 +5222,6 @@ "count": 1 } }, - "app/components/plugins/plugin-page/empty/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 7 - } - }, "app/components/plugins/plugin-page/empty/index.tsx": { "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 2 @@ -5289,31 +5248,16 @@ "count": 2 } }, - "app/components/plugins/plugin-page/list/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 4 - } - }, "app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 2 } }, - "app/components/plugins/plugin-page/plugin-tasks/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 2 - } - }, "app/components/plugins/provider-card.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 2 } }, - "app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 7 - } - }, "app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 4 @@ -5352,11 +5296,6 @@ "count": 1 } }, - "app/components/plugins/reference-setting-modal/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 7 - } - }, "app/components/plugins/reference-setting-modal/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 @@ -5382,11 +5321,6 @@ "count": 1 } }, - "app/components/plugins/update-plugin/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 5 - } - }, "app/components/plugins/update-plugin/plugin-version-picker.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 3 diff --git a/web/test/i18n-mock.ts b/web/test/i18n-mock.ts index 20e7a22eef..39f97db543 100644 --- a/web/test/i18n-mock.ts +++ b/web/test/i18n-mock.ts @@ -31,20 +31,31 @@ export function createTFunction(translations: TranslationMap, defaultNs?: string /** * Create useTranslation mock with optional custom translations * + * Caches t functions by defaultNs so the same reference is returned + * across renders, preventing infinite re-render loops when components + * include t in useEffect/useMemo dependency arrays. + * * @example * vi.mock('react-i18next', () => createUseTranslationMock({ * 'operation.confirm': 'Confirm', * })) */ export function createUseTranslationMock(translations: TranslationMap = {}) { + const tCache = new Map<string, ReturnType<typeof createTFunction>>() + const i18n = { + language: 'en', + changeLanguage: vi.fn(), + } return { - useTranslation: (defaultNs?: string) => ({ - t: createTFunction(translations, defaultNs), - i18n: { - language: 'en', - changeLanguage: vi.fn(), - }, - }), + useTranslation: (defaultNs?: string) => { + const cacheKey = defaultNs ?? '' + if (!tCache.has(cacheKey)) + tCache.set(cacheKey, createTFunction(translations, defaultNs)) + return { + t: tCache.get(cacheKey)!, + i18n, + } + }, } }