diff --git a/web/app/components/header/account-dropdown/compliance.spec.tsx b/web/app/components/header/account-dropdown/compliance.spec.tsx index 1eb747e154..c517325820 100644 --- a/web/app/components/header/account-dropdown/compliance.spec.tsx +++ b/web/app/components/header/account-dropdown/compliance.spec.tsx @@ -225,5 +225,97 @@ describe('Compliance', () => { payload: ACCOUNT_SETTING_TAB.BILLING, }) }) + + // isPending branches: spinner visible, disabled class, guard blocks second call + it('should show spinner and guard against duplicate download when isPending is true', async () => { + // Arrange + let resolveDownload: (value: { url: string }) => void + vi.mocked(getDocDownloadUrl).mockImplementation(() => new Promise((resolve) => { + resolveDownload = resolve + })) + vi.mocked(useProviderContext).mockReturnValue({ + ...baseProviderContextValue, + plan: { + ...baseProviderContextValue.plan, + type: Plan.team, + }, + }) + + // Act + openMenuAndRender() + const downloadButtons = screen.getAllByText('common.operation.download') + fireEvent.click(downloadButtons[0]) + + // Assert - btn-disabled class and spinner should appear while mutation is pending + await waitFor(() => { + const menuItem = screen.getByText('common.compliance.soc2Type1').closest('[role="menuitem"]') + expect(menuItem).not.toBeNull() + const disabledBtn = menuItem!.querySelector('.cursor-not-allowed') + expect(disabledBtn).not.toBeNull() + }, { timeout: 10000 }) + + // Cleanup: resolve the pending promise + resolveDownload!({ url: 'http://example.com/doc.pdf' }) + await waitFor(() => { + expect(downloadUrl).toHaveBeenCalled() + }) + }) + + it('should not call downloadCompliance again while pending', async () => { + let resolveDownload: (value: { url: string }) => void + vi.mocked(getDocDownloadUrl).mockImplementation(() => new Promise((resolve) => { + resolveDownload = resolve + })) + vi.mocked(useProviderContext).mockReturnValue({ + ...baseProviderContextValue, + plan: { + ...baseProviderContextValue.plan, + type: Plan.team, + }, + }) + + openMenuAndRender() + const downloadButtons = screen.getAllByText('common.operation.download') + + // First click starts download + fireEvent.click(downloadButtons[0]) + + // Wait for mutation to start and React to re-render (isPending=true) + await waitFor(() => { + const menuItem = screen.getByText('common.compliance.soc2Type1').closest('[role="menuitem"]') + const el = menuItem!.querySelector('.cursor-not-allowed') + expect(el).not.toBeNull() + expect(getDocDownloadUrl).toHaveBeenCalledTimes(1) + }, { timeout: 10000 }) + + // Second click while pending - should be guarded by isPending check + fireEvent.click(downloadButtons[0]) + + resolveDownload!({ url: 'http://example.com/doc.pdf' }) + await waitFor(() => { + expect(downloadUrl).toHaveBeenCalledTimes(1) + }, { timeout: 10000 }) + // getDocDownloadUrl should still have only been called once + expect(getDocDownloadUrl).toHaveBeenCalledTimes(1) + }, 20000) + + // canShowUpgradeTooltip=false: enterprise plan has empty tooltip text → no TooltipContent + it('should show upgrade badge with empty tooltip for enterprise plan', () => { + // Arrange + vi.mocked(useProviderContext).mockReturnValue({ + ...baseProviderContextValue, + plan: { + ...baseProviderContextValue.plan, + type: Plan.enterprise, + }, + }) + + // Act + openMenuAndRender() + + // Assert - enterprise is not in any download list, so upgrade badges should appear + // The key branch: upgradeTooltip[Plan.enterprise] = '' → canShowUpgradeTooltip=false + expect(screen.getAllByText('billing.upgradeBtn.encourageShort').length).toBeGreaterThan(0) + }) }) }) diff --git a/web/app/components/header/account-dropdown/index.spec.tsx b/web/app/components/header/account-dropdown/index.spec.tsx index c234d350d8..e33d89fa95 100644 --- a/web/app/components/header/account-dropdown/index.spec.tsx +++ b/web/app/components/header/account-dropdown/index.spec.tsx @@ -247,6 +247,23 @@ describe('AccountDropdown', () => { // Assert expect(screen.getByText('common.userProfile.compliance')).toBeInTheDocument() }) + + // Compound AND middle-false: IS_CLOUD_EDITION=true but isCurrentWorkspaceOwner=false + it('should hide Compliance in Cloud Edition when user is not workspace owner', () => { + // Arrange + mockConfig.IS_CLOUD_EDITION = true + vi.mocked(useAppContext).mockReturnValue({ + ...baseAppContextValue, + isCurrentWorkspaceOwner: false, + }) + + // Act + renderWithRouter() + fireEvent.click(screen.getByRole('button')) + + // Assert + expect(screen.queryByText('common.userProfile.compliance')).not.toBeInTheDocument() + }) }) describe('Actions', () => { diff --git a/web/app/components/header/account-dropdown/support.spec.tsx b/web/app/components/header/account-dropdown/support.spec.tsx index a7b1aab048..a19c15200b 100644 --- a/web/app/components/header/account-dropdown/support.spec.tsx +++ b/web/app/components/header/account-dropdown/support.spec.tsx @@ -36,8 +36,8 @@ vi.mock('@/config', async (importOriginal) => { return { ...actual, IS_CE_EDITION: false, - get ZENDESK_WIDGET_KEY() { return mockZendeskKey.value }, - get SUPPORT_EMAIL_ADDRESS() { return mockSupportEmailKey.value }, + get ZENDESK_WIDGET_KEY() { return mockZendeskKey.value || '' }, + get SUPPORT_EMAIL_ADDRESS() { return mockSupportEmailKey.value || '' }, } }) @@ -173,25 +173,18 @@ describe('Support', () => { expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument() }) - it('should show email support if specified in the config', () => { + // Optional chain null guard: ZENDESK_WIDGET_KEY is null + it('should show Email Support when ZENDESK_WIDGET_KEY is null', () => { // Arrange - mockZendeskKey.value = '' - mockSupportEmailKey.value = 'support@example.com' - vi.mocked(useProviderContext).mockReturnValue({ - ...baseProviderContextValue, - plan: { - ...baseProviderContextValue.plan, - type: Plan.sandbox, - }, - }) + mockZendeskKey.value = null as unknown as string // Act renderSupport() fireEvent.click(screen.getByText('common.userProfile.support')) // Assert - expect(screen.queryByText('common.userProfile.emailSupport')).toBeInTheDocument() - expect(screen.getByText('common.userProfile.emailSupport')?.closest('a')?.getAttribute('href')?.startsWith(`mailto:${mockSupportEmailKey.value}`)).toBe(true) + expect(screen.getByText('common.userProfile.emailSupport')).toBeInTheDocument() + expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/header/account-dropdown/workplace-selector/index.spec.tsx b/web/app/components/header/account-dropdown/workplace-selector/index.spec.tsx index 20104b572c..dd06cd30e9 100644 --- a/web/app/components/header/account-dropdown/workplace-selector/index.spec.tsx +++ b/web/app/components/header/account-dropdown/workplace-selector/index.spec.tsx @@ -136,4 +136,32 @@ describe('WorkplaceSelector', () => { }) }) }) + + describe('Edge Cases', () => { + // find() returns undefined: no workspace with current: true + it('should not crash when no workspace has current: true', () => { + // Arrange + vi.mocked(useWorkspacesContext).mockReturnValue({ + workspaces: [ + { id: '1', name: 'Workspace 1', current: false, plan: 'professional', status: 'normal', created_at: Date.now() }, + ], + }) + + // Act & Assert - should not throw + expect(() => renderComponent()).not.toThrow() + }) + + // name[0]?.toLocaleUpperCase() undefined: workspace with empty name + it('should not crash when workspace name is empty string', () => { + // Arrange + vi.mocked(useWorkspacesContext).mockReturnValue({ + workspaces: [ + { id: '1', name: '', current: true, plan: 'sandbox', status: 'normal', created_at: Date.now() }, + ], + }) + + // Act & Assert - should not throw + expect(() => renderComponent()).not.toThrow() + }) + }) }) diff --git a/web/app/components/header/account-setting/data-source-page/data-source-notion/index.spec.tsx b/web/app/components/header/account-setting/data-source-page/data-source-notion/index.spec.tsx index 5a1398499b..c5e0ba40c9 100644 --- a/web/app/components/header/account-setting/data-source-page/data-source-notion/index.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page/data-source-notion/index.spec.tsx @@ -388,37 +388,33 @@ describe('DataSourceNotion Component', () => { }) describe('Additional Action Edge Cases', () => { - it('should cover all possible falsy/nullish branches for connection data in handleAuthAgain and useEffect', async () => { + it.each([ + undefined, + null, + {}, + { data: undefined }, + { data: null }, + { data: '' }, + { data: 0 }, + { data: false }, + { data: 'http' }, + { data: 'internal' }, + { data: 'unknown' }, + ])('should cover connection data branch: %s', async (val) => { vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: mockWorkspaces })) + /* eslint-disable-next-line ts/no-explicit-any */ + vi.mocked(useNotionConnection).mockReturnValue({ data: val, isSuccess: true } as any) + render() - const connectionCases = [ - undefined, - null, - {}, - { data: undefined }, - { data: null }, - { data: '' }, - { data: 0 }, - { data: false }, - { data: 'http' }, - { data: 'internal' }, - { data: 'unknown' }, - ] + // Trigger handleAuthAgain with these values + const workspaceItem = getWorkspaceItem('Workspace 1') + const actionBtn = within(workspaceItem).getByRole('button') + fireEvent.click(actionBtn) + const authAgainBtn = await screen.findByText('common.dataSource.notion.changeAuthorizedPages') + fireEvent.click(authAgainBtn) - for (const val of connectionCases) { - /* eslint-disable-next-line ts/no-explicit-any */ - vi.mocked(useNotionConnection).mockReturnValue({ data: val, isSuccess: true } as any) - - // Trigger handleAuthAgain with these values - const workspaceItem = getWorkspaceItem('Workspace 1') - const actionBtn = within(workspaceItem).getByRole('button') - fireEvent.click(actionBtn) - const authAgainBtn = await screen.findByText('common.dataSource.notion.changeAuthorizedPages') - fireEvent.click(authAgainBtn) - } - - await waitFor(() => expect(useNotionConnection).toHaveBeenCalled()) + expect(useNotionConnection).toHaveBeenCalled() }) }) diff --git a/web/app/components/header/account-setting/data-source-page/data-source-website/config-jina-reader-modal.spec.tsx b/web/app/components/header/account-setting/data-source-page/data-source-website/config-jina-reader-modal.spec.tsx index ac733c4de5..937fa2dfd0 100644 --- a/web/app/components/header/account-setting/data-source-page/data-source-website/config-jina-reader-modal.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page/data-source-website/config-jina-reader-modal.spec.tsx @@ -134,5 +134,46 @@ describe('ConfigJinaReaderModal Component', () => { resolveSave!({ result: 'success' }) await waitFor(() => expect(mockOnSaved).toHaveBeenCalledTimes(1)) }) + + it('should show encryption info and external link in the modal', async () => { + render() + + // Verify PKCS1_OAEP link exists + const pkcsLink = screen.getByText('PKCS1_OAEP') + expect(pkcsLink.closest('a')).toHaveAttribute('href', 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html') + + // Verify the Jina Reader external link + const jinaLink = screen.getByRole('link', { name: /datasetCreation\.jinaReader\.getApiKeyLinkText/i }) + expect(jinaLink).toHaveAttribute('target', '_blank') + }) + + it('should return early when save is clicked while already saving (isSaving guard)', async () => { + const user = userEvent.setup() + // Arrange - a save that never resolves so isSaving stays true + let resolveFirst: (value: { result: 'success' }) => void + const neverResolves = new Promise<{ result: 'success' }>((resolve) => { + resolveFirst = resolve + }) + vi.mocked(createDataSourceApiKeyBinding).mockReturnValue(neverResolves) + render() + + const apiKeyInput = screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder') + await user.type(apiKeyInput, 'valid-key') + + const saveBtn = screen.getByRole('button', { name: /common\.operation\.save/i }) + // First click - starts saving, isSaving becomes true + await user.click(saveBtn) + expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1) + + // Second click using fireEvent bypasses disabled check - hits isSaving guard + const { fireEvent: fe } = await import('@testing-library/react') + fe.click(saveBtn) + // Still only called once because isSaving=true returns early + expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1) + + // Cleanup + resolveFirst!({ result: 'success' }) + await waitFor(() => expect(mockOnSaved).toHaveBeenCalled()) + }) }) }) diff --git a/web/app/components/header/account-setting/data-source-page/data-source-website/index.spec.tsx b/web/app/components/header/account-setting/data-source-page/data-source-website/index.spec.tsx index a0e01a9175..929160e5de 100644 --- a/web/app/components/header/account-setting/data-source-page/data-source-website/index.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page/data-source-website/index.spec.tsx @@ -195,4 +195,57 @@ describe('DataSourceWebsite Component', () => { expect(removeDataSourceApiKeyBinding).not.toHaveBeenCalled() }) }) + + describe('Firecrawl Save Flow', () => { + it('should re-fetch sources after saving Firecrawl configuration', async () => { + // Arrange + await renderAndWait(DataSourceProvider.fireCrawl) + fireEvent.click(screen.getByText('common.dataSource.configure')) + expect(screen.getByText('datasetCreation.firecrawl.configFirecrawl')).toBeInTheDocument() + vi.mocked(fetchDataSources).mockClear() + + // Act - fill in required API key field and save + const apiKeyInput = screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder') + fireEvent.change(apiKeyInput, { target: { value: 'test-key' } }) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i })) + + // Assert + await waitFor(() => { + expect(fetchDataSources).toHaveBeenCalled() + expect(screen.queryByText('datasetCreation.firecrawl.configFirecrawl')).not.toBeInTheDocument() + }) + }) + }) + + describe('Cancel Flow', () => { + it('should close watercrawl modal when cancel is clicked', async () => { + // Arrange + await renderAndWait(DataSourceProvider.waterCrawl) + fireEvent.click(screen.getByText('common.dataSource.configure')) + expect(screen.getByText('datasetCreation.watercrawl.configWatercrawl')).toBeInTheDocument() + + // Act + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i })) + + // Assert - modal closed + await waitFor(() => { + expect(screen.queryByText('datasetCreation.watercrawl.configWatercrawl')).not.toBeInTheDocument() + }) + }) + + it('should close jina reader modal when cancel is clicked', async () => { + // Arrange + await renderAndWait(DataSourceProvider.jinaReader) + fireEvent.click(screen.getByText('common.dataSource.configure')) + expect(screen.getByText('datasetCreation.jinaReader.configJinaReader')).toBeInTheDocument() + + // Act + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i })) + + // Assert - modal closed + await waitFor(() => { + expect(screen.queryByText('datasetCreation.jinaReader.configJinaReader')).not.toBeInTheDocument() + }) + }) + }) }) diff --git a/web/app/components/header/account-setting/key-validator/Operate.spec.tsx b/web/app/components/header/account-setting/key-validator/Operate.spec.tsx index 8ecd1a9f0e..001f6727dc 100644 --- a/web/app/components/header/account-setting/key-validator/Operate.spec.tsx +++ b/web/app/components/header/account-setting/key-validator/Operate.spec.tsx @@ -1,8 +1,9 @@ import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import Operate from './Operate' describe('Operate', () => { - it('renders cancel and save when editing', () => { + it('should render cancel and save when editing is open', () => { render( { expect(screen.getByText('common.operation.save')).toBeInTheDocument() }) - it('shows add key prompt when closed', () => { + it('should show add-key prompt when closed', () => { render( { expect(screen.getByText('common.provider.addKey')).toBeInTheDocument() }) - it('shows invalid state indicator and edit prompt when status is fail', () => { + it('should show invalid state and edit prompt when status is fail', () => { render( { expect(screen.getByText('common.provider.editKey')).toBeInTheDocument() }) - it('shows edit prompt without error text when status is success', () => { + it('should show edit prompt without error text when status is success', () => { render( { expect(screen.queryByText('common.provider.invalidApiKey')).toBeNull() }) - it('shows no actions for unsupported status', () => { + it('should not call onAdd when disabled', async () => { + const user = userEvent.setup() + const onAdd = vi.fn() render( , + ) + await user.click(screen.getByText('common.provider.addKey')) + expect(onAdd).not.toHaveBeenCalled() + }) + + it('should show no actions when status is unsupported', () => { + render( + { expect(screen.getByText(/plansCommon\.unlimited/i)).toBeInTheDocument() }) + it('should show non-billing member format for team plan even when billing is enabled', () => { + vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({ + enableBilling: true, + plan: { + type: Plan.team, + total: { teamMembers: 50 } as unknown as ReturnType['plan']['total'], + } as unknown as ReturnType['plan'], + })) + + render() + + // Plan.team is an unlimited member plan → isNotUnlimitedMemberPlan=false → non-billing layout + expect(screen.getByText(/plansCommon\.memberAfter/i)).toBeInTheDocument() + }) + + it('should show invite button when user is manager but not owner', () => { + vi.mocked(useAppContext).mockReturnValue({ + userProfile: { email: 'admin@example.com' }, + currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace, + isCurrentWorkspaceOwner: false, + isCurrentWorkspaceManager: true, + } as unknown as AppContextValue) + + render() + + expect(screen.getByRole('button', { name: /invite/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /transfer ownership/i })).not.toBeInTheDocument() + }) + + it('should use created_at as fallback when last_active_at is empty', () => { + const memberNoLastActive: Member = { + ...mockAccounts[1], + last_active_at: '', + created_at: '1700000000', + } + vi.mocked(useMembers).mockReturnValue({ + data: { accounts: [memberNoLastActive] }, + refetch: mockRefetch, + } as unknown as ReturnType) + + render() + + expect(mockFormatTimeFromNow).toHaveBeenCalledWith(1700000000000) + }) + + it('should not show plural s when only one account in billing layout', () => { + vi.mocked(useMembers).mockReturnValue({ + data: { accounts: [mockAccounts[0]] }, + refetch: mockRefetch, + } as unknown as ReturnType) + vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({ + enableBilling: true, + plan: { + type: Plan.sandbox, + total: { teamMembers: 5 } as unknown as ReturnType['plan']['total'], + } as unknown as ReturnType['plan'], + })) + + render() + + expect(screen.getByText(/plansCommon\.member/i)).toBeInTheDocument() + expect(screen.getByText('1')).toBeInTheDocument() + }) + + it('should not show plural s when only one account in non-billing layout', () => { + vi.mocked(useMembers).mockReturnValue({ + data: { accounts: [mockAccounts[0]] }, + refetch: mockRefetch, + } as unknown as ReturnType) + + render() + + expect(screen.getByText(/plansCommon\.memberAfter/i)).toBeInTheDocument() + expect(screen.getByText('1')).toBeInTheDocument() + }) + + it('should show normal role as fallback for unknown role', () => { + vi.mocked(useAppContext).mockReturnValue({ + userProfile: { email: 'admin@example.com' }, + currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace, + isCurrentWorkspaceOwner: false, + isCurrentWorkspaceManager: false, + } as unknown as AppContextValue) + vi.mocked(useMembers).mockReturnValue({ + data: { accounts: [{ ...mockAccounts[1], role: 'unknown_role' as Member['role'] }] }, + refetch: mockRefetch, + } as unknown as ReturnType) + + render() + + expect(screen.getByText('common.members.normal')).toBeInTheDocument() + }) + it('should show upgrade button when member limit is full', () => { vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({ enableBilling: true, diff --git a/web/app/components/header/account-setting/members-page/invite-modal/index.spec.tsx b/web/app/components/header/account-setting/members-page/invite-modal/index.spec.tsx index 82882c8be5..04f5491cc8 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/index.spec.tsx @@ -1,5 +1,5 @@ import type { InvitationResponse } from '@/models/common' -import { render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { vi } from 'vitest' import { ToastContext } from '@/app/components/base/toast/context' @@ -171,6 +171,66 @@ describe('InviteModal', () => { expect(screen.queryByText('user@example.com')).not.toBeInTheDocument() }) + it('should show unlimited label when workspace member limit is zero', async () => { + vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({ + licenseLimit: { workspace_members: { size: 5, limit: 0 } }, + refreshLicenseLimit: mockRefreshLicenseLimit, + } as unknown as Parameters[0])) + + renderModal() + + expect(await screen.findByText(/license\.unlimited/i)).toBeInTheDocument() + }) + + it('should initialize usedSize to zero when workspace_members.size is null', async () => { + vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({ + licenseLimit: { workspace_members: { size: null, limit: 10 } }, + refreshLicenseLimit: mockRefreshLicenseLimit, + } as unknown as Parameters[0])) + + renderModal() + + // usedSize starts at 0 (via ?? 0 fallback), no emails added → counter shows 0 + expect(await screen.findByText('0')).toBeInTheDocument() + }) + + it('should not call onSend when invite result is not success', async () => { + const user = userEvent.setup() + vi.mocked(inviteMember).mockResolvedValue({ + result: 'error', + invitation_results: [], + } as unknown as InvitationResponse) + + renderModal() + + await user.type(screen.getByTestId('mock-email-input'), 'user@example.com') + await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) + + await waitFor(() => { + expect(inviteMember).toHaveBeenCalled() + expect(mockOnSend).not.toHaveBeenCalled() + expect(mockOnCancel).not.toHaveBeenCalled() + }) + }) + + it('should show destructive text color when used size exceeds limit', async () => { + const user = userEvent.setup() + + vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({ + licenseLimit: { workspace_members: { size: 10, limit: 10 } }, + refreshLicenseLimit: mockRefreshLicenseLimit, + } as unknown as Parameters[0])) + + renderModal() + + const input = screen.getByTestId('mock-email-input') + await user.type(input, 'user@example.com') + + // usedSize = 10 + 1 = 11 > limit 10 → destructive color + const counter = screen.getByText('11') + expect(counter.closest('div')).toHaveClass('text-text-destructive') + }) + it('should not submit if already submitting', async () => { const user = userEvent.setup() let resolveInvite: (value: InvitationResponse) => void @@ -202,4 +262,72 @@ describe('InviteModal', () => { expect(mockOnCancel).toHaveBeenCalled() }) }) + + it('should show destructive color and disable send button when limit is exactly met with one email', async () => { + const user = userEvent.setup() + + // size=10, limit=10 - adding 1 email makes usedSize=11 > limit=10 + vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({ + licenseLimit: { workspace_members: { size: 10, limit: 10 } }, + refreshLicenseLimit: mockRefreshLicenseLimit, + } as unknown as Parameters[0])) + + renderModal() + + const input = screen.getByTestId('mock-email-input') + await user.type(input, 'user@example.com') + + // isLimitExceeded=true → button is disabled, cannot submit + const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i }) + expect(sendBtn).toBeDisabled() + expect(inviteMember).not.toHaveBeenCalled() + }) + + it('should hit isSubmitting guard inside handleSend when button is force-clicked during submission', async () => { + const user = userEvent.setup() + let resolveInvite: (value: InvitationResponse) => void + const invitePromise = new Promise((resolve) => { + resolveInvite = resolve + }) + vi.mocked(inviteMember).mockReturnValue(invitePromise) + + renderModal() + + const input = screen.getByTestId('mock-email-input') + await user.type(input, 'user@example.com') + + const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i }) + + // First click starts submission + await user.click(sendBtn) + expect(inviteMember).toHaveBeenCalledTimes(1) + + // Force-click bypasses disabled attribute → hits isSubmitting guard in handleSend + fireEvent.click(sendBtn) + expect(inviteMember).toHaveBeenCalledTimes(1) + + // Cleanup + resolveInvite!({ result: 'success', invitation_results: [] }) + await waitFor(() => { + expect(mockOnCancel).toHaveBeenCalled() + }) + }) + + it('should not show error text color when isLimited is false even with many emails', async () => { + // size=0, limit=0 → isLimited=false, usedSize=emails.length + vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({ + licenseLimit: { workspace_members: { size: 0, limit: 0 } }, + refreshLicenseLimit: mockRefreshLicenseLimit, + } as unknown as Parameters[0])) + + const user = userEvent.setup() + renderModal() + + const input = screen.getByTestId('mock-email-input') + await user.type(input, 'user@example.com') + + // isLimited=false → no destructive color + const counter = screen.getByText('1') + expect(counter.closest('div')).not.toHaveClass('text-text-destructive') + }) }) diff --git a/web/app/components/header/account-setting/members-page/invited-modal/index.spec.tsx b/web/app/components/header/account-setting/members-page/invited-modal/index.spec.tsx index 127c33a29f..b67fc3e42c 100644 --- a/web/app/components/header/account-setting/members-page/invited-modal/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/invited-modal/index.spec.tsx @@ -2,8 +2,12 @@ import type { InvitationResult } from '@/models/common' import { render, screen } from '@testing-library/react' import InvitedModal from './index' +const mockConfigState = vi.hoisted(() => ({ isCeEdition: true })) + vi.mock('@/config', () => ({ - IS_CE_EDITION: true, + get IS_CE_EDITION() { + return mockConfigState.isCeEdition + }, })) describe('InvitedModal', () => { @@ -13,6 +17,11 @@ describe('InvitedModal', () => { { email: 'failed@example.com', status: 'failed', message: 'Error msg' }, ] + beforeEach(() => { + vi.clearAllMocks() + mockConfigState.isCeEdition = true + }) + it('should show success and failed invitation sections', async () => { render() @@ -21,4 +30,59 @@ describe('InvitedModal', () => { expect(screen.getByText('http://invite.com/1')).toBeInTheDocument() expect(screen.getByText('failed@example.com')).toBeInTheDocument() }) + + it('should hide invitation link section when there are no successes', () => { + const failedOnly: InvitationResult[] = [ + { email: 'fail@example.com', status: 'failed', message: 'Quota exceeded' }, + ] + + render() + + expect(screen.queryByText(/members\.invitationLink/i)).not.toBeInTheDocument() + expect(screen.getByText(/members\.failedInvitationEmails/i)).toBeInTheDocument() + }) + + it('should hide failed section when there are only successes', () => { + const successOnly: InvitationResult[] = [ + { email: 'ok@example.com', status: 'success', url: 'http://invite.com/2' }, + ] + + render() + + expect(screen.getByText(/members\.invitationLink/i)).toBeInTheDocument() + expect(screen.queryByText(/members\.failedInvitationEmails/i)).not.toBeInTheDocument() + }) + + it('should hide both sections when results are empty', () => { + render() + + expect(screen.queryByText(/members\.invitationLink/i)).not.toBeInTheDocument() + expect(screen.queryByText(/members\.failedInvitationEmails/i)).not.toBeInTheDocument() + }) +}) + +describe('InvitedModal (non-CE edition)', () => { + const mockOnCancel = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockConfigState.isCeEdition = false + }) + + afterEach(() => { + mockConfigState.isCeEdition = true + }) + + it('should render invitationSentTip without CE edition content when IS_CE_EDITION is false', async () => { + const results: InvitationResult[] = [ + { email: 'success@example.com', status: 'success', url: 'http://invite.com/1' }, + ] + + render() + + // The !IS_CE_EDITION branch - should show the tip text + expect(await screen.findByText(/members\.invitationSentTip/i)).toBeInTheDocument() + // CE-only content should not be shown + expect(screen.queryByText(/members\.invitationLink/i)).not.toBeInTheDocument() + }) }) diff --git a/web/app/components/header/account-setting/members-page/operation/index.spec.tsx b/web/app/components/header/account-setting/members-page/operation/index.spec.tsx index e5e7fac10f..cfa29ec083 100644 --- a/web/app/components/header/account-setting/members-page/operation/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/operation/index.spec.tsx @@ -49,13 +49,13 @@ describe('Operation', () => { mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: false }) }) - it('renders the current role label', () => { + it('should render the current role label when member has editor role', () => { renderOperation() expect(screen.getByText('common.members.editor')).toBeInTheDocument() }) - it('shows dataset operator option when the feature flag is enabled', async () => { + it('should show dataset operator option when feature flag is enabled', async () => { const user = userEvent.setup() mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true }) @@ -66,7 +66,7 @@ describe('Operation', () => { expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument() }) - it('shows owner-allowed role options for admin operators', async () => { + it('should show owner-allowed role options when operator role is admin', async () => { const user = userEvent.setup() renderOperation({}, 'admin') @@ -77,7 +77,7 @@ describe('Operation', () => { expect(screen.getByText('common.members.normal')).toBeInTheDocument() }) - it('does not show role options for unsupported operators', async () => { + it('should not show role options when operator role is unsupported', async () => { const user = userEvent.setup() renderOperation({}, 'normal') @@ -88,7 +88,7 @@ describe('Operation', () => { expect(screen.getByText('common.members.removeFromTeam')).toBeInTheDocument() }) - it('calls updateMemberRole and onOperate when selecting another role', async () => { + it('should call updateMemberRole and onOperate when selecting another role', async () => { const user = userEvent.setup() const onOperate = vi.fn() renderOperation({}, 'owner', onOperate) @@ -102,7 +102,24 @@ describe('Operation', () => { }) }) - it('calls deleteMemberOrCancelInvitation when removing the member', async () => { + it('should show dataset operator option when operator is admin and feature flag is enabled', async () => { + const user = userEvent.setup() + mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true }) + renderOperation({}, 'admin') + + await user.click(screen.getByText('common.members.editor')) + + expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument() + expect(screen.queryByText('common.members.admin')).not.toBeInTheDocument() + }) + + it('should fall back to normal role label when member role is unknown', () => { + renderOperation({ role: 'unknown_role' as Member['role'] }) + + expect(screen.getByText('common.members.normal')).toBeInTheDocument() + }) + + it('should call deleteMemberOrCancelInvitation when removing the member', async () => { const user = userEvent.setup() const onOperate = vi.fn() renderOperation({}, 'owner', onOperate) diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.spec.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.spec.tsx index 4baa90a7fa..f57496451a 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.spec.tsx @@ -13,11 +13,6 @@ vi.mock('@/context/app-context') vi.mock('@/service/common') vi.mock('@/service/use-common') -// Mock Modal directly to avoid transition/portal issues in tests -vi.mock('@/app/components/base/modal', () => ({ - default: ({ children, isShow }: { children: React.ReactNode, isShow: boolean }) => isShow ?
{children}
: null, -})) - vi.mock('./member-selector', () => ({ default: ({ onSelect }: { onSelect: (id: string) => void }) => ( @@ -40,11 +35,13 @@ describe('TransferOwnershipModal', () => { data: { accounts: [] }, } as unknown as ReturnType) - // Fix Location stubbing for reload + // Stub globalThis.location.reload (component calls globalThis.location.reload()) const mockReload = vi.fn() vi.stubGlobal('location', { - ...window.location, reload: mockReload, + href: '', + assign: vi.fn(), + replace: vi.fn(), } as unknown as Location) }) @@ -105,8 +102,8 @@ describe('TransferOwnershipModal', () => { await waitFor(() => { expect(ownershipTransfer).toHaveBeenCalledWith('new-owner-id', { token: 'final-token' }) expect(window.location.reload).toHaveBeenCalled() - }) - }) + }, { timeout: 10000 }) + }, 15000) it('should handle timer countdown and resend', async () => { vi.useFakeTimers() @@ -202,6 +199,70 @@ describe('TransferOwnershipModal', () => { }) }) + it('should handle sendOwnerEmail returning null data', async () => { + const user = userEvent.setup() + vi.mocked(sendOwnerEmail).mockResolvedValue({ + data: null, + result: 'success', + } as unknown as Awaited>) + + renderModal() + await user.click(screen.getByTestId('transfer-modal-send-code')) + + // Should advance to verify step even with null data + await waitFor(() => { + expect(screen.getByText(/members\.transferModal\.verifyEmail/i)).toBeInTheDocument() + }) + }) + + it('should show fallback error prefix when sendOwnerEmail throws null', async () => { + const user = userEvent.setup() + vi.mocked(sendOwnerEmail).mockRejectedValue(null) + + renderModal() + await user.click(screen.getByTestId('transfer-modal-send-code')) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + message: expect.stringContaining('Error sending verification code:'), + })) + }) + }) + + it('should show fallback error prefix when verifyOwnerEmail throws null', async () => { + const user = userEvent.setup() + mockEmailVerification() + vi.mocked(verifyOwnerEmail).mockRejectedValue(null) + + renderModal() + await goToTransferStep(user) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + message: expect.stringContaining('Error verifying email:'), + })) + }) + }) + + it('should show fallback error prefix when ownershipTransfer throws null', async () => { + const user = userEvent.setup() + mockEmailVerification() + vi.mocked(ownershipTransfer).mockRejectedValue(null) + + renderModal() + await goToTransferStep(user) + await selectNewOwnerAndSubmit(user) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + message: expect.stringContaining('Error ownership transfer:'), + })) + }) + }) + it('should close when close button is clicked', async () => { const user = userEvent.setup() renderModal() diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.spec.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.spec.tsx index 376d0921b2..4e38f5ecc2 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.spec.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.spec.tsx @@ -71,9 +71,80 @@ describe('MemberSelector', () => { }) }) + it('should filter list by email when name does not match', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByTestId('member-selector-trigger')) + await user.type(screen.getByTestId('member-selector-search'), 'john@') + + const items = screen.getAllByTestId('member-selector-item') + expect(items).toHaveLength(1) + expect(screen.getByText('John Doe')).toBeInTheDocument() + expect(screen.queryByText('Jane Smith')).not.toBeInTheDocument() + }) + + it('should show placeholder when value does not match any account', () => { + render() + + expect(screen.getByText(/members\.transferModal\.transferPlaceholder/i)).toBeInTheDocument() + }) + it('should handle missing data gracefully', () => { vi.mocked(useMembers).mockReturnValue({ data: undefined } as unknown as ReturnType) render() expect(screen.getByText(/members\.transferModal\.transferPlaceholder/i)).toBeInTheDocument() }) + + it('should filter by email when account name is empty', async () => { + const user = userEvent.setup() + vi.mocked(useMembers).mockReturnValue({ + data: { accounts: [...mockAccounts, { id: '4', name: '', email: 'noname@example.com', avatar_url: '' }] }, + } as unknown as ReturnType) + render() + + await user.click(screen.getByTestId('member-selector-trigger')) + await user.type(screen.getByTestId('member-selector-search'), 'noname@') + + const items = screen.getAllByTestId('member-selector-item') + expect(items).toHaveLength(1) + }) + + it('should apply hover background class when dropdown is open', async () => { + const user = userEvent.setup() + render() + + const trigger = screen.getByTestId('member-selector-trigger') + await user.click(trigger) + + expect(trigger).toHaveClass('bg-state-base-hover-alt') + }) + + it('should not match account when neither name nor email contains search value', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByTestId('member-selector-trigger')) + await user.type(screen.getByTestId('member-selector-search'), 'xyz-no-match-xyz') + + expect(screen.queryByTestId('member-selector-item')).not.toBeInTheDocument() + }) + + it('should fall back to empty string for account with undefined email when searching', async () => { + const user = userEvent.setup() + vi.mocked(useMembers).mockReturnValue({ + data: { + accounts: [ + { id: '1', name: 'John', email: undefined as unknown as string, avatar_url: '' }, + ], + }, + } as unknown as ReturnType) + render() + + await user.click(screen.getByTestId('member-selector-trigger')) + await user.type(screen.getByTestId('member-selector-search'), 'john') + + const items = screen.getAllByTestId('member-selector-item') + expect(items).toHaveLength(1) + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/hooks.spec.ts b/web/app/components/header/account-setting/model-provider-page/hooks.spec.ts index 4908ef52bb..a202470f65 100644 --- a/web/app/components/header/account-setting/model-provider-page/hooks.spec.ts +++ b/web/app/components/header/account-setting/model-provider-page/hooks.spec.ts @@ -433,6 +433,55 @@ describe('hooks', () => { expect(result.current.credentials).toBeUndefined() }) + + it('should not call invalidateQueries when neither predefined nor custom is enabled', () => { + const invalidateQueries = vi.fn() + ; (useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + ; (useQuery as Mock).mockReturnValue({ + data: undefined, + isPending: false, + }) + + // Both predefinedEnabled and customEnabled are false (no credentialId) + const { result } = renderHook(() => useProviderCredentialsAndLoadBalancing( + 'openai', + ConfigurationMethodEnum.predefinedModel, + false, + undefined, + undefined, + )) + + act(() => { + result.current.mutate() + }) + + expect(invalidateQueries).not.toHaveBeenCalled() + }) + + it('should build URL without credentialId when not provided in predefined queryFn', async () => { + // Trigger the queryFn when credentialId is undefined but predefinedEnabled is true + ; (useQuery as Mock).mockReturnValue({ + data: { credentials: { api_key: 'k' } }, + isPending: false, + }) + + const { result: _result } = renderHook(() => useProviderCredentialsAndLoadBalancing( + 'openai', + ConfigurationMethodEnum.predefinedModel, + true, + undefined, + undefined, + )) + + // Find and invoke the predefined queryFn + const queryCall = (useQuery as Mock).mock.calls.find( + call => call[0].queryKey?.[1] === 'credentials', + ) + if (queryCall) { + await queryCall[0].queryFn() + expect(fetchModelProviderCredentials).toHaveBeenCalled() + } + }) }) describe('useModelList', () => { @@ -1111,6 +1160,26 @@ describe('hooks', () => { expect(result.current.plugins![0].plugin_id).toBe('plugin1') }) + it('should deduplicate plugins that exist in both collections and regular plugins', () => { + const duplicatePlugin = { plugin_id: 'shared-plugin', type: 'plugin' } + + ; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ + plugins: [duplicatePlugin], + isLoading: false, + }) + ; (useMarketplacePlugins as Mock).mockReturnValue({ + plugins: [{ ...duplicatePlugin }, { plugin_id: 'unique-plugin', type: 'plugin' }], + queryPlugins: vi.fn(), + queryPluginsWithDebounced: vi.fn(), + isLoading: false, + }) + + const { result } = renderHook(() => useMarketplaceAllPlugins([], '')) + + expect(result.current.plugins).toHaveLength(2) + expect(result.current.plugins!.filter(p => p.plugin_id === 'shared-plugin')).toHaveLength(1) + }) + it('should handle loading states', () => { ; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ plugins: [], @@ -1127,6 +1196,45 @@ describe('hooks', () => { expect(result.current.isLoading).toBe(true) }) + + it('should not crash when plugins is undefined', () => { + ; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ + plugins: [], + isLoading: false, + }) + ; (useMarketplacePlugins as Mock).mockReturnValue({ + plugins: undefined, + queryPlugins: vi.fn(), + queryPluginsWithDebounced: vi.fn(), + isLoading: false, + }) + + const { result } = renderHook(() => useMarketplaceAllPlugins([], '')) + + expect(result.current.plugins).toBeDefined() + expect(result.current.isLoading).toBe(false) + }) + + it('should return search plugins (not allPlugins) when searchText is truthy', () => { + const searchPlugins = [{ plugin_id: 'search-result', type: 'plugin' }] + const collectionPlugins = [{ plugin_id: 'collection-only', type: 'plugin' }] + + ; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ + plugins: collectionPlugins, + isLoading: false, + }) + ; (useMarketplacePlugins as Mock).mockReturnValue({ + plugins: searchPlugins, + queryPlugins: vi.fn(), + queryPluginsWithDebounced: vi.fn(), + isLoading: false, + }) + + const { result } = renderHook(() => useMarketplaceAllPlugins([], 'openai')) + + expect(result.current.plugins).toEqual(searchPlugins) + expect(result.current.plugins?.some(p => p.plugin_id === 'collection-only')).toBe(false) + }) }) describe('useRefreshModel', () => { @@ -1234,6 +1342,35 @@ describe('hooks', () => { expect(emit).not.toHaveBeenCalled() }) + it('should emit event and invalidate all supported model types when __model_type is undefined', () => { + const invalidateQueries = vi.fn() + const emit = vi.fn() + + ; (useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + ; (useEventEmitterContextContext as Mock).mockReturnValue({ + eventEmitter: { emit }, + }) + + const provider = createMockProvider() + const customFields = { __model_name: 'my-model', __model_type: undefined } as unknown as CustomConfigurationModelFixedFields + + const { result } = renderHook(() => useRefreshModel()) + + act(() => { + result.current.handleRefreshModel(provider, customFields, true) + }) + + expect(emit).toHaveBeenCalledWith({ + type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST, + payload: 'openai', + }) + // When __model_type is undefined, all supported model types are invalidated + const modelListCalls = invalidateQueries.mock.calls.filter( + call => call[0]?.queryKey?.[0] === 'model-list', + ) + expect(modelListCalls).toHaveLength(provider.supported_model_types.length) + }) + it('should handle provider with single model type', () => { const invalidateQueries = vi.fn() diff --git a/web/app/components/header/account-setting/model-provider-page/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/index.spec.tsx index 1f1832628c..3f54864ff4 100644 --- a/web/app/components/header/account-setting/model-provider-page/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/index.spec.tsx @@ -60,7 +60,15 @@ vi.mock('@/context/provider-context', () => ({ }), })) -const mockDefaultModelState = { +type MockDefaultModelData = { + model: string + provider?: { provider: string } +} | null + +const mockDefaultModelState: { + data: MockDefaultModelData + isLoading: boolean +} = { data: null, isLoading: false, } @@ -196,4 +204,129 @@ describe('ModelProviderPage', () => { ]) expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument() }) + + it('should show not configured alert when all default models are absent', () => { + mockDefaultModelState.data = null + mockDefaultModelState.isLoading = false + + render() + + expect(screen.getByText('common.modelProvider.notConfigured')).toBeInTheDocument() + }) + + it('should not show not configured alert when default model is loading', () => { + mockDefaultModelState.data = null + mockDefaultModelState.isLoading = true + + render() + + expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument() + }) + + it('should filter providers by label text', () => { + render() + act(() => { + vi.advanceTimersByTime(600) + }) + expect(screen.getByText('openai')).toBeInTheDocument() + expect(screen.queryByText('anthropic')).not.toBeInTheDocument() + }) + + it('should classify system-enabled providers with matching quota as configured', () => { + mockProviders.splice(0, mockProviders.length, { + provider: 'sys-provider', + label: { en_US: 'System Provider' }, + custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, + system_configuration: { + enabled: true, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, + }) + + render() + + expect(screen.getByText('sys-provider')).toBeInTheDocument() + expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument() + }) + + it('should classify system-enabled provider with no matching quota as not configured', () => { + mockProviders.splice(0, mockProviders.length, { + provider: 'sys-no-quota', + label: { en_US: 'System No Quota' }, + custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, + system_configuration: { + enabled: true, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [], + }, + }) + + render() + + expect(screen.getByText('sys-no-quota')).toBeInTheDocument() + expect(screen.getByText('common.modelProvider.toBeConfigured')).toBeInTheDocument() + }) + + it('should preserve order of two non-fixed providers (sort returns 0)', () => { + mockProviders.splice(0, mockProviders.length, { + provider: 'alpha-provider', + label: { en_US: 'Alpha Provider' }, + custom_configuration: { status: CustomConfigurationStatusEnum.active }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, + }, { + provider: 'beta-provider', + label: { en_US: 'Beta Provider' }, + custom_configuration: { status: CustomConfigurationStatusEnum.active }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, + }) + + render() + + const renderedProviders = screen.getAllByTestId('provider-card').map(item => item.textContent) + expect(renderedProviders).toEqual(['alpha-provider', 'beta-provider']) + }) + + it('should not show not configured alert when shared default model mock has data', () => { + mockDefaultModelState.data = { model: 'embed-model' } + + render() + + expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument() + }) + + it('should not show not configured alert when rerankDefaultModel has data', () => { + mockDefaultModelState.data = { model: 'rerank-model', provider: { provider: 'cohere' } } + mockDefaultModelState.isLoading = false + + render() + + expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument() + }) + + it('should not show not configured alert when ttsDefaultModel has data', () => { + mockDefaultModelState.data = { model: 'tts-model', provider: { provider: 'openai' } } + mockDefaultModelState.isLoading = false + + render() + + expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument() + }) + + it('should not show not configured alert when speech2textDefaultModel has data', () => { + mockDefaultModelState.data = { model: 'whisper', provider: { provider: 'openai' } } + mockDefaultModelState.isLoading = false + + render() + + expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument() + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.spec.tsx index af0ce9dcf2..93f5842a3a 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.spec.tsx @@ -96,4 +96,97 @@ describe('AddCredentialInLoadBalancing', () => { expect(onSelectCredential).toHaveBeenCalledWith(modelCredential.available_credentials[0]) }) + + // renderTrigger with open=true: bg-state-base-hover style applied + it('should apply hover background when trigger is rendered with open=true', async () => { + vi.doMock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({ + Authorized: ({ + renderTrigger, + }: { + renderTrigger: (open?: boolean) => React.ReactNode + }) => ( +
{renderTrigger(true)}
+ ), + })) + + // Must invalidate module cache so the component picks up the new mock + vi.resetModules() + try { + const { default: AddCredentialLB } = await import('./add-credential-in-load-balancing') + + const { container } = render( + , + ) + + // The trigger div rendered by renderTrigger(true) should have bg-state-base-hover + // (the static class applied when open=true via cn()) + const triggerDiv = container.querySelector('[data-testid="open-trigger"] > div') + expect(triggerDiv).toBeInTheDocument() + expect(triggerDiv!.className).toContain('bg-state-base-hover') + } + finally { + vi.doUnmock('@/app/components/header/account-setting/model-provider-page/model-auth') + vi.resetModules() + } + }) + + // customizableModel configuration method: component renders the add credential label + it('should render correctly with customizableModel configuration method', () => { + render( + , + ) + + expect(screen.getByText(/modelProvider.auth.addCredential/i)).toBeInTheDocument() + }) + + it('should handle undefined available_credentials gracefully using nullish coalescing', () => { + const credentialWithNoAvailable = { + available_credentials: undefined, + credentials: {}, + load_balancing: { enabled: false, configs: [] }, + } as unknown as typeof modelCredential + + render( + , + ) + + // Component should render without error - the ?? [] fallback is used + expect(screen.getByText(/modelProvider.auth.addCredential/i)).toBeInTheDocument() + }) + + it('should not throw when update action fires without onUpdate prop', () => { + // Arrange - no onUpdate prop + render( + , + ) + + // Act - trigger the update without onUpdate being set (should not throw) + expect(() => { + fireEvent.click(screen.getByRole('button', { name: 'Run update' })) + }).not.toThrow() + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.spec.tsx index d60c985b99..115ae98d76 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.spec.tsx @@ -85,4 +85,69 @@ describe('CredentialItem', () => { expect(onDelete).not.toHaveBeenCalled() }) + + // All disable flags true → no action buttons rendered + it('should hide all action buttons when disableRename, disableEdit, and disableDelete are all true', () => { + // Act + render( + , + ) + + // Assert + expect(screen.queryByTestId('edit-icon')).not.toBeInTheDocument() + expect(screen.queryByTestId('delete-icon')).not.toBeInTheDocument() + }) + + // disabled=true guards: clicks on the item row and on delete should both be no-ops + it('should not call onItemClick when disabled=true and item is clicked', () => { + const onItemClick = vi.fn() + + render() + + fireEvent.click(screen.getByText('Test API Key')) + + expect(onItemClick).not.toHaveBeenCalled() + }) + + it('should not call onDelete when disabled=true and delete button is clicked', () => { + const onDelete = vi.fn() + + render() + + fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement) + + expect(onDelete).not.toHaveBeenCalled() + }) + + // showSelectedIcon=true: check icon area is always rendered; check icon only appears when IDs match + it('should render check icon area when showSelectedIcon=true and selectedCredentialId matches', () => { + render( + , + ) + + expect(screen.getByTestId('check-icon')).toBeInTheDocument() + }) + + it('should not render check icon when showSelectedIcon=true but selectedCredentialId does not match', () => { + render( + , + ) + + expect(screen.queryByTestId('check-icon')).not.toBeInTheDocument() + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.spec.tsx index 4789641828..7147bf058e 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.spec.tsx @@ -24,36 +24,6 @@ vi.mock('../hooks', () => ({ }), })) -let mockPortalOpen = false - -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => { - mockPortalOpen = open - return
{children}
- }, - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => { - if (!mockPortalOpen) - return null - return
{children}
- }, -})) - -vi.mock('@/app/components/base/confirm', () => ({ - default: ({ isShow, onCancel, onConfirm }: { isShow: boolean, onCancel: () => void, onConfirm: () => void }) => { - if (!isShow) - return null - return ( -
- - -
- ) - }, -})) - vi.mock('./authorized-item', () => ({ default: ({ credentials, model, onEdit, onDelete, onItemClick }: { credentials: Credential[] @@ -105,382 +75,127 @@ describe('Authorized', () => { beforeEach(() => { vi.clearAllMocks() - mockPortalOpen = false mockDeleteCredentialId = null mockDoingAction = false }) - describe('Rendering', () => { - it('should render trigger button', () => { - render( - , - ) + it('should render trigger and open popup when trigger is clicked', () => { + render( + , + ) - expect(screen.getByText(/Trigger/)).toBeInTheDocument() - }) + fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i })) + expect(screen.getByTestId('authorized-item')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /addApiKey/i })).toBeInTheDocument() + }) - it('should render portal content when open', () => { - render( - , - ) + it('should call handleOpenModal when triggerOnlyOpenModal is true', () => { + render( + , + ) - expect(screen.getByTestId('portal-content')).toBeInTheDocument() - expect(screen.getByTestId('authorized-item')).toBeInTheDocument() - }) + fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i })) + expect(mockHandleOpenModal).toHaveBeenCalled() + expect(screen.queryByTestId('authorized-item')).not.toBeInTheDocument() + }) - it('should not render portal content when closed', () => { - render( - , - ) + it('should call onItemClick when credential is selected', () => { + const onItemClick = vi.fn() + render( + , + ) - expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() - }) + fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i })) + fireEvent.click(screen.getAllByRole('button', { name: 'Select' })[0]) - it('should render Add API Key button when not model credential', () => { - render( - , - ) + expect(onItemClick).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model) + }) - expect(screen.getByText(/addApiKey/)).toBeInTheDocument() - }) + it('should call handleActiveCredential when onItemClick is not provided', () => { + render( + , + ) - it('should render Add Model Credential button when is model credential', () => { - render( - , - ) + fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i })) + fireEvent.click(screen.getAllByRole('button', { name: 'Select' })[0]) - expect(screen.getByText(/addModelCredential/)).toBeInTheDocument() - }) + expect(mockHandleActiveCredential).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model) + }) - it('should not render add action when hideAddAction is true', () => { - render( - , - ) + it('should call handleOpenModal with fixed model fields when adding model credential', () => { + render( + , + ) - expect(screen.queryByText(/addApiKey/)).not.toBeInTheDocument() - }) + fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i })) + fireEvent.click(screen.getByText(/addModelCredential/)) - it('should render popup title when provided', () => { - render( - , - ) - - expect(screen.getByText('Select Credential')).toBeInTheDocument() + expect(mockHandleOpenModal).toHaveBeenCalledWith(undefined, { + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, }) }) - describe('User Interactions', () => { - it('should call onOpenChange when trigger is clicked in controlled mode', () => { - const onOpenChange = vi.fn() + it('should not render add action when hideAddAction is true', () => { + render( + , + ) - render( - , - ) - - fireEvent.click(screen.getByTestId('portal-trigger')) - - expect(onOpenChange).toHaveBeenCalledWith(true) - }) - - it('should toggle portal on trigger click', () => { - const { rerender } = render( - , - ) - - fireEvent.click(screen.getByTestId('portal-trigger')) - - rerender( - , - ) - - expect(screen.getByTestId('portal-content')).toBeInTheDocument() - }) - - it('should open modal when triggerOnlyOpenModal is true', () => { - render( - , - ) - - fireEvent.click(screen.getByTestId('portal-trigger')) - - expect(mockHandleOpenModal).toHaveBeenCalled() - }) - - it('should call handleOpenModal when Add API Key is clicked', () => { - render( - , - ) - - fireEvent.click(screen.getByText(/addApiKey/)) - - expect(mockHandleOpenModal).toHaveBeenCalled() - }) - - it('should call handleOpenModal with credential and model when edit is clicked', () => { - render( - , - ) - - fireEvent.click(screen.getAllByText('Edit')[0]) - - expect(mockHandleOpenModal).toHaveBeenCalledWith( - mockCredentials[0], - mockItems[0].model, - ) - }) - - it('should pass current model fields when adding model credential', () => { - render( - , - ) - - fireEvent.click(screen.getByText(/addModelCredential/)) - - expect(mockHandleOpenModal).toHaveBeenCalledWith(undefined, { - model: 'gpt-4', - model_type: ModelTypeEnum.textGeneration, - }) - }) - - it('should call onItemClick when credential is selected', () => { - const onItemClick = vi.fn() - - render( - , - ) - - fireEvent.click(screen.getAllByText('Select')[0]) - - expect(onItemClick).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model) - }) - - it('should call handleActiveCredential when onItemClick is not provided', () => { - render( - , - ) - - fireEvent.click(screen.getAllByText('Select')[0]) - - expect(mockHandleActiveCredential).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model) - }) - - it('should not call onItemClick when disableItemClick is true', () => { - const onItemClick = vi.fn() - - render( - , - ) - - fireEvent.click(screen.getAllByText('Select')[0]) - - expect(onItemClick).not.toHaveBeenCalled() - }) + fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i })) + expect(screen.queryByRole('button', { name: /addApiKey/i })).not.toBeInTheDocument() }) - describe('Delete Confirmation', () => { - it('should show confirm dialog when deleteCredentialId is set', () => { - mockDeleteCredentialId = 'cred-1' + it('should show confirm dialog and call confirm handler when delete is confirmed', () => { + mockDeleteCredentialId = 'cred-1' - render( - , - ) + render( + , + ) - expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument() - }) - - it('should not show confirm dialog when deleteCredentialId is null', () => { - render( - , - ) - - expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() - }) - - it('should call closeConfirmDelete when cancel is clicked', () => { - mockDeleteCredentialId = 'cred-1' - - render( - , - ) - - fireEvent.click(screen.getByText('Cancel')) - - expect(mockCloseConfirmDelete).toHaveBeenCalled() - }) - - it('should call handleConfirmDelete when confirm is clicked', () => { - mockDeleteCredentialId = 'cred-1' - - render( - , - ) - - fireEvent.click(screen.getByText('Confirm')) - - expect(mockHandleConfirmDelete).toHaveBeenCalled() - }) - }) - - describe('Edge Cases', () => { - it('should handle empty items array', () => { - render( - , - ) - - expect(screen.queryByTestId('authorized-item')).not.toBeInTheDocument() - }) - - it('should not render add action when provider does not allow custom token', () => { - const restrictedProvider = { ...mockProvider, allow_custom_token: false } - - render( - , - ) - - expect(screen.queryByText(/addApiKey/)).not.toBeInTheDocument() - }) + fireEvent.click(screen.getByRole('button', { name: /common.operation.confirm/i })) + expect(mockHandleConfirmDelete).toHaveBeenCalled() }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.spec.tsx index 94a8583313..8274570c5b 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.spec.tsx @@ -1,5 +1,6 @@ import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations' import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import ConfigProvider from './config-provider' const mockUseCredentialStatus = vi.fn() @@ -54,7 +55,8 @@ describe('ConfigProvider', () => { expect(screen.getByText(/operation.config/i)).toBeInTheDocument() }) - it('should still render setup label when custom credentials are not allowed', () => { + it('should show setup label and unavailable tooltip when custom credentials are not allowed and no credential exists', async () => { + const user = userEvent.setup() mockUseCredentialStatus.mockReturnValue({ hasCredential: false, authorized: false, @@ -65,6 +67,50 @@ describe('ConfigProvider', () => { render() + expect(screen.getByText(/operation.setup/i)).toBeInTheDocument() + await user.hover(screen.getByText(/operation.setup/i)) + expect(await screen.findByText(/auth\.credentialUnavailable/i)).toBeInTheDocument() + }) + + it('should show config label when hasCredential but not authorized', () => { + mockUseCredentialStatus.mockReturnValue({ + hasCredential: true, + authorized: false, + current_credential_id: 'cred-1', + current_credential_name: 'Key 1', + available_credentials: [], + }) + + render() + + expect(screen.getByText(/operation.config/i)).toBeInTheDocument() + }) + + it('should show config label when custom credentials are not allowed but credential exists', () => { + mockUseCredentialStatus.mockReturnValue({ + hasCredential: true, + authorized: true, + current_credential_id: 'cred-1', + current_credential_name: 'Key 1', + available_credentials: [], + }) + + render() + + expect(screen.getByText(/operation.config/i)).toBeInTheDocument() + }) + + it('should handle nullish credential values with fallbacks', () => { + mockUseCredentialStatus.mockReturnValue({ + hasCredential: false, + authorized: false, + current_credential_id: null, + current_credential_name: null, + available_credentials: null, + }) + + render() + expect(screen.getByText(/operation.setup/i)).toBeInTheDocument() }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/credential-selector.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/credential-selector.spec.tsx index a522abf7cb..68d5352857 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/credential-selector.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/credential-selector.spec.tsx @@ -1,12 +1,12 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import CredentialSelector from './credential-selector' -// Mock components vi.mock('./authorized/credential-item', () => ({ - default: ({ credential, onItemClick }: { credential: { credential_name: string }, onItemClick: (c: unknown) => void }) => ( -
onItemClick(credential)}> + default: ({ credential, onItemClick }: { credential: { credential_name: string }, onItemClick?: (c: unknown) => void }) => ( +
+ ), })) @@ -19,22 +19,6 @@ vi.mock('@remixicon/react', () => ({ RiArrowDownSLine: () =>
, })) -// Mock portal components -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( -
{children}
- ), - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children }: { children: React.ReactNode, open?: boolean }) => { - // We should only render children if open or if we want to test they are hidden - // The real component might handle this with CSS or conditional rendering. - // Let's use conditional rendering in the mock to avoid "multiple elements" errors. - return
{children}
- }, -})) - describe('CredentialSelector', () => { const mockCredentials = [ { credential_id: 'cred-1', credential_name: 'Key 1' }, @@ -46,7 +30,7 @@ describe('CredentialSelector', () => { vi.clearAllMocks() }) - it('should render selected credential name', () => { + it('should render selected credential name when selectedCredential is provided', () => { render( { />, ) - // Use getAllByText and take the first one (the one in the trigger) - expect(screen.getAllByText('Key 1')[0]).toBeInTheDocument() + expect(screen.getByText('Key 1')).toBeInTheDocument() expect(screen.getByTestId('indicator')).toBeInTheDocument() }) - it('should render placeholder when no credential selected', () => { + it('should render placeholder when selectedCredential is missing', () => { render( { expect(screen.getByText(/modelProvider.auth.selectModelCredential/)).toBeInTheDocument() }) - it('should open portal on click', () => { + it('should call onSelect when a credential item is clicked', async () => { + const user = userEvent.setup() render( { />, ) - fireEvent.click(screen.getByTestId('portal-trigger')) - expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true') - expect(screen.getAllByTestId('credential-item')).toHaveLength(2) - }) - - it('should call onSelect when a credential is clicked', () => { - render( - , - ) - - fireEvent.click(screen.getByTestId('portal-trigger')) - fireEvent.click(screen.getByText('Key 2')) + await user.click(screen.getByText(/modelProvider.auth.selectModelCredential/)) + await user.click(screen.getByRole('button', { name: 'Key 2' })) expect(mockOnSelect).toHaveBeenCalledWith(mockCredentials[1]) }) - it('should call onSelect with add new credential data when clicking add button', () => { + it('should call onSelect with add-new payload when add action is clicked', async () => { + const user = userEvent.setup() render( { />, ) - fireEvent.click(screen.getByTestId('portal-trigger')) - fireEvent.click(screen.getByText(/modelProvider.auth.addNewModelCredential/)) + await user.click(screen.getByText(/modelProvider.auth.selectModelCredential/)) + await user.click(screen.getByText(/modelProvider.auth.addNewModelCredential/)) expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({ credential_id: '__add_new_credential', @@ -115,7 +87,8 @@ describe('CredentialSelector', () => { })) }) - it('should not open portal when disabled', () => { + it('should not open options when disabled is true', async () => { + const user = userEvent.setup() render( { />, ) - fireEvent.click(screen.getByTestId('portal-trigger')) - expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false') + await user.click(screen.getByText(/modelProvider.auth.selectModelCredential/)) + expect(screen.queryByRole('button', { name: 'Key 1' })).not.toBeInTheDocument() }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth.spec.tsx index b637fed894..454cbfbfa6 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth.spec.tsx @@ -1,9 +1,11 @@ +import type { ReactNode } from 'react' import type { Credential, CustomModel, ModelProvider, } from '../../declarations' import { act, renderHook } from '@testing-library/react' +import { ToastContext } from '@/app/components/base/toast/context' import { ConfigurationMethodEnum, ModelModalModeEnum, ModelTypeEnum } from '../../declarations' import { useAuth } from './use-auth' @@ -20,9 +22,13 @@ const mockAddModelCredential = vi.fn() const mockEditProviderCredential = vi.fn() const mockEditModelCredential = vi.fn() -vi.mock('@/app/components/base/toast/context', () => ({ - useToastContext: () => ({ notify: mockNotify }), -})) +vi.mock('@/app/components/base/toast/context', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useToastContext: () => ({ notify: mockNotify }), + } +}) vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useModelModalHandler: () => mockOpenModelModal, @@ -66,6 +72,12 @@ describe('useAuth', () => { model_type: ModelTypeEnum.textGeneration, } + const createWrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + beforeEach(() => { vi.clearAllMocks() mockDeleteModelService.mockResolvedValue({ result: 'success' }) @@ -80,7 +92,7 @@ describe('useAuth', () => { }) it('should open and close delete confirmation state', () => { - const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel)) + const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper }) act(() => { result.current.openConfirmDelete(credential, model) @@ -100,7 +112,7 @@ describe('useAuth', () => { }) it('should activate credential, notify success, and refresh models', async () => { - const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel)) + const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel), { wrapper: createWrapper }) await act(async () => { await result.current.handleActiveCredential(credential, model) @@ -120,7 +132,7 @@ describe('useAuth', () => { }) it('should close delete dialog without calling services when nothing is pending', async () => { - const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel)) + const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper }) await act(async () => { await result.current.handleConfirmDelete() @@ -137,7 +149,7 @@ describe('useAuth', () => { const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel, undefined, { isModelCredential: false, onRemove, - })) + }), { wrapper: createWrapper }) act(() => { result.current.openConfirmDelete(credential, model) @@ -161,7 +173,7 @@ describe('useAuth', () => { const onRemove = vi.fn() const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel, undefined, { onRemove, - })) + }), { wrapper: createWrapper }) act(() => { result.current.openConfirmDelete(undefined, model) @@ -179,7 +191,7 @@ describe('useAuth', () => { }) it('should add or edit credentials and refresh on successful save', async () => { - const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel)) + const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper }) await act(async () => { await result.current.handleSaveCredential({ api_key: 'new-key' }) @@ -200,7 +212,7 @@ describe('useAuth', () => { const deferred = createDeferred<{ result: string }>() mockAddProviderCredential.mockReturnValueOnce(deferred.promise) - const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel)) + const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper }) let first!: Promise let second!: Promise @@ -226,7 +238,7 @@ describe('useAuth', () => { isModelCredential: true, onUpdate, mode: ModelModalModeEnum.configModelCredential, - })) + }), { wrapper: createWrapper }) act(() => { result.current.handleOpenModal(credential, model) @@ -244,4 +256,90 @@ describe('useAuth', () => { }), ) }) + + it('should not notify or refresh when handleSaveCredential returns non-success result', async () => { + mockAddProviderCredential.mockResolvedValue({ result: 'error' }) + + const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper }) + + await act(async () => { + await result.current.handleSaveCredential({ api_key: 'some-key' }) + }) + + expect(mockAddProviderCredential).toHaveBeenCalledWith({ api_key: 'some-key' }) + expect(mockNotify).not.toHaveBeenCalled() + expect(mockHandleRefreshModel).not.toHaveBeenCalled() + }) + + it('should pass undefined model and model_type when handleActiveCredential is called without a model parameter', async () => { + const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper }) + + await act(async () => { + await result.current.handleActiveCredential(credential) + }) + + expect(mockActiveProviderCredential).toHaveBeenCalledWith({ + credential_id: 'cred-1', + model: undefined, + model_type: undefined, + }) + }) + + // openConfirmDelete with credential only (no model): deleteCredentialId set, deleteModel stays null + it('should only set deleteCredentialId when openConfirmDelete is called without a model', () => { + const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper }) + + act(() => { + result.current.openConfirmDelete(credential, undefined) + }) + + expect(result.current.deleteCredentialId).toBe('cred-1') + expect(result.current.deleteModel).toBeNull() + expect(result.current.pendingOperationCredentialId.current).toBe('cred-1') + expect(result.current.pendingOperationModel.current).toBeNull() + }) + + // doingActionRef guard: second handleConfirmDelete call while first is in progress is a no-op + it('should ignore a second handleConfirmDelete call while the first is still in progress', async () => { + const deferred = createDeferred<{ result: string }>() + mockDeleteProviderCredential.mockReturnValueOnce(deferred.promise) + + const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper }) + + act(() => { + result.current.openConfirmDelete(credential, model) + }) + + let first!: Promise + let second!: Promise + + await act(async () => { + first = result.current.handleConfirmDelete() + second = result.current.handleConfirmDelete() + deferred.resolve({ result: 'success' }) + await Promise.all([first, second]) + }) + + expect(mockDeleteProviderCredential).toHaveBeenCalledTimes(1) + }) + + // doingActionRef guard: second handleActiveCredential call while first is in progress is a no-op + it('should ignore a second handleActiveCredential call while the first is still in progress', async () => { + const deferred = createDeferred<{ result: string }>() + mockActiveProviderCredential.mockReturnValueOnce(deferred.promise) + + const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper }) + + let first!: Promise + let second!: Promise + + await act(async () => { + first = result.current.handleActiveCredential(credential) + second = result.current.handleActiveCredential(credential) + deferred.resolve({ result: 'success' }) + await Promise.all([first, second]) + }) + + expect(mockActiveProviderCredential).toHaveBeenCalledTimes(1) + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/manage-custom-model-credentials.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/manage-custom-model-credentials.spec.tsx index ee25dbe6cd..3b07513464 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/manage-custom-model-credentials.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/manage-custom-model-credentials.spec.tsx @@ -13,11 +13,13 @@ vi.mock('./hooks', () => ({ // Mock Authorized vi.mock('./authorized', () => ({ - default: ({ renderTrigger, items, popupTitle }: { renderTrigger: (o?: boolean) => React.ReactNode, items: { length: number }, popupTitle: string }) => ( + default: ({ renderTrigger, items, popupTitle }: { renderTrigger: (o?: boolean) => React.ReactNode, items: Array<{ selectedCredential?: unknown }>, popupTitle: string }) => (
-
{renderTrigger()}
+
{renderTrigger()}
+
{renderTrigger(true)}
{popupTitle}
{items.length}
+
{items.map((it, i) => {it.selectedCredential ? 'has-cred' : 'no-cred'})}
), })) @@ -55,8 +57,41 @@ describe('ManageCustomModelCredentials', () => { render() expect(screen.getByTestId('authorized-mock')).toBeInTheDocument() - expect(screen.getByText(/modelProvider.auth.manageCredentials/)).toBeInTheDocument() + expect(screen.getAllByText(/modelProvider.auth.manageCredentials/).length).toBeGreaterThan(0) expect(screen.getByTestId('items-count')).toHaveTextContent('2') expect(screen.getByTestId('popup-title')).toHaveTextContent('modelProvider.auth.customModelCredentials') }) + + it('should render trigger in both open and closed states', () => { + const mockModels = [ + { + model: 'gpt-4', + available_model_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }], + current_credential_id: 'c1', + current_credential_name: 'Key 1', + }, + ] + mockUseCustomModels.mockReturnValue(mockModels) + + render() + + expect(screen.getByTestId('trigger-closed')).toBeInTheDocument() + expect(screen.getByTestId('trigger-open')).toBeInTheDocument() + }) + + it('should pass undefined selectedCredential when model has no current_credential_id', () => { + const mockModels = [ + { + model: 'gpt-3.5', + available_model_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }], + current_credential_id: '', + current_credential_name: '', + }, + ] + mockUseCustomModels.mockReturnValue(mockModels) + + render() + + expect(screen.getByTestId('selected-0')).toHaveTextContent('no-cred') + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.spec.tsx index a727e2ea40..1672e38f94 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.spec.tsx @@ -18,15 +18,6 @@ vi.mock('@/app/components/header/indicator', () => ({ default: ({ color }: { color: string }) =>
, })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => ( -
- {children} -
{popupContent}
-
- ), -})) - vi.mock('@remixicon/react', () => ({ RiArrowDownSLine: () =>
, })) @@ -125,6 +116,131 @@ describe('SwitchCredentialInLoadBalancing', () => { />, ) + fireEvent.mouseEnter(screen.getByText(/auth.credentialUnavailableInButton/)) expect(screen.getByText('plugin.auth.credentialUnavailable')).toBeInTheDocument() }) + + // Empty credentials with allowed custom: no tooltip but still shows unavailable text + it('should show unavailable status without tooltip when custom credentials are allowed', () => { + // Act + render( + , + ) + + // Assert + expect(screen.getByText(/auth.credentialUnavailableInButton/)).toBeInTheDocument() + expect(screen.queryByText('plugin.auth.credentialUnavailable')).not.toBeInTheDocument() + }) + + // not_allowed_to_use=true: indicator is red and destructive button text is shown + it('should show red indicator and unavailable button text when credential has not_allowed_to_use=true', () => { + const unavailableCredential = { credential_id: 'cred-1', credential_name: 'Key 1', not_allowed_to_use: true } + + render( + , + ) + + expect(screen.getByTestId('indicator-red')).toBeInTheDocument() + expect(screen.getByText(/auth.credentialUnavailableInButton/)).toBeInTheDocument() + }) + + // from_enterprise=true on the selected credential: Enterprise badge appears in the trigger + it('should show Enterprise badge when selected credential has from_enterprise=true', () => { + const enterpriseCredential = { credential_id: 'cred-1', credential_name: 'Enterprise Key', from_enterprise: true } + + render( + , + ) + + expect(screen.getByText('Enterprise')).toBeInTheDocument() + }) + + // non-empty credentials with allow_custom_token=false: no tooltip (tooltip only for empty+notAllowCustom) + it('should not show unavailable tooltip when credentials are non-empty and allow_custom_token=false', () => { + const restrictedProvider = { ...mockProvider, allow_custom_token: false } + + render( + , + ) + + fireEvent.mouseEnter(screen.getByText('Key 1')) + expect(screen.queryByText('plugin.auth.credentialUnavailable')).not.toBeInTheDocument() + expect(screen.getByText('Key 1')).toBeInTheDocument() + }) + + it('should pass undefined currentCustomConfigurationModelFixedFields when model is undefined', () => { + render( + , + ) + + // Component still renders (Authorized receives undefined currentCustomConfigurationModelFixedFields) + expect(screen.getByTestId('authorized-mock')).toBeInTheDocument() + expect(screen.getByText('Key 1')).toBeInTheDocument() + }) + + it('should treat undefined credentials as empty list', () => { + render( + , + ) + + // credentials is undefined → empty=true → unavailable text shown + expect(screen.getByText(/auth.credentialUnavailableInButton/)).toBeInTheDocument() + expect(screen.queryByTestId(/indicator-/)).not.toBeInTheDocument() + }) + + it('should render nothing for credential_name when it is empty string', () => { + const credWithEmptyName = { credential_id: 'cred-1', credential_name: '' } + + render( + , + ) + + // indicator-green shown (not authRemoved, not unavailable, not empty) + expect(screen.getByTestId('indicator-green')).toBeInTheDocument() + // credential_name is empty so nothing printed for name + expect(screen.queryByText('Key 1')).not.toBeInTheDocument() + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-icon/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-icon/index.spec.tsx index d397330159..5a204b5b3b 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-icon/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-icon/index.spec.tsx @@ -24,10 +24,6 @@ vi.mock('../hooks', () => ({ useLanguage: () => mockLanguage, })) -vi.mock('@/app/components/base/icons/src/public/llm', () => ({ - OpenaiYellow: () => , -})) - const createI18nText = (value: string): I18nText => ({ en_US: value, zh_Hans: value, @@ -92,10 +88,10 @@ describe('ModelIcon', () => { icon_small: createI18nText('openai.png'), }) - render() + const { container } = render() expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument() - expect(screen.getByTestId('openai-yellow-icon')).toBeInTheDocument() + expect(container.querySelector('svg')).toBeInTheDocument() }) // Edge case @@ -105,4 +101,25 @@ describe('ModelIcon', () => { expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument() expect(container.firstChild).not.toBeNull() }) + + it('should render OpenAI Yellow icon for langgenius/openai/openai provider with model starting with o', () => { + const provider = createModel({ + provider: 'langgenius/openai/openai', + icon_small: createI18nText('openai.png'), + }) + + const { container } = render() + + expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument() + expect(container.querySelector('svg')).toBeInTheDocument() + }) + + it('should apply opacity-50 when isDeprecated is true', () => { + const provider = createModel() + + const { container } = render() + + const wrapper = container.querySelector('.opacity-50') + expect(wrapper).toBeInTheDocument() + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.spec.tsx index 572a2944f8..153f052796 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.spec.tsx @@ -1,3 +1,4 @@ +import type { Node } from 'reactflow' import type { CredentialFormSchema, CredentialFormSchemaBase, @@ -7,6 +8,7 @@ import type { CredentialFormSchemaTextInput, FormValue, } from '../declarations' +import type { NodeOutPutVar } from '@/app/components/workflow/types' import { fireEvent, render, screen } from '@testing-library/react' import { FormTypeEnum } from '../declarations' import Form from './Form' @@ -17,8 +19,12 @@ type MockVarPayload = { type: string } type AnyFormSchema = CredentialFormSchema | (CredentialFormSchemaBase & { type: FormTypeEnum }) +const modelSelectorPropsSpy = vi.hoisted(() => vi.fn()) +const toolSelectorPropsSpy = vi.hoisted(() => vi.fn()) + +const mockLanguageRef = { value: 'en_US' } vi.mock('../hooks', () => ({ - useLanguage: () => 'en_US', + useLanguage: () => mockLanguageRef.value, })) vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({ @@ -28,9 +34,16 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({ })) vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({ - default: ({ setModel }: { setModel: (model: { model: string, model_type: string }) => void }) => ( - - ), + default: (props: { + setModel: (model: { model: string, model_type: string }) => void + isAgentStrategy?: boolean + readonly?: boolean + }) => { + modelSelectorPropsSpy(props) + return ( + + ) + }, })) vi.mock('@/app/components/plugins/plugin-detail-panel/multiple-tool-selector', () => ({ @@ -40,12 +53,21 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/multiple-tool-selector', ( })) vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector', () => ({ - default: ({ onSelect, onDelete }: { onSelect: (item: { id: string }) => void, onDelete: () => void }) => ( -
- - -
- ), + default: (props: { + onSelect: (item: { id: string }) => void + onDelete: () => void + nodeOutputVars?: unknown[] + availableNodes?: unknown[] + disabled?: boolean + }) => { + toolSelectorPropsSpy(props) + return ( +
+ + +
+ ) + }, })) vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ @@ -67,6 +89,7 @@ vi.mock('../../key-validator/ValidateStatus', () => ({ })) const createI18n = (text: string) => ({ en_US: text, zh_Hans: text }) +const createPartialI18n = (text: string) => ({ en_US: text } as unknown as ReturnType) const createBaseSchema = ( type: FormTypeEnum, @@ -117,6 +140,7 @@ const createSelectSchema = (overrides: Partial) => ( describe('Form', () => { beforeEach(() => { vi.clearAllMocks() + mockLanguageRef.value = 'en_US' }) // Rendering basics @@ -443,5 +467,1482 @@ describe('Form', () => { expect(onChange).toHaveBeenCalledWith({ override: '', any_var: [{ name: 'var-1' }], any_without_scope: [], custom_field: '' }) expect(screen.getAllByText('Extra Info')).toHaveLength(2) }) + + // readonly=true: input disabled + it('should disable inputs when readonly is true', () => { + // Arrange + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'api_key', + label: createI18n('API Key'), + placeholder: createI18n('API Key'), + }), + ] + const value: FormValue = { api_key: 'my-key' } + + // Act + render( +
, + ) + + // Assert + expect(screen.getByPlaceholderText('API Key')).toBeDisabled() + }) + + // Override returns null: falls through to default renderer + it('should fall through to default renderer when override returns null', () => { + // Arrange + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'field1', + label: createI18n('Field 1'), + placeholder: createI18n('Field 1'), + type: FormTypeEnum.textInput, + }), + ] + const value: FormValue = { field1: '' } + + // Act + render( + null]} + />, + ) + + // Assert - should fall through to default textInput renderer + expect(screen.getByPlaceholderText('Field 1')).toBeInTheDocument() + }) + + // isShowDefaultValue=true, value is null → default shown + it('should show default value when value is null and isShowDefaultValue is true', () => { + // Arrange + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'field1', + label: createI18n('Nullable'), + placeholder: createI18n('Nullable'), + default: 'default-val', + }), + ] + const value: FormValue = { field1: null } + + // Act + render( + , + ) + + // Assert + expect(screen.getByPlaceholderText('Nullable')).toHaveValue('default-val') + }) + + // isShowDefaultValue=true, value is undefined → default shown + it('should show default value when value is undefined and isShowDefaultValue is true', () => { + // Arrange + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'field1', + label: createI18n('Undef'), + placeholder: createI18n('Undef'), + default: 'default-undef', + }), + ] + const value: FormValue = { field1: undefined } + + // Act + render( + , + ) + + // Assert + expect(screen.getByPlaceholderText('Undef')).toHaveValue('default-undef') + }) + + // isEditMode=true, variable=__model_type → textInput disabled + it('should disable __model_type field in edit mode', () => { + // Arrange + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: '__model_type', + label: createI18n('Model Type'), + placeholder: createI18n('Model Type'), + }), + ] + const value: FormValue = { __model_type: 'llm' } + + // Act + render( + , + ) + + // Assert + expect(screen.getByPlaceholderText('Model Type')).toBeDisabled() + }) + + // Label with missing language key → en_US fallback used + it('should fall back to en_US label when current language key is missing', () => { + // Arrange + mockLanguageRef.value = 'fr_FR' + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'field1', + label: createPartialI18n('English Label'), + placeholder: createI18n('Field 1'), + }), + ] + const value: FormValue = { field1: '' } + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('English Label')).toBeInTheDocument() + }) + + // Select field with isShowDefaultValue=true + it('should use default value for select field when value is empty and isShowDefaultValue is true', () => { + // Arrange + const formSchemas: AnyFormSchema[] = [ + createSelectSchema({ + variable: 'select_field', + label: createI18n('Select Field'), + placeholder: createI18n('Pick one'), + default: 'b', + }), + ] + const value: FormValue = { select_field: '' } + + // Act + render( + , + ) + + // Assert - Select B should be the rendered default + expect(screen.getByText('Select B')).toBeInTheDocument() + }) + + // Radio option with show_on condition not met → option filtered out + it('should filter out radio options whose show_on conditions are not met', () => { + // Arrange + const formSchemas: AnyFormSchema[] = [ + createRadioSchema({ + variable: 'choice', + label: createI18n('Choice'), + options: [ + { label: createI18n('Always Visible'), value: 'a', show_on: [] }, + { label: createI18n('Conditional'), value: 'b', show_on: [{ variable: 'toggle', value: 'yes' }] }, + ], + }), + ] + const value: FormValue = { choice: 'a', toggle: 'no' } + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('Always Visible')).toBeInTheDocument() + expect(screen.queryByText('Conditional')).not.toBeInTheDocument() + }) + + // isEditMode + __model_name key: handleFormChange returns early + it('should not call onChange when editing __model_name in edit mode', () => { + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: '__model_name', + label: createI18n('Model Name'), + placeholder: createI18n('Model Name'), + }), + ] + const value: FormValue = { __model_name: 'old-model' } + const onChange = vi.fn() + + render( + , + ) + + fireEvent.change(screen.getByPlaceholderText('Model Name'), { target: { value: 'new-model' } }) + + expect(onChange).not.toHaveBeenCalled() + }) + + // showOnVariableMap: schema not found → clearVariable is undefined + it('should set undefined for dependent variable when schema is not found in formSchemas', () => { + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'api_key', + label: createI18n('API Key'), + placeholder: createI18n('API Key'), + }), + ] + const value: FormValue = { api_key: 'old', missing_field: 'val' } + const onChange = vi.fn() + + render( + , + ) + + fireEvent.change(screen.getByPlaceholderText('API Key'), { target: { value: 'new-key' } }) + + expect(onChange).toHaveBeenCalledWith({ api_key: 'new-key', missing_field: undefined }) + }) + + // secretInput renders password type, textNumber renders number type + it('should render password type for secretInput and number type for textNumber', () => { + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'secret', + type: FormTypeEnum.secretInput, + label: createI18n('Secret'), + placeholder: createI18n('Secret'), + }), + createNumberSchema({ + variable: 'num', + label: createI18n('Number'), + placeholder: createI18n('Number'), + }), + ] + const value: FormValue = { secret: 'hidden', num: '5' } + + render( + , + ) + + // Both rendered successfully + expect(screen.getByPlaceholderText('Secret')).toBeInTheDocument() + expect(screen.getByPlaceholderText('Number')).toBeInTheDocument() + }) + + // Placeholder fallback: null placeholder + it('should handle undefined placeholder gracefully', () => { + const formSchemas: AnyFormSchema[] = [ + { + ...createBaseSchema(FormTypeEnum.textInput, { variable: 'no_ph' }), + label: createI18n('No Placeholder'), + } as unknown as CredentialFormSchemaTextInput, + ] + const value: FormValue = { no_ph: '' } + + render( + , + ) + + expect(screen.getByText('No Placeholder')).toBeInTheDocument() + }) + + // validating=true + changeKey matches variable: ValidatingTip shown + it('should show ValidatingTip for the field being validated', () => { + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'api_key', + label: createI18n('API Key'), + placeholder: createI18n('API Key'), + }), + createTextSchema({ + variable: 'other', + label: createI18n('Other'), + placeholder: createI18n('Other'), + }), + ] + const value: FormValue = { api_key: '', other: '' } + const onChange = vi.fn() + + render( + , + ) + + // Change api_key to set changeKey + fireEvent.change(screen.getByPlaceholderText('API Key'), { target: { value: 'new' } }) + + // ValidatingTip should appear for api_key + expect(screen.getByText('Validating...')).toBeInTheDocument() + }) + + // Select with show_on not met: hidden + it('should hide select field when show_on conditions are not met', () => { + const formSchemas: AnyFormSchema[] = [ + createSelectSchema({ + variable: 'hidden_select', + label: createI18n('Hidden Select'), + placeholder: createI18n('Pick one'), + show_on: [{ variable: 'toggle', value: 'on' }], + }), + ] + const value: FormValue = { hidden_select: 'a', toggle: 'off' } + + render( + , + ) + + expect(screen.queryByText('Hidden Select')).not.toBeInTheDocument() + }) + + // Select option with show_on filter + it('should filter out select options whose show_on conditions are not met', () => { + const formSchemas: AnyFormSchema[] = [ + createSelectSchema({ + variable: 'filtered_select', + label: createI18n('Filtered Select'), + placeholder: createI18n('Pick one'), + options: [ + { label: createI18n('Always'), value: 'a', show_on: [] }, + { label: createI18n('Conditional'), value: 'b', show_on: [{ variable: 'toggle', value: 'yes' }] }, + ], + }), + ] + const value: FormValue = { filtered_select: 'a', toggle: 'no' } + + render( + , + ) + + expect(screen.getByText('Always')).toBeInTheDocument() + expect(screen.queryByText('Conditional')).not.toBeInTheDocument() + }) + + // Checkbox with show_on not met: hidden + it('should hide checkbox field when show_on conditions are not met', () => { + const formSchemas: AnyFormSchema[] = [ + createRadioSchema({ + variable: 'hidden_check', + type: FormTypeEnum.checkbox, + label: createI18n('Hidden Checkbox'), + options: [], + show_on: [{ variable: 'toggle', value: 'on' }], + }), + ] + const value: FormValue = { hidden_check: false, toggle: 'off' } + + render( + , + ) + + expect(screen.queryByText('Hidden Checkbox')).not.toBeInTheDocument() + }) + + // Select with readonly: disabled + it('should disable select field when readonly is true', () => { + const formSchemas: AnyFormSchema[] = [ + createSelectSchema({ + variable: 'ro_select', + label: createI18n('RO Select'), + placeholder: createI18n('Pick one'), + }), + ] + const value: FormValue = { ro_select: 'a' } + + render( + , + ) + + const selectTrigger = screen.getByRole('button', { name: 'Select A' }) + fireEvent.click(selectTrigger) + expect(screen.queryByText('Select B')).not.toBeInTheDocument() + }) + + // isShowDefaultValue=false: value used even if empty + it('should use actual empty value when isShowDefaultValue is false', () => { + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'field1', + label: createI18n('Field'), + placeholder: createI18n('Field'), + default: 'default-val', + }), + ] + const value: FormValue = { field1: '' } + + render( + , + ) + + expect(screen.getByPlaceholderText('Field')).toHaveValue('') + }) + + // Radio with disabled=true in edit mode for __model_type + it('should apply disabled styling for __model_type radio in edit mode', () => { + const formSchemas: AnyFormSchema[] = [ + createRadioSchema({ + variable: '__model_type', + label: createI18n('Model Type Radio'), + options: [ + { label: createI18n('Type A'), value: 'a', show_on: [] }, + ], + }), + ] + const value: FormValue = { __model_type: 'a' } + const onChange = vi.fn() + + render( + , + ) + + // Click should be blocked by isEditMode guard + fireEvent.click(screen.getByText('Type A')) + expect(onChange).not.toHaveBeenCalled() + }) + + // multiToolSelector with no tooltip + it('should render multiToolSelector without tooltip when tooltip is not provided', () => { + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'multi_tool', + type: FormTypeEnum.multiToolSelector, + label: createI18n('Multi Tool No Tip'), + }), + ] + const value: FormValue = { multi_tool: [] } + + render( + , + ) + + expect(screen.getByText('Select Tools')).toBeInTheDocument() + }) + + // Override with non-matching type: falls through to default + it('should not override when form type does not match override types', () => { + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'secret_field', + type: FormTypeEnum.secretInput, + label: createI18n('Secret Field'), + placeholder: createI18n('Secret Field'), + }), + ] + const value: FormValue = { secret_field: 'val' } + + render( +
Override Hit
]} + />, + ) + + expect(screen.queryByText('Override Hit')).not.toBeInTheDocument() + expect(screen.getByPlaceholderText('Secret Field')).toBeInTheDocument() + }) + + // Select with isShowDefaultValue: null value shows default + it('should use default value for select when value is null and isShowDefaultValue is true', () => { + const formSchemas: AnyFormSchema[] = [ + createSelectSchema({ + variable: 'null_select', + label: createI18n('Null Select'), + placeholder: createI18n('Pick'), + default: 'b', + }), + ] + const value: FormValue = { null_select: null } + + render( + , + ) + + expect(screen.getByText('Select B')).toBeInTheDocument() + }) + + // Select with isShowDefaultValue: undefined value shows default + it('should use default value for select when value is undefined and isShowDefaultValue is true', () => { + const formSchemas: AnyFormSchema[] = [ + createSelectSchema({ + variable: 'undef_select', + label: createI18n('Undef Select'), + placeholder: createI18n('Pick'), + default: 'a', + }), + ] + const value: FormValue = { undef_select: undefined } + + render( + , + ) + + expect(screen.getByText('Select A')).toBeInTheDocument() + }) + + // No fieldMoreInfo: should not crash + it('should render without fieldMoreInfo', () => { + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'f1', + label: createI18n('Field 1'), + placeholder: createI18n('Field 1'), + }), + ] + const value: FormValue = { f1: '' } + + render( + , + ) + + expect(screen.getByPlaceholderText('Field 1')).toBeInTheDocument() + }) + + it('should render tooltip when schema has tooltip property', () => { + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'api_key', + label: createI18n('API Key'), + placeholder: createI18n('API Key'), + tooltip: createI18n('Enter your API key here'), + }), + createRadioSchema({ + variable: 'region', + label: createI18n('Region'), + tooltip: createI18n('Select region'), + }), + createSelectSchema({ + variable: 'model', + label: createI18n('Model'), + tooltip: createI18n('Choose model'), + }), + { + ...createBaseSchema(FormTypeEnum.checkbox, { variable: 'agree' }), + label: createI18n('Agree'), + tooltip: createI18n('Agree tooltip'), + options: [], + show_on: [], + } as unknown as AnyFormSchema, + ] + const value: FormValue = { api_key: '', region: 'a', model: 'a', agree: false } + + render( + , + ) + + expect(screen.getByText('API Key')).toBeInTheDocument() + expect(screen.getByText('Region')).toBeInTheDocument() + expect(screen.getByText('Model')).toBeInTheDocument() + expect(screen.getByText('Agree')).toBeInTheDocument() + }) + + it('should render required asterisk for radio, select, checkbox, and other field types', () => { + const formSchemas: AnyFormSchema[] = [ + createRadioSchema({ + variable: 'radio_req', + label: createI18n('Radio Req'), + required: true, + }), + createSelectSchema({ + variable: 'select_req', + label: createI18n('Select Req'), + required: true, + }), + { + ...createBaseSchema(FormTypeEnum.checkbox, { variable: 'check_req' }), + label: createI18n('Check Req'), + required: true, + options: [], + show_on: [], + } as unknown as AnyFormSchema, + createTextSchema({ + variable: 'model_sel', + type: FormTypeEnum.modelSelector, + label: createI18n('Model Sel'), + required: true, + }), + createTextSchema({ + variable: 'tool_sel', + type: FormTypeEnum.toolSelector, + label: createI18n('Tool Sel'), + required: true, + }), + createTextSchema({ + variable: 'app_sel', + type: FormTypeEnum.appSelector, + label: createI18n('App Sel'), + required: true, + }), + createTextSchema({ + variable: 'any_field', + type: FormTypeEnum.any, + label: createI18n('Any Field'), + required: true, + }), + ] + const value: FormValue = { + radio_req: 'a', + select_req: 'a', + check_req: false, + model_sel: {}, + tool_sel: null, + app_sel: null, + any_field: [], + } + + render( + , + ) + + // All 7 required fields should have asterisks + expect(screen.getAllByText('*')).toHaveLength(7) + }) + + it('should show ValidatingTip for radio field being validated', () => { + const formSchemas: AnyFormSchema[] = [ + createRadioSchema({ + variable: 'region', + label: createI18n('Region'), + }), + ] + const value: FormValue = { region: 'a' } + const onChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByText('Option B')) + expect(screen.getByText('Validating...')).toBeInTheDocument() + }) + + it('should render textInput with show_on condition met', () => { + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'conditional_field', + label: createI18n('Conditional'), + placeholder: createI18n('Conditional'), + show_on: [{ variable: 'toggle', value: 'on' }], + }), + ] + const value: FormValue = { conditional_field: 'val', toggle: 'on' } + + render( + , + ) + + expect(screen.getByPlaceholderText('Conditional')).toBeInTheDocument() + }) + + it('should render radio with show_on condition met', () => { + const formSchemas: AnyFormSchema[] = [ + createRadioSchema({ + variable: 'cond_radio', + label: createI18n('Cond Radio'), + show_on: [{ variable: 'toggle', value: 'on' }], + }), + ] + const value: FormValue = { cond_radio: 'a', toggle: 'on' } + + render( + , + ) + + expect(screen.getByText('Cond Radio')).toBeInTheDocument() + }) + + it('should proceed with onChange when isEditMode is true but key is not locked', () => { + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'custom_key', + label: createI18n('Custom Key'), + placeholder: createI18n('Custom Key'), + }), + ] + const value: FormValue = { custom_key: 'old' } + const onChange = vi.fn() + + render( + , + ) + + fireEvent.change(screen.getByPlaceholderText('Custom Key'), { target: { value: 'new' } }) + expect(onChange).toHaveBeenCalledWith({ custom_key: 'new' }) + }) + + it('should return undefined when customRenderField is not provided for unknown type', () => { + const formSchemas: Array = [ + { + ...createTextSchema({ + variable: 'unknown', + label: createI18n('Unknown'), + }), + type: 'custom-type', + } as unknown as CustomSchema, + ] + const value: FormValue = { unknown: '' } + + render( + + value={value} + onChange={vi.fn()} + formSchemas={formSchemas} + validating={false} + validatedSuccess={false} + showOnVariableMap={{}} + isEditMode={false} + />, + ) + + // Should not crash - the field simply doesn't render + expect(screen.queryByText('Unknown')).not.toBeInTheDocument() + }) + + it('should render fieldMoreInfo for checkbox field', () => { + const formSchemas: AnyFormSchema[] = [ + { + ...createBaseSchema(FormTypeEnum.checkbox, { variable: 'check' }), + label: createI18n('Check'), + options: [], + show_on: [], + } as unknown as AnyFormSchema, + ] + const value: FormValue = { check: false } + + render( +
Check Extra
} + />, + ) + + expect(screen.getByText('Check Extra')).toBeInTheDocument() + }) + }) + + describe('Language fallback branches', () => { + it('should fallback to en_US for labels, placeholders, and tooltips when language key is missing', () => { + mockLanguageRef.value = 'fr_FR' + + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'api_key', + label: createPartialI18n('API Key Fallback'), + placeholder: createPartialI18n('Enter Key Fallback'), + tooltip: createPartialI18n('Tooltip Fallback'), + }), + createRadioSchema({ + variable: 'region', + label: createPartialI18n('Region Fallback'), + }), + createSelectSchema({ + variable: 'model', + label: createPartialI18n('Model Fallback'), + placeholder: createPartialI18n('Select Fallback'), + }), + { + ...createBaseSchema(FormTypeEnum.checkbox, { variable: 'agree' }), + label: createPartialI18n('Agree Fallback'), + options: [], + show_on: [], + } as unknown as AnyFormSchema, + ] + const value: FormValue = { api_key: '', region: 'a', model: 'a', agree: false } + + render( + , + ) + + expect(screen.getByText('API Key Fallback')).toBeInTheDocument() + expect(screen.getByText('Region Fallback')).toBeInTheDocument() + expect(screen.getByText('Model Fallback')).toBeInTheDocument() + expect(screen.getByText('Agree Fallback')).toBeInTheDocument() + }) + + it('should fallback to en_US for modelSelector, toolSelector, and appSelector labels', () => { + mockLanguageRef.value = 'fr_FR' + + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'model_sel', + type: FormTypeEnum.modelSelector, + label: createPartialI18n('ModelSel Fallback'), + }), + createTextSchema({ + variable: 'tool_sel', + type: FormTypeEnum.toolSelector, + label: createPartialI18n('ToolSel Fallback'), + }), + createTextSchema({ + variable: 'app_sel', + type: FormTypeEnum.appSelector, + label: createPartialI18n('AppSel Fallback'), + }), + createTextSchema({ + variable: 'any_field', + type: FormTypeEnum.any, + label: createPartialI18n('Any Fallback'), + }), + ] + const value: FormValue = { model_sel: '', tool_sel: '', app_sel: '', any_field: '' } + + render( + , + ) + + expect(screen.getByText('ModelSel Fallback')).toBeInTheDocument() + expect(screen.getByText('ToolSel Fallback')).toBeInTheDocument() + expect(screen.getByText('AppSel Fallback')).toBeInTheDocument() + expect(screen.getByText('Any Fallback')).toBeInTheDocument() + }) + + it('should not change value when __model_type is edited in edit mode', () => { + const onChange = vi.fn() + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: '__model_type', + label: createI18n('Model Type'), + placeholder: createI18n('Model Type'), + }), + ] + const value: FormValue = { __model_type: 'llm' } + + render( + , + ) + + const input = screen.getByDisplayValue('llm') + fireEvent.change(input, { target: { value: 'embedding' } }) + expect(onChange).not.toHaveBeenCalled() + }) + + it('should use value instead of default when isShowDefaultValue is true but value is non-empty', () => { + const formSchemas: AnyFormSchema[] = [ + { + ...createTextSchema({ + variable: 'with_val', + label: createI18n('With Value'), + placeholder: createI18n('Placeholder'), + }), + default: 'default-text', + } as unknown as AnyFormSchema, + ] + const value: FormValue = { with_val: 'actual-value' } + + render( + , + ) + + expect(screen.getByDisplayValue('actual-value')).toBeInTheDocument() + }) + + it('should pass nodeOutputVars and availableNodes to toolSelector', () => { + toolSelectorPropsSpy.mockClear() + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'tool_sel', + type: FormTypeEnum.toolSelector, + label: createI18n('Tool Selector'), + }), + ] + const value: FormValue = { tool_sel: '' } + const nodeOutputVars: NodeOutPutVar[] = [] + const availableNodes: Node[] = [] + + render( + , + ) + + expect(screen.getByText('Select Tool')).toBeInTheDocument() + expect(toolSelectorPropsSpy).toHaveBeenCalledWith(expect.objectContaining({ + nodeOutputVars, + availableNodes, + })) + }) + + it('should pass isAgentStrategy to modelSelector', () => { + modelSelectorPropsSpy.mockClear() + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'model_sel', + type: FormTypeEnum.modelSelector, + label: createI18n('Model Selector'), + }), + ] + const value: FormValue = { model_sel: '' } + + render( + , + ) + + expect(screen.getByText('Select Model')).toBeInTheDocument() + expect(modelSelectorPropsSpy).toHaveBeenCalledWith(expect.objectContaining({ + isAgentStrategy: true, + })) + }) + + it('should use empty array fallback for multiToolSelector when value is null', () => { + // Arrange + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'multi_tool', + type: FormTypeEnum.multiToolSelector, + label: createI18n('Multi Tool'), + }), + ] + const value: FormValue = { multi_tool: null } + const onChange = vi.fn() + + // Act + render( + , + ) + + // Assert - should render without crash (value[variable] || [] path taken) + expect(screen.getByText('Select Tools')).toBeInTheDocument() + }) + + it('should show ValidatingTip for multiToolSelector field being validated', () => { + // Arrange + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'multi_tool', + type: FormTypeEnum.multiToolSelector, + label: createI18n('Multi Tool'), + }), + ] + const value: FormValue = { multi_tool: [] } + const onChange = vi.fn() + + // Act + render( + , + ) + + fireEvent.click(screen.getByText('Select Tools')) + + // Assert + expect(screen.getByText('Validating...')).toBeInTheDocument() + }) + + it('should show ValidatingTip for appSelector field being validated', () => { + // Arrange + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'app_sel', + type: FormTypeEnum.appSelector, + label: createI18n('App Selector'), + }), + ] + const value: FormValue = { app_sel: null } + const onChange = vi.fn() + + // Act + render( + , + ) + + fireEvent.click(screen.getByText('Select App')) + + // Assert + expect(screen.getByText('Validating...')).toBeInTheDocument() + }) + + it('should show ValidatingTip for any-type field being validated', () => { + // Arrange + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'any_var', + type: FormTypeEnum.any, + label: createI18n('Any Var'), + scope: 'text', + }), + ] + const value: FormValue = { any_var: [] } + const onChange = vi.fn() + + // Act + render( + , + ) + + fireEvent.click(screen.getByText('Pick Variable')) + + // Assert + expect(screen.getByText('Validating...')).toBeInTheDocument() + }) + + it('should use empty string fallback for nodeId in any-type when nodeId is not provided', () => { + // Arrange + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'any_field', + type: FormTypeEnum.any, + label: createI18n('Any Field'), + }), + ] + const value: FormValue = { any_field: [] } + + // Act + render( + , + ) + + // Assert - should render without crash + expect(screen.getByText('Any Field')).toBeInTheDocument() + }) + + it('should use en_US label fallback for multiToolSelector when language key is missing', () => { + // Arrange + mockLanguageRef.value = 'fr_FR' + + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'multi_tool', + type: FormTypeEnum.multiToolSelector, + label: createPartialI18n('MultiTool Fallback'), + tooltip: createPartialI18n('Tooltip Fallback'), + }), + ] + const value: FormValue = { multi_tool: [] } + + // Act + render( + , + ) + + // Assert - MultipleToolSelector mock renders with the label prop + expect(screen.getByText('Select Tools')).toBeInTheDocument() + }) + + it('should show ValidatingTip for select field being validated', () => { + // Arrange: value 'a' is pre-selected so 'Select A' text appears in the trigger button + const formSchemas: AnyFormSchema[] = [ + createSelectSchema({ + variable: 'model_select', + label: createI18n('Model'), + }), + ] + const value: FormValue = { model_select: 'a' } + const onChange = vi.fn() + + // Act + render( + , + ) + + // First click opens the dropdown (Select A is the trigger button text) + fireEvent.click(screen.getByText('Select A')) + // Then click on 'Select B' option in the open dropdown + fireEvent.click(screen.getByText('Select B')) + + // Assert: ValidatingTip shows for the select field + expect(screen.getByText('Validating...')).toBeInTheDocument() + }) + + it('should show ValidatingTip for toolSelector field being validated', () => { + // Arrange + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'tool_sel', + type: FormTypeEnum.toolSelector, + label: createI18n('Tool Selector'), + }), + ] + const value: FormValue = { tool_sel: null } + const onChange = vi.fn() + + // Act + render( + , + ) + + // Trigger tool selection to set changeKey + fireEvent.click(screen.getByText('Select Tool')) + + // Assert + expect(screen.getByText('Validating...')).toBeInTheDocument() + }) + + it('should not render customRenderField for a FormTypeEnum value that is unhandled by Form', () => { + // Arrange: pass a FormTypeEnum value that exists in the enum but is not handled by any if block + const formSchemas: Array = [ + { + ...createBaseSchema(FormTypeEnum.boolean, { variable: 'bool_field' }), + label: createI18n('Boolean Field'), + show_on: [], + } as unknown as AnyFormSchema, + ] + const value: FormValue = { bool_field: false } + const customRenderField = vi.fn() + + // Act + render( + , + ) + + // Assert: customRenderField is not called for a known FormTypeEnum (boolean is in the enum) + expect(customRenderField).not.toHaveBeenCalled() + }) }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx index 2927abe549..64c6c97ded 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx @@ -161,7 +161,7 @@ function Form< const disabled = readonly || (isEditMode && (variable === '__model_type' || variable === '__model_name')) return (
-
+
{label[language] || label.en_US} {required && ( * @@ -204,13 +204,14 @@ function Form< return (
-
+
{label[language] || label.en_US} {required && ( * )} {tooltipContent}
+ {/* eslint-disable-next-line tailwindcss/no-unknown-classes */}
{options.filter((option) => { if (option.show_on.length) @@ -229,7 +230,7 @@ function Form< > -
{option.label[language] || option.label.en_US}
+
{option.label[language] || option.label.en_US}
))}
@@ -254,7 +255,7 @@ function Form< return (
-
+
{label[language] || label.en_US} {required && ( @@ -295,9 +296,9 @@ function Form< return (
-
+
- {label[language] || label.en_US} + {label[language] || label.en_US} {required && ( * )} @@ -326,7 +327,7 @@ function Form< } = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput) return (
-
+
{label[language] || label.en_US} {required && ( * @@ -358,7 +359,7 @@ function Form< } = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput) return (
-
+
{label[language] || label.en_US} {required && ( * @@ -422,7 +423,7 @@ function Form< return (
-
+
{label[language] || label.en_US} {required && ( * @@ -451,7 +452,7 @@ function Form< return (
-
+
{label[language] || label.en_US} {required && ( * diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/Input.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/Input.spec.tsx index baea6732cb..66db50d976 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/Input.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/Input.spec.tsx @@ -93,4 +93,88 @@ describe('Input', () => { expect(onChange).not.toHaveBeenCalledWith('2') expect(onChange).not.toHaveBeenCalledWith('6') }) + + it('should not clamp when min and max are not provided', () => { + const onChange = vi.fn() + + render( + , + ) + + const input = screen.getByPlaceholderText('Free') + fireEvent.change(input, { target: { value: '999' } }) + fireEvent.blur(input) + + // onChange only called from change event, not from blur clamping + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith('999') + }) + + it('should show check circle icon when validated is true', () => { + const { container } = render( + , + ) + + expect(screen.getByPlaceholderText('Key')).toBeInTheDocument() + expect(container.querySelector('.absolute.right-2\\.5.top-2\\.5')).toBeInTheDocument() + }) + + it('should not show check circle icon when validated is false', () => { + const { container } = render( + , + ) + + expect(screen.getByPlaceholderText('Key')).toBeInTheDocument() + expect(container.querySelector('.absolute.right-2\\.5.top-2\\.5')).not.toBeInTheDocument() + }) + + it('should apply disabled attribute when disabled prop is true', () => { + render( + , + ) + + expect(screen.getByPlaceholderText('Disabled')).toBeDisabled() + }) + + it('should call onFocus when input receives focus', () => { + const onFocus = vi.fn() + + render( + , + ) + + fireEvent.focus(screen.getByPlaceholderText('Focus')) + expect(onFocus).toHaveBeenCalledTimes(1) + }) + + it('should render with custom className', () => { + render( + , + ) + + expect(screen.getByPlaceholderText('Styled')).toHaveClass('custom-class') + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/index.spec.tsx index 376c128c89..07d3c820cf 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/index.spec.tsx @@ -1,5 +1,7 @@ -import type { Credential, CredentialFormSchema, ModelProvider } from '../declarations' +import type { ComponentProps } from 'react' +import type { Credential, CredentialFormSchema, CustomModel, ModelProvider } from '../declarations' import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' import { ConfigurationMethodEnum, CurrentSystemQuotaTypeEnum, @@ -43,15 +45,6 @@ const mockHandlers = vi.hoisted(() => ({ handleActiveCredential: vi.fn(), })) -type FormResponse = { - isCheckValidated: boolean - values: Record -} -const mockFormState = vi.hoisted(() => ({ - responses: [] as FormResponse[], - setFieldValue: vi.fn(), -})) - vi.mock('../model-auth/hooks', () => ({ useCredentialData: () => ({ isLoading: mockState.isLoading, @@ -86,36 +79,6 @@ vi.mock('../hooks', () => ({ useLanguage: () => 'en_US', })) -vi.mock('@/app/components/base/form/form-scenarios/auth', async () => { - const React = await import('react') - const AuthForm = React.forwardRef(({ - onChange, - }: { - onChange?: (field: string, value: string) => void - }, ref: React.ForwardedRef<{ getFormValues: () => FormResponse, getForm: () => { setFieldValue: (field: string, value: string) => void } }>) => { - React.useImperativeHandle(ref, () => ({ - getFormValues: () => mockFormState.responses.shift() || { isCheckValidated: false, values: {} }, - getForm: () => ({ setFieldValue: mockFormState.setFieldValue }), - })) - return ( -
- -
- ) - }) - - return { default: AuthForm } -}) - -vi.mock('../model-auth', () => ({ - CredentialSelector: ({ onSelect }: { onSelect: (credential: Credential & { addNewCredential?: boolean }) => void }) => ( -
- - -
- ), -})) - const createI18n = (text: string) => ({ en_US: text, zh_Hans: text }) const createProvider = (overrides?: Partial): ModelProvider => ({ @@ -158,7 +121,7 @@ const createProvider = (overrides?: Partial): ModelProvider => ({ ...overrides, }) -const renderModal = (overrides?: Partial>) => { +const renderModal = (overrides?: Partial>) => { const provider = createProvider() const props = { provider, @@ -168,13 +131,50 @@ const renderModal = (overrides?: Partial onRemove: vi.fn(), ...overrides, } - const view = render() - return { - ...props, - unmount: view.unmount, - } + render() + return props } +const mockFormRef1 = { + getFormValues: vi.fn(), + getForm: vi.fn(() => ({ setFieldValue: vi.fn() })), +} + +const mockFormRef2 = { + getFormValues: vi.fn(), + getForm: vi.fn(() => ({ setFieldValue: vi.fn() })), +} + +vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({ + default: React.forwardRef((props: { formSchemas: Record[], onChange?: (f: string, v: string) => void }, ref: React.ForwardedRef) => { + React.useImperativeHandle(ref, () => { + // Return the mock depending on schemas passed (hacky but works for refs) + if (props.formSchemas.length > 0 && props.formSchemas[0].name === '__model_name') + return mockFormRef1 + return mockFormRef2 + }) + return ( +
props.onChange?.('test-field', 'val')}> + AuthForm Mock ( + {props.formSchemas.length} + {' '} + fields) +
+ ) + }), +})) + +vi.mock('../model-auth', () => ({ + CredentialSelector: ({ onSelect }: { onSelect: (val: unknown) => void }) => ( + + ), + useAuth: vi.fn(), + useCredentialData: vi.fn(), + useModelFormSchemas: vi.fn(), +})) + describe('ModelModal', () => { beforeEach(() => { vi.clearAllMocks() @@ -187,167 +187,131 @@ describe('ModelModal', () => { mockState.formValues = {} mockState.modelNameAndTypeFormSchemas = [] mockState.modelNameAndTypeFormValues = {} - mockFormState.responses = [] + + // reset form refs + mockFormRef1.getFormValues.mockReturnValue({ isCheckValidated: true, values: { __model_name: 'test', __model_type: ModelTypeEnum.textGeneration } }) + mockFormRef2.getFormValues.mockReturnValue({ isCheckValidated: true, values: { __authorization_name__: 'test_auth', api_key: 'sk-test' } }) }) - it('should show title, description, and loading state for predefined models', () => { + it('should render title and loading state for predefined credential modal', () => { mockState.isLoading = true - - const predefined = renderModal() - + renderModal() expect(screen.getByText('common.modelProvider.auth.apiKeyModal.title')).toBeInTheDocument() expect(screen.getByText('common.modelProvider.auth.apiKeyModal.desc')).toBeInTheDocument() - expect(screen.getByRole('status')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled() + }) - predefined.unmount() - const customizable = renderModal({ configurateMethod: ConfigurationMethodEnum.customizableModel }) - expect(screen.queryByText('common.modelProvider.auth.apiKeyModal.desc')).not.toBeInTheDocument() - customizable.unmount() - - mockState.credentialData = { credentials: {}, available_credentials: [] } - renderModal({ mode: ModelModalModeEnum.configModelCredential, model: { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration } }) + it('should render model credential title when mode is configModelCredential', () => { + renderModal({ + mode: ModelModalModeEnum.configModelCredential, + model: { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration }, + }) expect(screen.getByText('common.modelProvider.auth.addModelCredential')).toBeInTheDocument() }) - it('should reveal the credential label when adding a new credential', () => { - renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList }) - - expect(screen.queryByText('common.modelProvider.auth.modelCredential')).not.toBeInTheDocument() - - fireEvent.click(screen.getByText('Add New')) - - expect(screen.getByText('common.modelProvider.auth.modelCredential')).toBeInTheDocument() - }) - - it('should call onCancel when the cancel button is clicked', () => { - const { onCancel } = renderModal() - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) - - expect(onCancel).toHaveBeenCalledTimes(1) - }) - - it('should call onCancel when the escape key is pressed', () => { - const { onCancel } = renderModal() - - fireEvent.keyDown(document, { key: 'Escape' }) - - expect(onCancel).toHaveBeenCalledTimes(1) - }) - - it('should confirm deletion when a delete dialog is shown', () => { - mockState.credentialData = { credentials: { api_key: 'secret' }, available_credentials: [] } - mockState.deleteCredentialId = 'delete-id' - - const credential: Credential = { credential_id: 'cred-1' } - const { onCancel } = renderModal({ credential }) - - expect(screen.getByText('common.modelProvider.confirmDelete')).toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) - - expect(mockHandlers.handleConfirmDelete).toHaveBeenCalledTimes(1) - expect(onCancel).toHaveBeenCalledTimes(1) - }) - - it('should handle save flows for different modal modes', async () => { - mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text-input' } as unknown as CredentialFormSchema] - mockState.formSchemas = [{ variable: 'api_key', type: 'secret-input' } as unknown as CredentialFormSchema] - mockFormState.responses = [ - { isCheckValidated: true, values: { __model_name: 'custom-model', __model_type: ModelTypeEnum.textGeneration } }, - { isCheckValidated: true, values: { __authorization_name__: 'Auth Name', api_key: 'secret' } }, - ] - const configCustomModel = renderModal({ mode: ModelModalModeEnum.configCustomModel }) - fireEvent.click(screen.getAllByText('Model Name Change')[0]) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) - - expect(mockFormState.setFieldValue).toHaveBeenCalledWith('__model_name', 'updated-model') - await waitFor(() => { - expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({ - credential_id: undefined, - credentials: { api_key: 'secret' }, - name: 'Auth Name', - model: 'custom-model', - model_type: ModelTypeEnum.textGeneration, - }) - }) - expect(configCustomModel.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Auth Name', api_key: 'secret' }) - configCustomModel.unmount() - - mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Model Auth', api_key: 'abc' } }] - const model = { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration } - const configModelCredential = renderModal({ + it('should render edit credential title when credential exists', () => { + renderModal({ mode: ModelModalModeEnum.configModelCredential, - model, - credential: { credential_id: 'cred-123' }, + credential: { credential_id: '1' } as unknown as Credential, }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - await waitFor(() => { - expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({ - credential_id: 'cred-123', - credentials: { api_key: 'abc' }, - name: 'Model Auth', - model: 'gpt-4', - model_type: ModelTypeEnum.textGeneration, - }) - }) - expect(configModelCredential.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Model Auth', api_key: 'abc' }) - configModelCredential.unmount() + expect(screen.getByText('common.modelProvider.auth.editModelCredential')).toBeInTheDocument() + }) + + it('should change title to Add Model when mode is configCustomModel', () => { + mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text' } as unknown as CredentialFormSchema] + renderModal({ mode: ModelModalModeEnum.configCustomModel }) + expect(screen.getByText('common.modelProvider.auth.addModel')).toBeInTheDocument() + }) + + it('should validate and fail save if form is invalid in configCustomModel mode', async () => { + mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text' } as unknown as CredentialFormSchema] + mockFormRef1.getFormValues.mockReturnValue({ isCheckValidated: false, values: {} }) + renderModal({ mode: ModelModalModeEnum.configCustomModel }) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) + expect(mockHandlers.handleSaveCredential).not.toHaveBeenCalled() + }) + + it('should validate and save new credential and model in configCustomModel mode', async () => { + mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text' } as unknown as CredentialFormSchema] + const props = renderModal({ mode: ModelModalModeEnum.configCustomModel }) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) - mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Provider Auth', api_key: 'provider-key' } }] - const configProviderCredential = renderModal({ mode: ModelModalModeEnum.configProviderCredential }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) await waitFor(() => { expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({ credential_id: undefined, - credentials: { api_key: 'provider-key' }, - name: 'Provider Auth', + credentials: { api_key: 'sk-test' }, + name: 'test_auth', + model: 'test', + model_type: ModelTypeEnum.textGeneration, }) + expect(props.onSave).toHaveBeenCalled() }) - configProviderCredential.unmount() + }) - const addToModelList = renderModal({ - mode: ModelModalModeEnum.addCustomModelToModelList, - model, - }) - fireEvent.click(screen.getByText('Choose Existing')) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) - expect(mockHandlers.handleActiveCredential).toHaveBeenCalledWith({ credential_id: 'existing' }, model) - expect(addToModelList.onCancel).toHaveBeenCalled() - addToModelList.unmount() + it('should save credential only in standard configProviderCredential mode', async () => { + const { onSave } = renderModal({ mode: ModelModalModeEnum.configProviderCredential }) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'New Auth', api_key: 'new-key' } }] - const addToModelListWithNew = renderModal({ - mode: ModelModalModeEnum.addCustomModelToModelList, - model, - }) - fireEvent.click(screen.getByText('Add New')) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) await waitFor(() => { expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({ credential_id: undefined, - credentials: { api_key: 'new-key' }, - name: 'New Auth', - model: 'gpt-4', + credentials: { api_key: 'sk-test' }, + name: 'test_auth', + }) + expect(onSave).toHaveBeenCalled() + }) + }) + + it('should save active credential and cancel when picking existing credential in addCustomModelToModelList mode', async () => { + renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList, model: { model: 'm1', model_type: ModelTypeEnum.textGeneration } as unknown as CustomModel }) + // By default selected is undefined so button clicks form + // Let's not click credential selector, so it evaluates without it. If selectedCredential is undefined, form validation is checked. + mockFormRef2.getFormValues.mockReturnValue({ isCheckValidated: false, values: {} }) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) + expect(mockHandlers.handleSaveCredential).not.toHaveBeenCalled() + }) + + it('should save active credential when picking existing credential in addCustomModelToModelList mode', async () => { + renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList, model: { model: 'm2', model_type: ModelTypeEnum.textGeneration } as unknown as CustomModel }) + + // Select existing credential (addNewCredential: true simulates new but we can simulate false if we just hack the mocked state in the component, but it's internal. + // The credential selector sets selectedCredential. + fireEvent.click(screen.getByTestId('credential-selector')) // Sets addNewCredential = true internally, so it proceeds to form save + + mockFormRef2.getFormValues.mockReturnValue({ isCheckValidated: true, values: { __authorization_name__: 'auth', api: 'key' } }) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) + + await waitFor(() => { + expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({ + credential_id: undefined, + credentials: { api: 'key' }, + name: 'auth', + model: 'm2', model_type: ModelTypeEnum.textGeneration, }) }) - addToModelListWithNew.unmount() + }) - mockFormState.responses = [{ isCheckValidated: false, values: {} }] - const invalidSave = renderModal({ mode: ModelModalModeEnum.configProviderCredential }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - await waitFor(() => { - expect(mockHandlers.handleSaveCredential).toHaveBeenCalledTimes(4) - }) - invalidSave.unmount() + it('should open and confirm deletion of credential', () => { + mockState.credentialData = { credentials: { api_key: '123' }, available_credentials: [] } + mockState.formValues = { api_key: '123' } // To trigger isEditMode = true + const credential = { credential_id: 'c1' } as unknown as Credential + renderModal({ credential }) - mockState.credentialData = { credentials: { api_key: 'value' }, available_credentials: [] } - mockState.formValues = { api_key: 'value' } - const removable = renderModal({ credential: { credential_id: 'remove-1' } }) + // Open Delete Confirm fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' })) - expect(mockHandlers.openConfirmDelete).toHaveBeenCalledWith({ credential_id: 'remove-1' }, undefined) - removable.unmount() + expect(mockHandlers.openConfirmDelete).toHaveBeenCalledWith(credential, undefined) + + // Simulate the dialog appearing and confirming + mockState.deleteCredentialId = 'c1' + renderModal({ credential }) // Re-render logic mock + fireEvent.click(screen.getAllByRole('button', { name: 'common.operation.confirm' })[0]) + + expect(mockHandlers.handleConfirmDelete).toHaveBeenCalled() + }) + + it('should bind escape key to cancel', () => { + const props = renderModal() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(props.onCancel).toHaveBeenCalled() }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.spec.tsx index 111af0b497..ccfab6d165 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.spec.tsx @@ -1,9 +1,8 @@ import { fireEvent, render, screen } from '@testing-library/react' -import { vi } from 'vitest' import ModelParameterModal from './index' let isAPIKeySet = true -let parameterRules = [ +let parameterRules: Array> | undefined = [ { name: 'temperature', label: { en_US: 'Temperature' }, @@ -62,42 +61,17 @@ vi.mock('../hooks', () => ({ }), })) -// Mock PortalToFollowElem components to control visibility and simplify testing -vi.mock('@/app/components/base/portal-to-follow-elem', () => { - return { - PortalToFollowElem: ({ children }: { children: React.ReactNode }) => { - return ( -
-
- {children} -
-
- ) - }, - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( -
- {children} -
- ), - PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className: string }) => ( -
- {children} -
- ), - } -}) - vi.mock('./parameter-item', () => ({ - default: ({ parameterRule, value, onChange, onSwitch }: { parameterRule: { name: string, label: { en_US: string } }, value: string | number, onChange: (v: number) => void, onSwitch: (checked: boolean, val: unknown) => void }) => ( + default: ({ parameterRule, onChange, onSwitch }: { + parameterRule: { name: string, label: { en_US: string } } + onChange: (v: number) => void + onSwitch: (checked: boolean, val: unknown) => void + }) => (
{parameterRule.label.en_US} - onChange(Number(e.target.value))} - /> - - + + +
), })) @@ -105,7 +79,6 @@ vi.mock('./parameter-item', () => ({ vi.mock('../model-selector', () => ({ default: ({ onSelect }: { onSelect: (value: { provider: string, model: string }) => void }) => (
- Model Selector
), @@ -121,16 +94,11 @@ vi.mock('./trigger', () => ({ default: () => , })) -vi.mock('@/utils/classnames', () => ({ - cn: (...args: (string | undefined | null | false)[]) => args.filter(Boolean).join(' '), -})) - -// Mock config vi.mock('@/config', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - PROVIDER_WITH_PRESET_TONE: ['openai'], // ensure presets mock renders + PROVIDER_WITH_PRESET_TONE: ['openai'], } }) @@ -188,21 +156,19 @@ describe('ModelParameterModal', () => { ] }) - it('should render trigger and content', () => { + it('should render trigger and open modal content when trigger is clicked', () => { render() - expect(screen.getByText('Open Settings')).toBeInTheDocument() - expect(screen.getByText('Temperature')).toBeInTheDocument() + fireEvent.click(screen.getByText('Open Settings')) expect(screen.getByTestId('model-selector')).toBeInTheDocument() - fireEvent.click(screen.getByTestId('portal-trigger')) + expect(screen.getByTestId('param-temperature')).toBeInTheDocument() }) - it('should update params when changed and handle switch add/remove', () => { + it('should call onCompletionParamsChange when parameter changes and switch actions happen', () => { render() + fireEvent.click(screen.getByText('Open Settings')) - const input = screen.getByLabelText('temperature') - fireEvent.change(input, { target: { value: '0.9' } }) - + fireEvent.click(screen.getByText('Change')) expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({ ...defaultProps.completionParams, temperature: 0.9, @@ -218,51 +184,18 @@ describe('ModelParameterModal', () => { }) }) - it('should handle preset selection', () => { + it('should call onCompletionParamsChange when preset is selected', () => { render() - + fireEvent.click(screen.getByText('Open Settings')) fireEvent.click(screen.getByText('Preset 1')) expect(defaultProps.onCompletionParamsChange).toHaveBeenCalled() }) - it('should handle debug mode toggle', () => { - const { rerender } = render() - const toggle = screen.getByText(/debugAsMultipleModel/i) - fireEvent.click(toggle) - expect(defaultProps.onDebugWithMultipleModelChange).toHaveBeenCalled() - - rerender() - expect(screen.getByText(/debugAsSingleModel/i)).toBeInTheDocument() - }) - it('should handle custom renderTrigger', () => { - const renderTrigger = vi.fn().mockReturnValue(
Custom Trigger
) - render() - - expect(screen.getByText('Custom Trigger')).toBeInTheDocument() - expect(renderTrigger).toHaveBeenCalled() - fireEvent.click(screen.getByTestId('portal-trigger')) - expect(renderTrigger).toHaveBeenCalledTimes(1) - }) - - it('should handle model selection and advanced mode parameters', () => { - parameterRules = [ - { - name: 'temperature', - label: { en_US: 'Temperature' }, - type: 'float', - default: 0.7, - min: 0, - max: 1, - help: { en_US: 'Control randomness' }, - }, - ] - const { rerender } = render() - expect(screen.getByTestId('param-temperature')).toBeInTheDocument() - - rerender() - expect(screen.getByTestId('param-stop')).toBeInTheDocument() - + it('should call setModel when model selector picks another model', () => { + render() + fireEvent.click(screen.getByText('Open Settings')) fireEvent.click(screen.getByText('Select GPT-4.1')) + expect(defaultProps.setModel).toHaveBeenCalledWith({ modelId: 'gpt-4.1', provider: 'openai', @@ -270,4 +203,32 @@ describe('ModelParameterModal', () => { features: ['vision', 'tool-call'], }) }) + + it('should toggle debug mode when debug footer is clicked', () => { + render() + fireEvent.click(screen.getByText('Open Settings')) + fireEvent.click(screen.getByText(/debugAsMultipleModel/i)) + expect(defaultProps.onDebugWithMultipleModelChange).toHaveBeenCalled() + }) + + it('should render loading state when parameter rules are loading', () => { + isRulesLoading = true + render() + fireEvent.click(screen.getByText('Open Settings')) + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('should not open content when readonly is true', () => { + render() + fireEvent.click(screen.getByText('Open Settings')) + expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument() + }) + + it('should render no parameter items when rules are undefined', () => { + parameterRules = undefined + render() + fireEvent.click(screen.getByText('Open Settings')) + expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument() + expect(screen.getByTestId('model-selector')).toBeInTheDocument() + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.spec.tsx index bd4c902f54..e4a355fca0 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.spec.tsx @@ -1,238 +1,182 @@ import type { ModelParameterRule } from '../declarations' import { fireEvent, render, screen } from '@testing-library/react' -import { vi } from 'vitest' import ParameterItem from './parameter-item' vi.mock('../hooks', () => ({ useLanguage: () => 'en_US', })) -vi.mock('@/app/components/base/radio', () => { - const Radio = ({ children, value }: { children: React.ReactNode, value: boolean }) => - Radio.Group = ({ children, onChange }: { children: React.ReactNode, onChange: (value: boolean) => void }) => ( -
- {children} - - -
- ) - return { default: Radio } -}) - -vi.mock('@/app/components/base/select', () => ({ - SimpleSelect: ({ onSelect, items }: { onSelect: (item: { value: string }) => void, items: { value: string, name: string }[] }) => ( - - ), -})) - vi.mock('@/app/components/base/slider', () => ({ - default: ({ value, onChange }: { value: number, onChange: (val: number) => void }) => ( - onChange(Number(e.target.value))} /> - ), -})) - -vi.mock('@/app/components/base/switch', () => ({ - default: ({ onChange, value }: { onChange: (val: boolean) => void, value: boolean }) => ( - + default: ({ onChange }: { onChange: (v: number) => void }) => ( + ), })) vi.mock('@/app/components/base/tag-input', () => ({ - default: ({ onChange }: { onChange: (val: string[]) => void }) => ( - onChange(e.target.value.split(','))} /> + default: ({ onChange }: { onChange: (v: string[]) => void }) => ( + ), })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ popupContent }: { popupContent: React.ReactNode }) =>
{popupContent}
, -})) - describe('ParameterItem', () => { const createRule = (overrides: Partial = {}): ModelParameterRule => ({ name: 'temp', label: { en_US: 'Temperature', zh_Hans: 'Temperature' }, type: 'float', - min: 0, - max: 1, help: { en_US: 'Help text', zh_Hans: 'Help text' }, required: false, ...overrides, }) - const createProps = (overrides: { - parameterRule?: ModelParameterRule - value?: number | string | boolean | string[] - } = {}) => { - const onChange = vi.fn() - const onSwitch = vi.fn() - return { - parameterRule: createRule(), - value: 0.7, - onChange, - onSwitch, - ...overrides, - } - } - beforeEach(() => { vi.clearAllMocks() }) - it('should render float input with slider', () => { - const props = createProps() - const { rerender } = render() - - expect(screen.getByText('Temperature')).toBeInTheDocument() + // Float tests + it('should render float controls and clamp numeric input to max', () => { + const onChange = vi.fn() + render() const input = screen.getByRole('spinbutton') - fireEvent.change(input, { target: { value: '0.8' } }) - expect(props.onChange).toHaveBeenCalledWith(0.8) - fireEvent.change(input, { target: { value: '1.4' } }) - expect(props.onChange).toHaveBeenCalledWith(1) - - fireEvent.change(input, { target: { value: '-0.2' } }) - expect(props.onChange).toHaveBeenCalledWith(0) - - const slider = screen.getByRole('slider') - fireEvent.change(slider, { target: { value: '2' } }) - expect(props.onChange).toHaveBeenCalledWith(1) - - fireEvent.change(slider, { target: { value: '-1' } }) - expect(props.onChange).toHaveBeenCalledWith(0) - - fireEvent.change(slider, { target: { value: '0.4' } }) - expect(props.onChange).toHaveBeenCalledWith(0.4) - - fireEvent.blur(input) - expect(input).toHaveValue(0.7) - - const minBoundedProps = createProps({ - parameterRule: createRule({ type: 'float', min: 1, max: 2 }), - value: 1.5, - }) - rerender() - fireEvent.change(screen.getByRole('slider'), { target: { value: '0' } }) - expect(minBoundedProps.onChange).toHaveBeenCalledWith(1) + expect(onChange).toHaveBeenCalledWith(1) + expect(screen.getByTestId('slider-btn')).toBeInTheDocument() }) - it('should render boolean radio', () => { - const props = createProps({ parameterRule: createRule({ type: 'boolean', default: false }), value: true }) - render() + it('should clamp float numeric input to min', () => { + const onChange = vi.fn() + render() + const input = screen.getByRole('spinbutton') + fireEvent.change(input, { target: { value: '0.05' } }) + expect(onChange).toHaveBeenCalledWith(0.1) + }) + + // Int tests + it('should render int controls and clamp numeric input', () => { + const onChange = vi.fn() + render() + const input = screen.getByRole('spinbutton') + fireEvent.change(input, { target: { value: '15' } }) + expect(onChange).toHaveBeenCalledWith(10) + fireEvent.change(input, { target: { value: '-5' } }) + expect(onChange).toHaveBeenCalledWith(0) + }) + + it('should adjust step based on max for int type', () => { + const { rerender } = render() + expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '1') + + rerender() + expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '10') + + rerender() + expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '100') + }) + + it('should render int input without slider if min or max is missing', () => { + render() + expect(screen.queryByRole('slider')).not.toBeInTheDocument() + // No max -> precision step + expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '0') + }) + + // Slider events (uses generic value mock for slider) + it('should handle slide change and clamp values', () => { + const onChange = vi.fn() + render() + + // Test that the actual slider triggers the onChange logic correctly + // The implementation of Slider uses onChange(val) directly via the mock + fireEvent.click(screen.getByTestId('slider-btn')) + expect(onChange).toHaveBeenCalledWith(2) + }) + + // Text & String tests + it('should render exact string input and propagate text changes', () => { + const onChange = vi.fn() + render() + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'updated' } }) + expect(onChange).toHaveBeenCalledWith('updated') + }) + + it('should render textarea for text type', () => { + const onChange = vi.fn() + const { container } = render() + const textarea = container.querySelector('textarea')! + expect(textarea).toBeInTheDocument() + fireEvent.change(textarea, { target: { value: 'new long text' } }) + expect(onChange).toHaveBeenCalledWith('new long text') + }) + + it('should render select for string with options', () => { + render() + // SimpleSelect renders an element with text 'a' + expect(screen.getByText('a')).toBeInTheDocument() + }) + + // Tag Tests + it('should render tag input for tag type', () => { + const onChange = vi.fn() + render() + expect(screen.getByText('placeholder')).toBeInTheDocument() + // Trigger mock tag input + fireEvent.click(screen.getByTestId('tag-input')) + expect(onChange).toHaveBeenCalledWith(['tag1', 'tag2']) + }) + + // Boolean tests + it('should render boolean radios and update value on click', () => { + const onChange = vi.fn() + render() + fireEvent.click(screen.getByText('False')) + expect(onChange).toHaveBeenCalledWith(false) + }) + + // Switch tests + it('should call onSwitch with current value when optional switch is toggled off', () => { + const onSwitch = vi.fn() + render() + fireEvent.click(screen.getByRole('switch')) + expect(onSwitch).toHaveBeenCalledWith(false, 0.7) + }) + + it('should not render switch if required or name is stop', () => { + const { rerender } = render() + expect(screen.queryByRole('switch')).not.toBeInTheDocument() + rerender() + expect(screen.queryByRole('switch')).not.toBeInTheDocument() + }) + + // Default Value Fallbacks (rendering without value) + it('should use default values if value is undefined', () => { + const { rerender } = render() + expect(screen.getByRole('spinbutton')).toHaveValue(0.5) + + rerender() + expect(screen.getByRole('textbox')).toHaveValue('hello') + + rerender() expect(screen.getByText('True')).toBeInTheDocument() - fireEvent.click(screen.getByText('Select False')) - expect(props.onChange).toHaveBeenCalledWith(false) + expect(screen.getByText('False')).toBeInTheDocument() + + // Without default + rerender() // min is 0 by default in createRule + expect(screen.getByRole('spinbutton')).toHaveValue(0) }) - it('should render string input and select options', () => { - const props = createProps({ parameterRule: createRule({ type: 'string' }), value: 'test' }) - const { rerender } = render() - const input = screen.getByRole('textbox') - fireEvent.change(input, { target: { value: 'new' } }) - expect(props.onChange).toHaveBeenCalledWith('new') - - const selectProps = createProps({ - parameterRule: createRule({ type: 'string', options: ['opt1', 'opt2'] }), - value: 'opt1', - }) - rerender() - const select = screen.getByRole('combobox') - fireEvent.change(select, { target: { value: 'opt2' } }) - expect(selectProps.onChange).toHaveBeenCalledWith('opt2') + // Input Blur + it('should reset input to actual bound value on blur', () => { + render() + const input = screen.getByRole('spinbutton') + // change local state (which triggers clamp internally to let's say 1.4 -> 1 but leaves input text, though handleInputChange updates local state) + // Actually our test fires a change so localValue = 1, then blur sets it + fireEvent.change(input, { target: { value: '5' } }) + fireEvent.blur(input) + expect(input).toHaveValue(1) }) - it('should handle switch toggle', () => { - const props = createProps() - let view = render() - fireEvent.click(screen.getByText('Switch')) - expect(props.onSwitch).toHaveBeenCalledWith(false, 0.7) - - const intDefaultProps = createProps({ - parameterRule: createRule({ type: 'int', min: 0, default: undefined }), - value: undefined, - }) - view.unmount() - view = render() - fireEvent.click(screen.getByText('Switch')) - expect(intDefaultProps.onSwitch).toHaveBeenCalledWith(true, 0) - - const stringDefaultProps = createProps({ - parameterRule: createRule({ type: 'string', default: 'preset-value' }), - value: undefined, - }) - view.unmount() - view = render() - fireEvent.click(screen.getByText('Switch')) - expect(stringDefaultProps.onSwitch).toHaveBeenCalledWith(true, 'preset-value') - - const booleanDefaultProps = createProps({ - parameterRule: createRule({ type: 'boolean', default: true }), - value: undefined, - }) - view.unmount() - view = render() - fireEvent.click(screen.getByText('Switch')) - expect(booleanDefaultProps.onSwitch).toHaveBeenCalledWith(true, true) - - const tagDefaultProps = createProps({ - parameterRule: createRule({ type: 'tag', default: ['one'] }), - value: undefined, - }) - view.unmount() - const tagView = render() - fireEvent.click(screen.getByText('Switch')) - expect(tagDefaultProps.onSwitch).toHaveBeenCalledWith(true, ['one']) - - const zeroValueProps = createProps({ - parameterRule: createRule({ type: 'float', default: 0.5 }), - value: 0, - }) - tagView.unmount() - render() - fireEvent.click(screen.getByText('Switch')) - expect(zeroValueProps.onSwitch).toHaveBeenCalledWith(false, 0) - }) - - it('should support text and tag parameter interactions', () => { - const textProps = createProps({ - parameterRule: createRule({ type: 'text', name: 'prompt' }), - value: 'initial prompt', - }) - const { rerender } = render() - const textarea = screen.getByRole('textbox') - fireEvent.change(textarea, { target: { value: 'rewritten prompt' } }) - expect(textProps.onChange).toHaveBeenCalledWith('rewritten prompt') - - const tagProps = createProps({ - parameterRule: createRule({ - type: 'tag', - name: 'tags', - tagPlaceholder: { en_US: 'Tag hint', zh_Hans: 'Tag hint' }, - }), - value: ['alpha'], - }) - rerender() - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'one,two' } }) - expect(tagProps.onChange).toHaveBeenCalledWith(['one', 'two']) - }) - - it('should support int parameters and unknown type fallback', () => { - const intProps = createProps({ - parameterRule: createRule({ type: 'int', min: 0, max: 500, default: 100 }), - value: 100, - }) - const { rerender } = render() - fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '350' } }) - expect(intProps.onChange).toHaveBeenCalledWith(350) - - const unknownTypeProps = createProps({ - parameterRule: createRule({ type: 'unsupported' }), - value: 0.7, - }) - rerender() + // Unsupported + it('should render no input for unsupported parameter type', () => { + render() expect(screen.queryByRole('textbox')).not.toBeInTheDocument() expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument() }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter.spec.tsx index 04789d163e..cb90bb14c9 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter.spec.tsx @@ -2,19 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { vi } from 'vitest' import PresetsParameter from './presets-parameter' -vi.mock('@/app/components/base/dropdown', () => ({ - default: ({ renderTrigger, items, onSelect }: { renderTrigger: (open: boolean) => React.ReactNode, items: { value: number, text: string }[], onSelect: (item: { value: number }) => void }) => ( -
- {renderTrigger(false)} - {items.map(item => ( - - ))} -
- ), -})) - describe('PresetsParameter', () => { beforeEach(() => { vi.clearAllMocks() @@ -26,7 +13,39 @@ describe('PresetsParameter', () => { expect(screen.getByText('common.modelProvider.loadPresets')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i })) fireEvent.click(screen.getByText('common.model.tone.Creative')) expect(onSelect).toHaveBeenCalledWith(1) }) + + // open=true: trigger has bg-state-base-hover class + it('should apply hover background class when open is true', () => { + render() + fireEvent.click(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i })) + + const button = screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i }) + expect(button).toHaveClass('bg-state-base-hover') + }) + + // Tone map branch 2: Balanced → Scales02 icon + it('should call onSelect with tone id 2 when Balanced is clicked', () => { + const onSelect = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i })) + fireEvent.click(screen.getByText('common.model.tone.Balanced')) + + expect(onSelect).toHaveBeenCalledWith(2) + }) + + // Tone map branch 3: Precise → Target04 icon + it('should call onSelect with tone id 3 when Precise is clicked', () => { + const onSelect = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i })) + fireEvent.click(screen.getByText('common.model.tone.Precise')) + + expect(onSelect).toHaveBeenCalledWith(3) + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.spec.tsx index a5b6e490af..620ad7f818 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.spec.tsx @@ -1,4 +1,5 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { vi } from 'vitest' import StatusIndicators from './status-indicators' @@ -8,10 +9,6 @@ vi.mock('@/service/use-plugins', () => ({ useInstalledPluginList: () => ({ data: { plugins: installedPlugins } }), })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ popupContent }: { popupContent: React.ReactNode }) =>
{popupContent}
, -})) - vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version', () => ({ SwitchPluginVersion: ({ uniqueIdentifier }: { uniqueIdentifier: string }) =>
{`SwitchVersion:${uniqueIdentifier}`}
, })) @@ -38,57 +35,95 @@ describe('StatusIndicators', () => { expect(container).toBeEmptyDOMElement() }) - it('should render warning states when provider model is disabled', () => { - const parentClick = vi.fn() - const { rerender } = render( -
- -
, + it('should render deprecated tooltip when provider model is disabled and in model list', async () => { + const user = userEvent.setup() + const { container } = render( + , ) - expect(screen.getByText('nodes.agent.modelSelectorTooltips.deprecated')).toBeInTheDocument() - rerender( -
- -
, - ) - expect(screen.getByText('nodes.agent.modelNotSupport.title')).toBeInTheDocument() - expect(screen.getByText('nodes.agent.linkToPlugin').closest('a')).toHaveAttribute('href', '/plugins') - fireEvent.click(screen.getByText('nodes.agent.modelNotSupport.title')) - fireEvent.click(screen.getByText('nodes.agent.linkToPlugin')) - expect(parentClick).not.toHaveBeenCalled() + const trigger = container.querySelector('[data-state]') + expect(trigger).toBeInTheDocument() + await user.hover(trigger as HTMLElement) - rerender( -
- -
, + expect(await screen.findByText('nodes.agent.modelSelectorTooltips.deprecated')).toBeInTheDocument() + }) + + it('should render model-not-support tooltip when disabled model is not in model list and has no pluginInfo', async () => { + const user = userEvent.setup() + const { container } = render( + , ) + + const trigger = container.querySelector('[data-state]') + expect(trigger).toBeInTheDocument() + await user.hover(trigger as HTMLElement) + + expect(await screen.findByText('nodes.agent.modelNotSupport.title')).toBeInTheDocument() + }) + + it('should render switch plugin version when pluginInfo exists for disabled unsupported model', () => { + render( + , + ) + expect(screen.getByText('SwitchVersion:demo@1.0.0')).toBeInTheDocument() }) - it('should render marketplace warning when provider is unavailable', () => { + it('should render nothing when needsConfiguration is true even with disabled and modelProvider', () => { + const { container } = render( + , + ) + expect(container).toBeEmptyDOMElement() + }) + + it('should render SwitchVersion with empty identifier when plugin is not in installed list', () => { + installedPlugins = [] + render( + , + ) + + expect(screen.getByText('SwitchVersion:')).toBeInTheDocument() + }) + + it('should render marketplace warning tooltip when provider is unavailable', async () => { + const user = userEvent.setup() + const { container } = render( { t={t} />, ) - expect(screen.getByText('nodes.agent.modelNotInMarketplace.title')).toBeInTheDocument() + + const trigger = container.querySelector('[data-state]') + expect(trigger).toBeInTheDocument() + await user.hover(trigger as HTMLElement) + + expect(await screen.findByText('nodes.agent.modelNotInMarketplace.title')).toBeInTheDocument() }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger.spec.tsx index 5e22309a33..8a3484cc1f 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger.spec.tsx @@ -1,5 +1,6 @@ import type { ComponentProps } from 'react' import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import Trigger from './trigger' vi.mock('../hooks', () => ({ @@ -24,6 +25,10 @@ describe('Trigger', () => { const currentProvider = { provider: 'openai', label: { en_US: 'OpenAI' } } as unknown as ComponentProps['currentProvider'] const currentModel = { model: 'gpt-4' } as unknown as ComponentProps['currentModel'] + beforeEach(() => { + vi.clearAllMocks() + }) + it('should render initialized state', () => { render( { ) expect(screen.getByText('gpt-4')).toBeInTheDocument() }) + + // isInWorkflow=true: workflow border class + RiArrowDownSLine arrow + it('should render workflow styles when isInWorkflow is true', () => { + // Act + const { container } = render( + , + ) + + // Assert + expect(container.firstChild).toHaveClass('border-workflow-block-parma-bg') + expect(container.firstChild).toHaveClass('bg-workflow-block-parma-bg') + expect(container.querySelectorAll('svg').length).toBe(2) + }) + + // disabled=true + hasDeprecated=true: AlertTriangle + deprecated tooltip + it('should show deprecated warning when disabled with hasDeprecated', () => { + // Act + render( + , + ) + + // Assert - AlertTriangle renders with warning color + const warningIcon = document.querySelector('.text-\\[\\#F79009\\]') + expect(warningIcon).toBeInTheDocument() + }) + + // disabled=true + modelDisabled=true: status text tooltip + it('should show model status tooltip when disabled with modelDisabled', () => { + // Act + render( + , + ) + + // Assert - AlertTriangle warning icon should be present + const warningIcon = document.querySelector('.text-\\[\\#F79009\\]') + expect(warningIcon).toBeInTheDocument() + }) + + it('should render empty tooltip content when disabled without deprecated or modelDisabled', async () => { + const user = userEvent.setup() + const { container } = render( + , + ) + const warningIcon = document.querySelector('.text-\\[\\#F79009\\]') + expect(warningIcon).toBeInTheDocument() + const trigger = container.querySelector('[data-state]') + expect(trigger).toBeInTheDocument() + await user.hover(trigger as HTMLElement) + const tooltip = screen.queryByRole('tooltip') + if (tooltip) + expect(tooltip).toBeEmptyDOMElement() + expect(screen.queryByText('modelProvider.deprecated')).not.toBeInTheDocument() + expect(screen.queryByText('No Configure')).not.toBeInTheDocument() + }) + + // providerName not matching any provider: find() returns undefined + it('should render without crashing when providerName does not match any provider', () => { + // Act + render( + , + ) + + // Assert + expect(screen.getByText('gpt-4')).toBeInTheDocument() + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/empty-trigger.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/empty-trigger.spec.tsx index 0c35e87ebe..9a7b9a2c3f 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/empty-trigger.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/empty-trigger.spec.tsx @@ -10,4 +10,22 @@ describe('EmptyTrigger', () => { render() expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument() }) + + // open=true: hover bg class present + it('should apply hover background class when open is true', () => { + // Act + const { container } = render() + + // Assert + expect(container.firstChild).toHaveClass('bg-components-input-bg-hover') + }) + + // className prop truthy: custom className appears on root + it('should apply custom className when provided', () => { + // Act + const { container } = render() + + // Assert + expect(container.firstChild).toHaveClass('custom-class') + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.spec.tsx index af398f83ba..ba2a4a1471 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.spec.tsx @@ -10,12 +10,13 @@ import PopupItem from './popup-item' const mockUpdateModelList = vi.hoisted(() => vi.fn()) const mockUpdateModelProviders = vi.hoisted(() => vi.fn()) +const mockLanguageRef = vi.hoisted(() => ({ value: 'en_US' })) vi.mock('../hooks', async () => { const actual = await vi.importActual('../hooks') return { ...actual, - useLanguage: () => 'en_US', + useLanguage: () => mockLanguageRef.value, useUpdateModelList: () => mockUpdateModelList, useUpdateModelProviders: () => mockUpdateModelProviders, } @@ -69,6 +70,7 @@ const makeModel = (overrides: Partial = {}): Model => ({ describe('PopupItem', () => { beforeEach(() => { vi.clearAllMocks() + mockLanguageRef.value = 'en_US' mockUseProviderContext.mockReturnValue({ modelProviders: [{ provider: 'openai' }], }) @@ -144,4 +146,87 @@ describe('PopupItem', () => { expect(screen.getByText('GPT-4')).toBeInTheDocument() }) + + it('should not show check icon when model matches but provider does not', () => { + const defaultModel: DefaultModel = { provider: 'anthropic', model: 'gpt-4' } + render( + , + ) + + const checkIcons = document.querySelectorAll('.h-4.w-4.shrink-0.text-text-accent') + expect(checkIcons.length).toBe(0) + }) + + it('should not show mode badge when model_properties.mode is absent', () => { + const modelItem = makeModelItem({ model_properties: {} }) + render( + , + ) + + expect(screen.queryByText('CHAT')).not.toBeInTheDocument() + }) + + it('should fall back to en_US label when current locale translation is empty', () => { + mockLanguageRef.value = 'zh_Hans' + const model = makeModel({ + label: { en_US: 'English Label', zh_Hans: '' }, + }) + render() + + expect(screen.getByText('English Label')).toBeInTheDocument() + }) + + it('should not show context_size badge when absent', () => { + const modelItem = makeModelItem({ model_properties: { mode: 'chat' } }) + render( + , + ) + + expect(screen.queryByText(/K$/)).not.toBeInTheDocument() + }) + + it('should not show capabilities section when features are empty', () => { + const modelItem = makeModelItem({ features: [] }) + render( + , + ) + + expect(screen.queryByText('common.model.capabilities')).not.toBeInTheDocument() + }) + + it('should not show capabilities for non-qualifying model types', () => { + const modelItem = makeModelItem({ + model_type: ModelTypeEnum.tts, + features: [ModelFeatureEnum.vision], + }) + render( + , + ) + + expect(screen.queryByText('common.model.capabilities')).not.toBeInTheDocument() + }) + + it('should show en_US label when language is fr_FR and fr_FR key is absent', () => { + mockLanguageRef.value = 'fr_FR' + const model = makeModel({ label: { en_US: 'FallbackLabel', zh_Hans: 'FallbackLabel' } }) + render() + + expect(screen.getByText('FallbackLabel')).toBeInTheDocument() + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.spec.tsx index 4083f4a37c..02920026f4 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.spec.tsx @@ -1,5 +1,6 @@ import type { Model, ModelItem } from '../declarations' import { fireEvent, render, screen } from '@testing-library/react' +import { tooltipManager } from '@/app/components/base/tooltip/TooltipManager' import { ConfigurationMethodEnum, ModelFeatureEnum, @@ -22,21 +23,6 @@ vi.mock('@/utils/tool-call', () => ({ supportFunctionCall: mockSupportFunctionCall, })) -const mockCloseActiveTooltip = vi.hoisted(() => vi.fn()) -vi.mock('@/app/components/base/tooltip/TooltipManager', () => ({ - tooltipManager: { - closeActiveTooltip: mockCloseActiveTooltip, - register: vi.fn(), - clear: vi.fn(), - }, -})) - -vi.mock('@/app/components/base/icons/src/vender/solid/general', () => ({ - XCircle: ({ onClick }: { onClick?: () => void }) => ( -