diff --git a/web/app/components/datasets/create/website/jina-reader/index.spec.tsx b/web/app/components/datasets/create/website/jina-reader/index.spec.tsx index 565f6660db..dec4999a82 100644 --- a/web/app/components/datasets/create/website/jina-reader/index.spec.tsx +++ b/web/app/components/datasets/create/website/jina-reader/index.spec.tsx @@ -394,7 +394,10 @@ describe('JinaReader', () => { it('should update controlFoldOptions when step changes', async () => { // Arrange const mockCreateTask = createJinaReaderTask as Mock - mockCreateTask.mockImplementation(() => new Promise((_resolve) => { /* pending */ })) + let resolveTask: (value: unknown) => void + mockCreateTask.mockImplementationOnce(() => new Promise((resolve) => { + resolveTask = resolve + })) const props = createDefaultProps() @@ -412,6 +415,9 @@ describe('JinaReader', () => { await waitFor(() => { expect(screen.getByText(/totalPageScraped/i)).toBeInTheDocument() }) + + // Cleanup - resolve the pending promise to avoid act() warning + resolveTask!({ data: { title: 'T', content: 'C', description: 'D', url: 'https://example.com' } }) }) }) @@ -869,42 +875,6 @@ describe('JinaReader', () => { }) }) - it('should show error when limit is null', async () => { - // Arrange - const props = createDefaultProps({ - crawlOptions: createDefaultCrawlOptions({ limit: null as unknown as number }), - }) - - // Act - render() - const input = screen.getByRole('textbox') - await userEvent.type(input, 'https://example.com') - await userEvent.click(screen.getByRole('button', { name: /run/i })) - - // Assert - await waitFor(() => { - expect(createJinaReaderTask).not.toHaveBeenCalled() - }) - }) - - it('should show error when limit is undefined', async () => { - // Arrange - const props = createDefaultProps({ - crawlOptions: createDefaultCrawlOptions({ limit: undefined as unknown as number }), - }) - - // Act - render() - const input = screen.getByRole('textbox') - await userEvent.type(input, 'https://example.com') - await userEvent.click(screen.getByRole('button', { name: /run/i })) - - // Assert - await waitFor(() => { - expect(createJinaReaderTask).not.toHaveBeenCalled() - }) - }) - it('should handle API throwing an exception', async () => { // Arrange const mockCreateTask = createJinaReaderTask as Mock @@ -1073,9 +1043,12 @@ describe('JinaReader', () => { // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock + let resolveStatus: (value: unknown) => void mockCreateTask.mockResolvedValueOnce({ job_id: 'zero-current-job' }) - mockCheckStatus.mockImplementation(() => new Promise(() => { /* never resolves */ })) + mockCheckStatus.mockImplementationOnce(() => new Promise((resolve) => { + resolveStatus = resolve + })) const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: 10 }), @@ -1091,15 +1064,21 @@ describe('JinaReader', () => { await waitFor(() => { expect(screen.getByText(/totalPageScraped.*0\/10/)).toBeInTheDocument() }) + + // Cleanup - resolve the pending promise to avoid act() warning + resolveStatus!({ status: 'completed', current: 1, total: 1, data: [] }) }) it('should show 0/0 progress when limit is zero string', async () => { // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock + let resolveStatus: (value: unknown) => void mockCreateTask.mockResolvedValueOnce({ job_id: 'zero-total-job' }) - mockCheckStatus.mockImplementation(() => new Promise(() => { /* never resolves */ })) + mockCheckStatus.mockImplementationOnce(() => new Promise((resolve) => { + resolveStatus = resolve + })) const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: '0' }), @@ -1115,6 +1094,9 @@ describe('JinaReader', () => { await waitFor(() => { expect(screen.getByText(/totalPageScraped.*0\/0/)).toBeInTheDocument() }) + + // Cleanup - resolve the pending promise to avoid act() warning + resolveStatus!({ status: 'completed', current: 0, total: 0, data: [] }) }) it('should complete successfully when result data is undefined', async () => { @@ -1150,9 +1132,12 @@ describe('JinaReader', () => { // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock + let resolveStatus: (value: unknown) => void mockCreateTask.mockResolvedValueOnce({ job_id: 'no-total-job' }) - mockCheckStatus.mockImplementation(() => new Promise(() => { /* never resolves */ })) + mockCheckStatus.mockImplementationOnce(() => new Promise((resolve) => { + resolveStatus = resolve + })) const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: 15 }), @@ -1168,12 +1153,16 @@ describe('JinaReader', () => { await waitFor(() => { expect(screen.getByText(/totalPageScraped.*0\/15/)).toBeInTheDocument() }) + + // Cleanup - resolve the pending promise to avoid act() warning + resolveStatus!({ status: 'completed', current: 15, total: 15, data: [] }) }) it('should fallback to limit when crawlResult has zero total', async () => { // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock + let resolveStatus: (value: unknown) => void mockCreateTask.mockResolvedValueOnce({ job_id: 'both-zero-job' }) mockCheckStatus @@ -1183,7 +1172,9 @@ describe('JinaReader', () => { total: 0, data: [], }) - .mockImplementationOnce(() => new Promise(() => { /* never resolves */ })) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveStatus = resolve + })) const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: 5 }), @@ -1199,6 +1190,9 @@ describe('JinaReader', () => { await waitFor(() => { expect(screen.getByText(/totalPageScraped/)).toBeInTheDocument() }) + + // Cleanup - resolve the pending promise to avoid act() warning + resolveStatus!({ status: 'completed', current: 5, total: 5, data: [] }) }) it('should construct result item from direct data response', async () => { @@ -1437,9 +1431,12 @@ describe('JinaReader', () => { // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock + let resolveStatus: (value: unknown) => void mockCreateTask.mockResolvedValueOnce({ job_id: 'progress-job' }) - mockCheckStatus.mockImplementation(() => new Promise((_resolve) => { /* pending */ })) // Never resolves + mockCheckStatus.mockImplementationOnce(() => new Promise((resolve) => { + resolveStatus = resolve + })) const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: 10 }), @@ -1455,6 +1452,9 @@ describe('JinaReader', () => { await waitFor(() => { expect(screen.getByText(/totalPageScraped.*0\/10/)).toBeInTheDocument() }) + + // Cleanup - resolve the pending promise to avoid act() warning + resolveStatus!({ status: 'completed', current: 10, total: 10, data: [] }) }) it('should display time consumed after crawl completion', async () => { diff --git a/web/app/components/plugins/plugin-page/empty/index.spec.tsx b/web/app/components/plugins/plugin-page/empty/index.spec.tsx index 0084fc6c3d..bfdf857662 100644 --- a/web/app/components/plugins/plugin-page/empty/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/empty/index.spec.tsx @@ -1,274 +1,272 @@ import type { FilterState } from '../filter-management' import type { SystemFeatures } from '@/types/feature' -import { act, fireEvent, render, screen } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { defaultSystemFeatures, InstallationScope } from '@/types/feature' - -// ==================== Imports (after mocks) ==================== - +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { InstallationScope } from '@/types/feature' import Empty from './index' -// ==================== Mock Setup ==================== +// ============================================================================ +// Mock Setup +// ============================================================================ -// Use vi.hoisted to define ALL mock state and functions -const { - mockSetActiveTab, - mockUseInstalledPluginList, - mockState, -} = vi.hoisted(() => { - const state = { - filters: { - categories: [] as string[], - tags: [] as string[], - searchQuery: '', - } as FilterState, - systemFeatures: { - enable_marketplace: true, - plugin_installation_permission: { - plugin_installation_scope: 'all' as const, - restrict_to_marketplace_only: false, - }, - } as Partial, - pluginList: { plugins: [] as Array<{ id: string }> } as { plugins: Array<{ id: string }> } | undefined, - } - return { - mockSetActiveTab: vi.fn(), - mockUseInstalledPluginList: vi.fn(() => ({ data: state.pluginList })), - mockState: state, - } -}) +// Create a stable t function reference to avoid infinite re-renders +// The component's useEffect depends on t, so it must be stable +const stableT = (key: string) => key -// Mock plugin page context -vi.mock('../context', () => ({ - usePluginPageContext: (selector: (value: any) => any) => { - const contextValue = { - filters: mockState.filters, - setActiveTab: mockSetActiveTab, - } - return selector(contextValue) - }, -})) - -// Mock global public store (Zustand store) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (state: any) => any) => { - return selector({ - systemFeatures: { - ...defaultSystemFeatures, - ...mockState.systemFeatures, - }, - }) - }, +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: stableT, + i18n: { + language: 'en', + changeLanguage: vi.fn(), + }, + }), })) // Mock useInstalledPluginList hook +const mockUseInstalledPluginList = vi.fn() vi.mock('@/service/use-plugins', () => ({ useInstalledPluginList: () => mockUseInstalledPluginList(), })) -// Mock InstallFromGitHub component +// Mock system features from global public context +let mockSystemFeatures: Partial = { + enable_marketplace: true, + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.ALL, + restrict_to_marketplace_only: false, + }, +} + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: { systemFeatures: Partial }) => unknown) => + selector({ systemFeatures: mockSystemFeatures }), +})) + +// Mock plugin page context +let mockFilters: FilterState = { + categories: [], + tags: [], + searchQuery: '', +} +const mockSetActiveTab = vi.fn() + +vi.mock('../context', () => ({ + usePluginPageContext: (selector: (value: { filters: FilterState, setActiveTab: (tab: string) => void }) => unknown) => + selector({ filters: mockFilters, setActiveTab: mockSetActiveTab }), +})) + +// Mock install components vi.mock('@/app/components/plugins/install-plugin/install-from-github', () => ({ - default: ({ onClose }: { onSuccess: () => void, onClose: () => void }) => ( -
- - + default: ({ onClose, onSuccess }: { onClose: () => void, onSuccess: () => void }) => ( +
+ +
), })) -// Mock InstallFromLocalPackage component vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', () => ({ - default: ({ file, onClose }: { file: File, onSuccess: () => void, onClose: () => void }) => ( -
- - + default: ({ file, onClose, onSuccess }: { file: File, onClose: () => void, onSuccess: () => void }) => ( +
+ +
), })) // Mock Line component vi.mock('../../marketplace/empty/line', () => ({ - default: ({ className }: { className?: string }) =>
, + default: ({ className }: { className?: string }) => ( +
+ ), })) -// ==================== Test Utilities ==================== +// ============================================================================ +// Test Data Factories +// ============================================================================ -const resetMockState = () => { - mockState.filters = { categories: [], tags: [], searchQuery: '' } - mockState.systemFeatures = { - enable_marketplace: true, - plugin_installation_permission: { - plugin_installation_scope: InstallationScope.ALL, - restrict_to_marketplace_only: false, - }, - } - mockState.pluginList = { plugins: [] } - mockUseInstalledPluginList.mockReturnValue({ data: mockState.pluginList }) -} +const createDefaultSystemFeatures = (overrides: Partial = {}): Partial => ({ + enable_marketplace: true, + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.ALL, + restrict_to_marketplace_only: false, + }, + ...overrides, +}) -const setMockFilters = (filters: Partial) => { - mockState.filters = { ...mockState.filters, ...filters } -} +const createFilterState = (overrides: Partial = {}): FilterState => ({ + categories: [], + tags: [], + searchQuery: '', + ...overrides, +}) -const setMockSystemFeatures = (features: Partial) => { - mockState.systemFeatures = { ...mockState.systemFeatures, ...features } -} +const createPluginListResponse = (plugins: unknown[] = []) => ({ + data: { + plugins, + total: plugins.length, + }, +}) -const setMockPluginList = (list: { plugins: Array<{ id: string }> } | undefined) => { - mockState.pluginList = list - mockUseInstalledPluginList.mockReturnValue({ data: list }) -} - -const createMockFile = (name: string, type = 'application/octet-stream'): File => { - return new File(['test'], name, { type }) -} - -// Helper to wait for useEffect to complete (single tick) -const flushEffects = async () => { - await act(async () => {}) -} - -// ==================== Tests ==================== - -describe('Empty Component', () => { +// ============================================================================ +// Rendering Tests +// ============================================================================ +describe('Empty', () => { beforeEach(() => { vi.clearAllMocks() - resetMockState() + mockSystemFeatures = createDefaultSystemFeatures() + mockFilters = createFilterState() + mockUseInstalledPluginList.mockReturnValue(createPluginListResponse([])) }) - // ==================== Rendering Tests ==================== describe('Rendering', () => { - it('should render basic structure correctly', async () => { + it('should render without crashing', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument() + }) + + it('should render skeleton grid background', () => { // Arrange & Act const { container } = render() - await flushEffects() - // Assert - file input + // Assert + const skeletonCards = container.querySelectorAll('.h-24.rounded-xl.bg-components-card-bg') + expect(skeletonCards.length).toBe(20) + }) + + it('should render Group icon', () => { + // Arrange & Act + const { container } = render() + + // Assert + const iconContainer = container.querySelector('.size-14') + expect(iconContainer).toBeInTheDocument() + }) + + it('should render decorative lines', () => { + // Arrange & Act + render() + + // Assert + const lines = screen.getAllByTestId('line') + expect(lines.length).toBe(4) + }) + + it('should be wrapped with React.memo', () => { + // Assert - React.memo components have $$typeof Symbol(react.memo) + expect(Empty.$$typeof?.toString()).toBe('Symbol(react.memo)') + }) + + it('should have displayName set on inner component', () => { + // Assert - displayName is set on the inner component (type property of memo wrapper) + const innerComponent = (Empty as unknown as { type: { displayName?: string } }).type + expect(innerComponent?.displayName).toBe('Empty') + }) + }) + + // ============================================================================ + // State Management Tests + // ============================================================================ + describe('State Management', () => { + it('should initialize with no selected action', () => { + // Arrange & Act + render() + + // Assert + expect(screen.queryByTestId('install-from-github')).not.toBeInTheDocument() + expect(screen.queryByTestId('install-from-local-package')).not.toBeInTheDocument() + }) + + it('should update selectedAction when github button is clicked', async () => { + // Arrange + render() + + // Act + const githubButton = screen.getByText('plugin.source.github') + await userEvent.click(githubButton) + + // Assert + expect(screen.getByTestId('install-from-github')).toBeInTheDocument() + }) + + it('should clear selectedAction when github modal is closed', async () => { + // Arrange + render() + + // Act - Open then close + await userEvent.click(screen.getByText('plugin.source.github')) + expect(screen.getByTestId('install-from-github')).toBeInTheDocument() + + await userEvent.click(screen.getByTestId('github-close')) + + // Assert + expect(screen.queryByTestId('install-from-github')).not.toBeInTheDocument() + }) + + it('should update selectedFile when file is selected', async () => { + // Arrange + render() + const file = new File(['test content'], 'test.difypkg', { type: 'application/octet-stream' }) + + // Act + const localButton = screen.getByText('plugin.source.local') + await userEvent.click(localButton) + + // Get the hidden file input and simulate file selection const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement expect(fileInput).toBeInTheDocument() - expect(fileInput.style.display).toBe('none') - expect(fileInput.accept).toBe('.difypkg,.difybndl') - // Assert - skeleton cards - const skeletonCards = container.querySelectorAll('.rounded-xl.bg-components-card-bg') - expect(skeletonCards).toHaveLength(20) + // Simulate file change + Object.defineProperty(fileInput, 'files', { + value: [file], + configurable: true, + }) + fireEvent.change(fileInput) - // Assert - group icon container - const iconContainer = document.querySelector('.size-14') - expect(iconContainer).toBeInTheDocument() + // Assert + await waitFor(() => { + const localPackageModal = screen.getByTestId('install-from-local-package') + expect(localPackageModal).toBeInTheDocument() + expect(localPackageModal).toHaveAttribute('data-filename', 'test.difypkg') + }) + }) - // Assert - line components - const lines = screen.getAllByTestId('line-component') - expect(lines).toHaveLength(4) + it('should clear selectedAction when local package modal is closed', async () => { + // Arrange + render() + const file = new File(['test'], 'test.difypkg', { type: 'application/octet-stream' }) + + // Act - Select file + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + Object.defineProperty(fileInput, 'files', { + value: [file], + configurable: true, + }) + fireEvent.change(fileInput) + + await waitFor(() => { + expect(screen.getByTestId('install-from-local-package')).toBeInTheDocument() + }) + + // Close the modal + await userEvent.click(screen.getByTestId('local-close')) + + // Assert + expect(screen.queryByTestId('install-from-local-package')).not.toBeInTheDocument() }) }) - // ==================== Text Display Tests (useMemo) ==================== - describe('Text Display (useMemo)', () => { - it('should display "noInstalled" text when plugin list is empty', async () => { + // ============================================================================ + // Side Effects and Cleanup Tests + // ============================================================================ + describe('Side Effects and Cleanup', () => { + it('should update install methods when system features change', () => { // Arrange - setMockPluginList({ plugins: [] }) - - // Act - render() - await flushEffects() - - // Assert - expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument() - }) - - it('should display "notFound" text when filters are active with plugins', async () => { - // Arrange - setMockPluginList({ plugins: [{ id: 'plugin-1' }] }) - - // Test categories filter - setMockFilters({ categories: ['model'] }) - const { rerender } = render() - await flushEffects() - expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() - - // Test tags filter - setMockFilters({ categories: [], tags: ['tag1'] }) - rerender() - await flushEffects() - expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() - - // Test searchQuery filter - setMockFilters({ tags: [], searchQuery: 'test query' }) - rerender() - await flushEffects() - expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() - }) - - it('should prioritize "noInstalled" over "notFound" when no plugins exist', async () => { - // Arrange - setMockFilters({ categories: ['model'], searchQuery: 'test' }) - setMockPluginList({ plugins: [] }) - - // Act - render() - await flushEffects() - - // Assert - expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument() - }) - }) - - // ==================== Install Methods Tests (useEffect) ==================== - describe('Install Methods (useEffect)', () => { - it('should render all three install methods when marketplace enabled and not restricted', async () => { - // Arrange - setMockSystemFeatures({ - enable_marketplace: true, - plugin_installation_permission: { - plugin_installation_scope: InstallationScope.ALL, - restrict_to_marketplace_only: false, - }, - }) - - // Act - render() - await flushEffects() - - // Assert - const buttons = screen.getAllByRole('button') - expect(buttons).toHaveLength(3) - expect(screen.getByText('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('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 () => { - // Arrange - setMockSystemFeatures({ - enable_marketplace: true, - plugin_installation_permission: { - plugin_installation_scope: InstallationScope.ALL, - restrict_to_marketplace_only: true, - }, - }) - - // Act - render() - await flushEffects() - - // Assert - const buttons = screen.getAllByRole('button') - expect(buttons).toHaveLength(1) - expect(screen.getByText('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 () => { - // Arrange - setMockSystemFeatures({ + mockSystemFeatures = createDefaultSystemFeatures({ enable_marketplace: false, plugin_installation_permission: { plugin_installation_scope: InstallationScope.ALL, @@ -278,20 +276,17 @@ describe('Empty Component', () => { // Act render() - await flushEffects() - // Assert - const buttons = screen.getAllByRole('button') - expect(buttons).toHaveLength(2) + // Assert - Marketplace option should not be visible 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 () => { + it('should only show marketplace when restrict_to_marketplace_only is true', () => { // Arrange - setMockSystemFeatures({ - enable_marketplace: false, + mockSystemFeatures = createDefaultSystemFeatures({ + enable_marketplace: true, plugin_installation_permission: { plugin_installation_scope: InstallationScope.ALL, restrict_to_marketplace_only: true, @@ -300,145 +295,280 @@ describe('Empty Component', () => { // Act render() - await flushEffects() // Assert - const buttons = screen.queryAllByRole('button') - expect(buttons).toHaveLength(0) + expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument() + expect(screen.queryByText('plugin.source.github')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.source.local')).not.toBeInTheDocument() }) }) - // ==================== User Interactions Tests ==================== - describe('User Interactions', () => { - it('should call setActiveTab with "discover" when marketplace button is clicked', async () => { + // ============================================================================ + // Memoization Logic Tests + // ============================================================================ + describe('Memoization Logic', () => { + it('should show noInstalled text when plugin list is empty', () => { // Arrange - render() - await flushEffects() + mockUseInstalledPluginList.mockReturnValue(createPluginListResponse([])) // Act - fireEvent.click(screen.getByText('plugin.source.marketplace')) + render() + + // Assert + expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument() + }) + + it('should show notFound text when filters are active with no results', () => { + // Arrange + mockUseInstalledPluginList.mockReturnValue(createPluginListResponse([{ id: '1' }])) + mockFilters = createFilterState({ categories: ['model'] }) + + // Act + render() + + // Assert + expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() + }) + + it('should show notFound text when tags filter is active', () => { + // Arrange + mockUseInstalledPluginList.mockReturnValue(createPluginListResponse([{ id: '1' }])) + mockFilters = createFilterState({ tags: ['agent'] }) + + // Act + render() + + // Assert + expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() + }) + + it('should show notFound text when searchQuery is active', () => { + // Arrange + mockUseInstalledPluginList.mockReturnValue(createPluginListResponse([{ id: '1' }])) + mockFilters = createFilterState({ searchQuery: 'test' }) + + // Act + render() + + // Assert + expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() + }) + + it('should update text based on filter and plugin list state', () => { + // Test noInstalled when plugin list is empty + mockUseInstalledPluginList.mockReturnValue(createPluginListResponse([])) + mockFilters = createFilterState() + + const { unmount } = render() + expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument() + unmount() + + // Test notFound when filters are active with plugins present + mockFilters = createFilterState({ categories: ['model'] }) + mockUseInstalledPluginList.mockReturnValue(createPluginListResponse([{ id: '1' }])) + + render() + expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() + }) + }) + + // ============================================================================ + // User Interactions Tests + // ============================================================================ + describe('User Interactions', () => { + it('should navigate to discover tab when marketplace button is clicked', async () => { + // Arrange + mockSystemFeatures = createDefaultSystemFeatures({ enable_marketplace: true }) + render() + + // Act + await userEvent.click(screen.getByText('plugin.source.marketplace')) // Assert expect(mockSetActiveTab).toHaveBeenCalledWith('discover') }) - it('should open and close GitHub modal correctly', async () => { - // Arrange - render() - await flushEffects() - - // Assert - initially no modal - expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument() - - // Act - open modal - fireEvent.click(screen.getByText('plugin.source.github')) - - // Assert - modal is open - expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() - - // Act - close modal - fireEvent.click(screen.getByTestId('github-modal-close')) - - // Assert - modal is closed - expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument() - }) - it('should trigger file input click when local button is clicked', async () => { // Arrange render() - await flushEffects() const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement const clickSpy = vi.spyOn(fileInput, 'click') // Act - fireEvent.click(screen.getByText('plugin.source.local')) + await userEvent.click(screen.getByText('plugin.source.local')) // Assert expect(clickSpy).toHaveBeenCalled() }) - it('should open and close local modal when file is selected', async () => { + it('should not show modals for unselected file', async () => { // Arrange render() - await flushEffects() + + // Act - Click local but don't select a file const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement - const mockFile = createMockFile('test-plugin.difypkg') + fireEvent.change(fileInput, { target: { files: [] } }) - // Assert - initially no modal - expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() - - // Act - select file - Object.defineProperty(fileInput, 'files', { value: [mockFile], writable: true }) - fireEvent.change(fileInput) - - // Assert - modal is open with correct file - expect(screen.getByTestId('install-from-local-modal')).toBeInTheDocument() - expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'test-plugin.difypkg') - - // Act - close modal - fireEvent.click(screen.getByTestId('local-modal-close')) - - // Assert - modal is closed - expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() + // Assert + expect(screen.queryByTestId('install-from-local-package')).not.toBeInTheDocument() }) - it('should not open local modal when no file is selected', async () => { + it('should handle file input change event', async () => { // Arrange render() - await flushEffects() - const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + const testFile = new File(['content'], 'plugin.difypkg', { type: 'application/octet-stream' }) - // Act - trigger change with empty files - Object.defineProperty(fileInput, 'files', { value: [], writable: true }) + // Act + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + Object.defineProperty(fileInput, 'files', { + value: [testFile], + configurable: true, + }) fireEvent.change(fileInput) // Assert - expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() + await waitFor(() => { + expect(screen.getByTestId('install-from-local-package')).toBeInTheDocument() + }) + }) + + it('should render buttons with correct styling', () => { + // Arrange & Act + render() + + // Assert + const buttons = screen.getAllByRole('button') + buttons.forEach((button) => { + expect(button).toHaveClass('justify-start') + }) }) }) - // ==================== State Management Tests ==================== - describe('State Management', () => { - it('should maintain modal state correctly and allow reopening', async () => { + // ============================================================================ + // Component Memoization Tests + // ============================================================================ + describe('Component Memoization', () => { + it('should not rerender when unrelated props change', () => { // Arrange - render() - await flushEffects() + const renderCount = vi.fn() + const TestWrapper = () => { + renderCount() + return + } - // Act - Open, close, and reopen GitHub modal - fireEvent.click(screen.getByText('plugin.source.github')) - expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() + // Act + const { rerender } = render() + rerender() - fireEvent.click(screen.getByTestId('github-modal-close')) - expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument() - - fireEvent.click(screen.getByText('plugin.source.github')) - expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() + // Assert - Initial render + rerender + expect(renderCount).toHaveBeenCalledTimes(2) }) - it('should update selectedFile state when file is selected', async () => { + it('should maintain stable reference for install methods', () => { + // Arrange & Act + const { container, rerender } = render() + const initialButtons = container.querySelectorAll('button') + + rerender() + const afterRerenderButtons = container.querySelectorAll('button') + + // Assert + expect(initialButtons.length).toBe(afterRerenderButtons.length) + }) + }) + + // ============================================================================ + // Edge Cases Tests + // ============================================================================ + describe('Edge Cases', () => { + it('should handle undefined plugin list data', () => { + // Arrange + mockUseInstalledPluginList.mockReturnValue({ data: undefined }) + + // Act + render() + + // Assert - Should still render without crashing + expect(screen.getByRole('button', { name: /github/i })).toBeInTheDocument() + }) + + it('should handle null plugin list', () => { + // Arrange + mockUseInstalledPluginList.mockReturnValue({ data: null }) + + // Act + render() + + // Assert + expect(document.querySelector('.relative.z-0.w-full.grow')).toBeInTheDocument() + }) + + it('should handle empty system features', () => { + // Arrange + mockSystemFeatures = { + enable_marketplace: false, + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.NONE, + restrict_to_marketplace_only: false, + }, + } + + // Act + render() + + // Assert + expect(screen.getByText('plugin.source.github')).toBeInTheDocument() + }) + + it('should handle multiple file selection attempts', async () => { // Arrange render() - await flushEffects() + const file1 = new File(['content1'], 'plugin1.difypkg') + const file2 = new File(['content2'], 'plugin2.difypkg') + + // Act - First file const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement - - // Act - select .difypkg file - Object.defineProperty(fileInput, 'files', { value: [createMockFile('my-plugin.difypkg')], writable: true }) + Object.defineProperty(fileInput, 'files', { value: [file1], configurable: true }) fireEvent.change(fileInput) - expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'my-plugin.difypkg') - // Close and select .difybndl file - fireEvent.click(screen.getByTestId('local-modal-close')) - Object.defineProperty(fileInput, 'files', { value: [createMockFile('test-bundle.difybndl')], writable: true }) + await waitFor(() => { + expect(screen.getByTestId('install-from-local-package')).toHaveAttribute('data-filename', 'plugin1.difypkg') + }) + + // Close and select another + await userEvent.click(screen.getByTestId('local-close')) + + Object.defineProperty(fileInput, 'files', { value: [file2], configurable: true }) fireEvent.change(fileInput) - expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'test-bundle.difybndl') + + // Assert + await waitFor(() => { + expect(screen.getByTestId('install-from-local-package')).toHaveAttribute('data-filename', 'plugin2.difypkg') + }) + }) + + it('should handle rapid button clicks', async () => { + // Arrange + const user = userEvent.setup() + render() + + // Act - Rapidly click different buttons + await user.click(screen.getByText('plugin.source.github')) + await user.click(screen.getByTestId('github-close')) + await user.click(screen.getByText('plugin.source.marketplace')) + + // Assert + expect(mockSetActiveTab).toHaveBeenCalledWith('discover') }) }) - // ==================== Side Effects Tests ==================== - describe('Side Effects', () => { - it('should update installMethods when system features change', async () => { - // Arrange - Start with marketplace enabled - setMockSystemFeatures({ + // ============================================================================ + // Prop Variations Tests + // ============================================================================ + describe('Prop Variations', () => { + it('should render all install methods when all permissions are granted', () => { + // Arrange + mockSystemFeatures = createDefaultSystemFeatures({ enable_marketplace: true, plugin_installation_permission: { plugin_installation_scope: InstallationScope.ALL, @@ -446,127 +576,247 @@ describe('Empty Component', () => { }, }) - const { rerender } = render() - await flushEffects() + // Act + render() - // Assert initial state - 3 methods - expect(screen.getAllByRole('button')).toHaveLength(3) + // Assert + expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument() + expect(screen.getByText('plugin.source.github')).toBeInTheDocument() + expect(screen.getByText('plugin.source.local')).toBeInTheDocument() + }) - // Act - Restrict to marketplace only - setMockSystemFeatures({ + it('should render only marketplace when restricted', () => { + // Arrange + mockSystemFeatures = createDefaultSystemFeatures({ enable_marketplace: true, plugin_installation_permission: { - plugin_installation_scope: InstallationScope.ALL, + plugin_installation_scope: InstallationScope.OFFICIAL_ONLY, restrict_to_marketplace_only: true, }, }) - rerender() - await flushEffects() - // Assert - Only marketplace button - expect(screen.getAllByRole('button')).toHaveLength(1) + // Act + render() + + // Assert 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 update text when pluginList or filters change', async () => { + it('should render github and local when marketplace is disabled', () => { // Arrange - setMockPluginList({ plugins: [] }) - const { rerender } = render() - await flushEffects() + mockSystemFeatures = createDefaultSystemFeatures({ + enable_marketplace: false, + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.ALL, + restrict_to_marketplace_only: false, + }, + }) - // Assert initial state - expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument() + // Act + render() - // Act - Update to have plugins with filters - setMockFilters({ categories: ['tool'] }) - setMockPluginList({ plugins: [{ id: 'plugin-1' }] }) - rerender() - await flushEffects() + // Assert + expect(screen.queryByText('plugin.source.marketplace')).not.toBeInTheDocument() + expect(screen.getByText('plugin.source.github')).toBeInTheDocument() + expect(screen.getByText('plugin.source.local')).toBeInTheDocument() + }) + + it('should handle filter combinations for text display', () => { + // Arrange + mockUseInstalledPluginList.mockReturnValue(createPluginListResponse([{ id: '1' }])) + mockFilters = createFilterState({ + categories: ['model'], + tags: ['agent'], + searchQuery: 'test', + }) + + // Act + render() // Assert expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() }) }) - // ==================== Edge Cases ==================== - describe('Edge Cases', () => { - it('should handle undefined/null plugin data gracefully', () => { - // Test undefined plugin list - setMockPluginList(undefined) - expect(() => render()).not.toThrow() - - // Test null plugins array - mockUseInstalledPluginList.mockReturnValue({ data: { plugins: null as any } }) - expect(() => render()).not.toThrow() - }) - - it('should handle file input edge cases', async () => { - // Arrange + // ============================================================================ + // API Calls Tests + // ============================================================================ + describe('API Calls', () => { + it('should call useInstalledPluginList on mount', () => { + // Arrange & Act render() - await flushEffects() - const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement - // Test undefined files - Object.defineProperty(fileInput, 'files', { value: undefined, writable: true }) - fireEvent.change(fileInput) - expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() - }) - }) - - // ==================== React.memo Tests ==================== - describe('React.memo Behavior', () => { - it('should be wrapped with React.memo and have displayName', () => { // Assert - expect(Empty).toBeDefined() - expect((Empty as any).$$typeof?.toString()).toContain('Symbol') - expect((Empty as any).displayName || (Empty as any).type?.displayName).toBeDefined() + expect(mockUseInstalledPluginList).toHaveBeenCalled() + }) + + it('should handle plugin list loading state', () => { + // Arrange + mockUseInstalledPluginList.mockReturnValue({ + data: undefined, + isLoading: true, + }) + + // Act + render() + + // Assert - Component should still render + expect(document.querySelector('.relative.z-0.w-full.grow')).toBeInTheDocument() + }) + + it('should handle plugin list error state', () => { + // Arrange + mockUseInstalledPluginList.mockReturnValue({ + data: undefined, + error: new Error('Failed to fetch'), + }) + + // Act + render() + + // Assert - Component should still render gracefully + expect(document.querySelector('.relative.z-0.w-full.grow')).toBeInTheDocument() }) }) - // ==================== Modal Callbacks Tests ==================== - describe('Modal Callbacks', () => { - it('should handle modal onSuccess callbacks (noop)', async () => { + // ============================================================================ + // File Input Tests + // ============================================================================ + describe('File Input', () => { + it('should have correct accept attribute', () => { + // Arrange & Act + render() + + // Assert + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + expect(fileInput).toHaveAttribute('accept', '.difypkg,.difybndl') + }) + + it('should be hidden from view', () => { + // Arrange & Act + render() + + // Assert + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + expect(fileInput).toHaveStyle({ display: 'none' }) + }) + + it('should handle file input ref correctly', async () => { // Arrange render() - await flushEffects() - // Test GitHub modal onSuccess - fireEvent.click(screen.getByText('plugin.source.github')) - fireEvent.click(screen.getByTestId('github-modal-success')) - expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() + // Act + const localButton = screen.getByText('plugin.source.local') + await userEvent.click(localButton) - // Close GitHub modal and test Local modal onSuccess - fireEvent.click(screen.getByTestId('github-modal-close')) + // Assert - File input should have been accessed via ref + const fileInput = document.querySelector('input[type="file"]') + expect(fileInput).toBeInTheDocument() + }) + }) + // ============================================================================ + // Integration Tests + // ============================================================================ + describe('Integration', () => { + it('should complete full workflow: click github -> close modal', async () => { + // Arrange + render() + + // Act + await userEvent.click(screen.getByText('plugin.source.github')) + expect(screen.getByTestId('install-from-github')).toBeInTheDocument() + + await userEvent.click(screen.getByTestId('github-close')) + + // Assert + expect(screen.queryByTestId('install-from-github')).not.toBeInTheDocument() + }) + + it('should complete full workflow: select file -> close modal', async () => { + // Arrange + render() + const testFile = new File(['test'], 'test.difypkg') + + // Act const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement - Object.defineProperty(fileInput, 'files', { value: [createMockFile('test-plugin.difypkg')], writable: true }) + Object.defineProperty(fileInput, 'files', { value: [testFile], configurable: true }) fireEvent.change(fileInput) - fireEvent.click(screen.getByTestId('local-modal-success')) - expect(screen.getByTestId('install-from-local-modal')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByTestId('install-from-local-package')).toBeInTheDocument() + }) + + await userEvent.click(screen.getByTestId('local-close')) + + // Assert + expect(screen.queryByTestId('install-from-local-package')).not.toBeInTheDocument() + }) + + it('should switch between modals correctly', async () => { + // Arrange + render() + + // Act - Open GitHub modal + await userEvent.click(screen.getByText('plugin.source.github')) + expect(screen.getByTestId('install-from-github')).toBeInTheDocument() + + // Close and navigate to marketplace + await userEvent.click(screen.getByTestId('github-close')) + await userEvent.click(screen.getByText('plugin.source.marketplace')) + + // Assert + expect(mockSetActiveTab).toHaveBeenCalledWith('discover') + expect(screen.queryByTestId('install-from-github')).not.toBeInTheDocument() }) }) - // ==================== Conditional Modal Rendering ==================== - describe('Conditional Modal Rendering', () => { - it('should only render one modal at a time and require file for local modal', async () => { + // ============================================================================ + // Conditional Rendering Tests + // ============================================================================ + describe('Conditional Rendering', () => { + it('should show github modal only when selectedAction is github', async () => { // Arrange render() - await flushEffects() - // Assert - no modals initially - expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument() - expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() + // Assert - Initially hidden + expect(screen.queryByTestId('install-from-github')).not.toBeInTheDocument() - // Open GitHub modal - only GitHub modal visible - fireEvent.click(screen.getByText('plugin.source.github')) - expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() - expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() + // Act + await userEvent.click(screen.getByText('plugin.source.github')) - // Click local button - triggers file input, no modal yet (no file selected) - 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() + // Assert - Now visible + expect(screen.getByTestId('install-from-github')).toBeInTheDocument() + }) + + it('should show local package modal only when action is local AND file is selected', async () => { + // Arrange + render() + + // Assert - Initially hidden + expect(screen.queryByTestId('install-from-local-package')).not.toBeInTheDocument() + + // Act - Click local button (triggers file picker, but no file selected yet) + // We need to simulate file selection + const testFile = new File(['content'], 'test.difypkg') + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + Object.defineProperty(fileInput, 'files', { value: [testFile], configurable: true }) + fireEvent.change(fileInput) + + // Assert - Now visible with file + await waitFor(() => { + expect(screen.getByTestId('install-from-local-package')).toBeInTheDocument() + }) + }) + + it('should not show local package modal if no file is selected', () => { + // Arrange + render() + + // Assert + expect(screen.queryByTestId('install-from-local-package')).not.toBeInTheDocument() }) }) })