From 42af9d5438146d30f5c780d5717fa883220e8206 Mon Sep 17 00:00:00 2001 From: akashseth-ifp Date: Mon, 23 Feb 2026 17:36:35 +0530 Subject: [PATCH 01/26] test(web): add members-page account-setting specs and improve coverage (#32311) --- .../year-and-month-picker/options.spec.tsx | 6 +- .../edit-workspace-modal/index.spec.tsx | 103 ++++++++++ .../members-page/index.spec.tsx | 194 ++++++++++++++++++ .../members-page/invite-button.spec.tsx | 71 +++++++ .../members-page/invite-modal/index.spec.tsx | 118 +++++++++++ .../invite-modal/role-selector.spec.tsx | 61 ++++++ .../members-page/invited-modal/index.spec.tsx | 24 +++ .../invited-modal/invitation-link.spec.tsx | 30 +++ .../members-page/operation/index.spec.tsx | 39 +++- .../operation/transfer-ownership.spec.tsx | 89 ++++++++ .../transfer-ownership-modal/index.spec.tsx | 149 ++++++++++++++ .../member-selector.spec.tsx | 107 ++++++++++ 12 files changed, 982 insertions(+), 9 deletions(-) create mode 100644 web/app/components/header/account-setting/members-page/edit-workspace-modal/index.spec.tsx create mode 100644 web/app/components/header/account-setting/members-page/index.spec.tsx create mode 100644 web/app/components/header/account-setting/members-page/invite-button.spec.tsx create mode 100644 web/app/components/header/account-setting/members-page/invite-modal/index.spec.tsx create mode 100644 web/app/components/header/account-setting/members-page/invite-modal/role-selector.spec.tsx create mode 100644 web/app/components/header/account-setting/members-page/invited-modal/index.spec.tsx create mode 100644 web/app/components/header/account-setting/members-page/invited-modal/invitation-link.spec.tsx create mode 100644 web/app/components/header/account-setting/members-page/operation/transfer-ownership.spec.tsx create mode 100644 web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.spec.tsx create mode 100644 web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.spec.tsx diff --git a/web/app/components/base/date-and-time-picker/year-and-month-picker/options.spec.tsx b/web/app/components/base/date-and-time-picker/year-and-month-picker/options.spec.tsx index d4319650f5..2ca448fed0 100644 --- a/web/app/components/base/date-and-time-picker/year-and-month-picker/options.spec.tsx +++ b/web/app/components/base/date-and-time-picker/year-and-month-picker/options.spec.tsx @@ -32,10 +32,10 @@ describe('YearAndMonthPicker Options', () => { it('should render year options', () => { const props = createOptionsProps() - render() + const { container } = render() - const allItems = screen.getAllByRole('listitem') - expect(allItems).toHaveLength(212) + const yearList = container.querySelectorAll('ul')[1] + expect(yearList?.children).toHaveLength(200) }) }) diff --git a/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.spec.tsx b/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.spec.tsx new file mode 100644 index 0000000000..791ca0362e --- /dev/null +++ b/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.spec.tsx @@ -0,0 +1,103 @@ +import type { AppContextValue } from '@/context/app-context' +import type { ICurrentWorkspace } from '@/models/common' +import { 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' +import { useAppContext } from '@/context/app-context' +import { updateWorkspaceInfo } from '@/service/common' +import EditWorkspaceModal from './index' + +vi.mock('@/context/app-context') +vi.mock('@/service/common') + +describe('EditWorkspaceModal', () => { + const mockOnCancel = vi.fn() + const mockNotify = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(useAppContext).mockReturnValue({ + currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace, + isCurrentWorkspaceOwner: true, + } as unknown as AppContextValue) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + const renderModal = () => render( + + + , + ) + + it('should show current workspace name in the input', async () => { + renderModal() + + expect(await screen.findByDisplayValue('Test Workspace')).toBeInTheDocument() + }) + + it('should let user edit workspace name', async () => { + const user = userEvent.setup() + + renderModal() + + const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i) + await user.clear(input) + await user.type(input, 'New Workspace Name') + + expect(input).toHaveValue('New Workspace Name') + }) + + it('should submit update when confirming as owner', async () => { + const user = userEvent.setup() + const mockAssign = vi.fn() + vi.stubGlobal('location', { ...window.location, assign: mockAssign }) + vi.mocked(updateWorkspaceInfo).mockResolvedValue({} as ICurrentWorkspace) + + renderModal() + + const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i) + await user.clear(input) + await user.type(input, 'Renamed Workspace') + await user.click(screen.getByRole('button', { name: /operation\.confirm/i })) + + await waitFor(() => { + expect(updateWorkspaceInfo).toHaveBeenCalledWith({ + url: '/workspaces/info', + body: { name: 'Renamed Workspace' }, + }) + expect(mockAssign).toHaveBeenCalled() + }) + }) + + it('should show error toast when update fails', async () => { + const user = userEvent.setup() + + vi.mocked(updateWorkspaceInfo).mockRejectedValue(new Error('update failed')) + + renderModal() + + await user.click(screen.getByRole('button', { name: /operation\.confirm/i })) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + }) + + it('should disable confirm button for non-owners', async () => { + vi.mocked(useAppContext).mockReturnValue({ + currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace, + isCurrentWorkspaceOwner: false, + } as unknown as AppContextValue) + + renderModal() + + expect(await screen.findByRole('button', { name: /operation\.confirm/i })).toBeDisabled() + }) +}) diff --git a/web/app/components/header/account-setting/members-page/index.spec.tsx b/web/app/components/header/account-setting/members-page/index.spec.tsx new file mode 100644 index 0000000000..211c44444a --- /dev/null +++ b/web/app/components/header/account-setting/members-page/index.spec.tsx @@ -0,0 +1,194 @@ +import type { AppContextValue } from '@/context/app-context' +import type { ICurrentWorkspace, Member } from '@/models/common' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { vi } from 'vitest' +import { createMockProviderContextValue } from '@/__mocks__/provider-context' +import { useAppContext } from '@/context/app-context' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useProviderContext } from '@/context/provider-context' +import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' +import { useMembers } from '@/service/use-common' +import MembersPage from './index' + +vi.mock('@/context/app-context') +vi.mock('@/context/global-public-context') +vi.mock('@/context/provider-context') +vi.mock('@/hooks/use-format-time-from-now') +vi.mock('@/service/use-common') + +vi.mock('./edit-workspace-modal', () => ({ + default: ({ onCancel }: { onCancel: () => void }) => ( +
+
Edit Workspace Modal
+ +
+ ), +})) +vi.mock('./invite-button', () => ({ + default: ({ onClick, disabled }: { onClick: () => void, disabled: boolean }) => ( + + ), +})) +vi.mock('./invite-modal', () => ({ + default: ({ onCancel, onSend }: { onCancel: () => void, onSend: (results: Array<{ email: string, status: 'success', url: string }>) => void }) => ( +
+
Invite Modal
+ + +
+ ), +})) +vi.mock('./invited-modal', () => ({ + default: ({ onCancel }: { onCancel: () => void }) => ( +
+
Invited Modal
+ +
+ ), +})) +vi.mock('./operation', () => ({ + default: () =>
Member Operation
, +})) +vi.mock('./operation/transfer-ownership', () => ({ + default: ({ onOperate }: { onOperate: () => void }) => , +})) +vi.mock('./transfer-ownership-modal', () => ({ + default: ({ onClose }: { onClose: () => void }) => ( +
+
Transfer Ownership Modal
+ +
+ ), +})) + +describe('MembersPage', () => { + const mockRefetch = vi.fn() + const mockFormatTimeFromNow = vi.fn(() => 'just now') + + const mockAccounts: Member[] = [ + { + id: '1', + name: 'Owner User', + email: 'owner@example.com', + avatar: '', + avatar_url: '', + role: 'owner', + last_active_at: '1731000000', + last_login_at: '1731000000', + created_at: '1731000000', + status: 'active', + }, + { + id: '2', + name: 'Admin User', + email: 'admin@example.com', + avatar: '', + avatar_url: '', + role: 'admin', + last_active_at: '1731000000', + last_login_at: '1731000000', + created_at: '1731000000', + status: 'active', + }, + ] + + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(useAppContext).mockReturnValue({ + userProfile: { email: 'owner@example.com' }, + currentWorkspace: { name: 'Test Workspace', role: 'owner' } as ICurrentWorkspace, + isCurrentWorkspaceOwner: true, + isCurrentWorkspaceManager: true, + } as unknown as AppContextValue) + + vi.mocked(useMembers).mockReturnValue({ + data: { accounts: mockAccounts }, + refetch: mockRefetch, + } as unknown as ReturnType) + + vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({ + systemFeatures: { is_email_setup: true }, + } as unknown as Parameters[0])) + + vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({ + enableBilling: false, + isAllowTransferWorkspace: true, + })) + + vi.mocked(useFormatTimeFromNow).mockReturnValue({ + formatTimeFromNow: mockFormatTimeFromNow, + }) + }) + + it('should render workspace and member information', () => { + render() + + expect(screen.getByText('Test Workspace')).toBeInTheDocument() + expect(screen.getByText('Owner User')).toBeInTheDocument() + expect(screen.getByText('Admin User')).toBeInTheDocument() + }) + + it('should open and close invite modal', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole('button', { name: /invite/i })) + expect(screen.getByText('Invite Modal')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Close Invite Modal' })) + expect(screen.queryByText('Invite Modal')).not.toBeInTheDocument() + }) + + it('should open invited modal after invite results are sent', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole('button', { name: /invite/i })) + await user.click(screen.getByRole('button', { name: 'Send Invite Results' })) + + expect(screen.getByText('Invited Modal')).toBeInTheDocument() + expect(mockRefetch).toHaveBeenCalled() + + await user.click(screen.getByRole('button', { name: 'Close Invited Modal' })) + expect(screen.queryByText('Invited Modal')).not.toBeInTheDocument() + }) + + it('should open transfer ownership modal when transfer action is used', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole('button', { name: /transfer ownership/i })) + expect(screen.getByText('Transfer Ownership Modal')).toBeInTheDocument() + }) + + it('should show non-interactive owner role when transfer ownership is not allowed', () => { + vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({ + enableBilling: false, + isAllowTransferWorkspace: false, + })) + + render() + + expect(screen.getByText('common.members.owner')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /transfer ownership/i })).not.toBeInTheDocument() + }) + + it('should hide manager controls for non-owner non-manager users', () => { + 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) + + render() + + expect(screen.queryByRole('button', { name: /invite/i })).not.toBeInTheDocument() + expect(screen.queryByText('Transfer ownership')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/members-page/invite-button.spec.tsx b/web/app/components/header/account-setting/members-page/invite-button.spec.tsx new file mode 100644 index 0000000000..7388c7ef3b --- /dev/null +++ b/web/app/components/header/account-setting/members-page/invite-button.spec.tsx @@ -0,0 +1,71 @@ +import type { AppContextValue } from '@/context/app-context' +import type { ICurrentWorkspace } from '@/models/common' +import { render, screen } from '@testing-library/react' +import { vi } from 'vitest' +import { useAppContext } from '@/context/app-context' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useWorkspacePermissions } from '@/service/use-workspace' +import InviteButton from './invite-button' + +vi.mock('@/context/app-context') +vi.mock('@/context/global-public-context') +vi.mock('@/service/use-workspace') + +describe('InviteButton', () => { + const setupMocks = ({ + brandingEnabled, + isFetching, + allowInvite, + }: { + brandingEnabled: boolean + isFetching: boolean + allowInvite?: boolean + }) => { + vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({ + systemFeatures: { branding: { enabled: brandingEnabled } }, + } as unknown as Parameters[0])) + vi.mocked(useWorkspacePermissions).mockReturnValue({ + data: allowInvite === undefined ? null : { allow_member_invite: allowInvite }, + isFetching, + } as unknown as ReturnType) + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useAppContext).mockReturnValue({ + currentWorkspace: { id: 'workspace-id' } as ICurrentWorkspace, + } as unknown as AppContextValue) + }) + + it('should show invite button when branding is disabled', () => { + setupMocks({ brandingEnabled: false, isFetching: false }) + + render() + + expect(screen.getByRole('button', { name: /members\.invite/i })).toBeInTheDocument() + }) + + it('should show loading status while permissions are loading', () => { + setupMocks({ brandingEnabled: true, isFetching: true }) + + render() + + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('should hide invite button when permission is denied', () => { + setupMocks({ brandingEnabled: true, isFetching: false, allowInvite: false }) + + render() + + expect(screen.queryByRole('button', { name: /members\.invite/i })).not.toBeInTheDocument() + }) + + it('should show invite button when permission is granted', () => { + setupMocks({ brandingEnabled: true, isFetching: false, allowInvite: true }) + + render() + + expect(screen.getByRole('button', { name: /members\.invite/i })).toBeInTheDocument() + }) +}) 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 new file mode 100644 index 0000000000..fc733d9cd7 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/invite-modal/index.spec.tsx @@ -0,0 +1,118 @@ +import type { InvitationResponse } from '@/models/common' +import { 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' +import { useProviderContextSelector } from '@/context/provider-context' +import { inviteMember } from '@/service/common' +import InviteModal from './index' + +vi.mock('@/context/provider-context', () => ({ + useProviderContextSelector: vi.fn(), + useProviderContext: vi.fn(() => ({ + datasetOperatorEnabled: true, + })), +})) +vi.mock('@/service/common') +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'en-US', +})) + +describe('InviteModal', () => { + const mockOnCancel = vi.fn() + const mockOnSend = vi.fn() + const mockRefreshLicenseLimit = vi.fn() + const mockNotify = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({ + licenseLimit: { workspace_members: { size: 5, limit: 10 } }, + refreshLicenseLimit: mockRefreshLicenseLimit, + } as unknown as Parameters[0])) + }) + + const renderModal = (isEmailSetup = true) => render( + + + , + ) + + it('should render invite modal content', async () => { + renderModal() + + expect(await screen.findByText(/members\.inviteTeamMember$/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled() + }) + + it('should show warning when email service is not configured', async () => { + renderModal(false) + + expect(await screen.findByText(/members\.emailNotSetup$/i)).toBeInTheDocument() + }) + + it('should enable send button after entering an email', async () => { + const user = userEvent.setup() + + renderModal() + + const input = screen.getByRole('textbox') + await user.type(input, 'user@example.com{enter}') + + expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeEnabled() + }) + + it('should not close modal when invite request fails', async () => { + const user = userEvent.setup() + vi.mocked(inviteMember).mockRejectedValue(new Error('request failed')) + + renderModal() + + await user.type(screen.getByRole('textbox'), 'user@example.com{enter}') + await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) + + await waitFor(() => { + expect(inviteMember).toHaveBeenCalled() + expect(mockOnCancel).not.toHaveBeenCalled() + expect(mockOnSend).not.toHaveBeenCalled() + }) + }) + + it('should send invites and close modal on successful submission', async () => { + const user = userEvent.setup() + vi.mocked(inviteMember).mockResolvedValue({ + result: 'success', + invitation_results: [], + } as InvitationResponse) + + renderModal() + + const input = screen.getByRole('textbox') + await user.type(input, 'user@example.com{enter}') + await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) + + await waitFor(() => { + expect(inviteMember).toHaveBeenCalled() + expect(mockRefreshLicenseLimit).toHaveBeenCalled() + expect(mockOnCancel).toHaveBeenCalled() + expect(mockOnSend).toHaveBeenCalled() + }) + }) + + it('should keep send button disabled when license limit is exceeded', 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.getByRole('textbox') + await user.type(input, 'user@example.com{enter}') + + expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled() + }) +}) diff --git a/web/app/components/header/account-setting/members-page/invite-modal/role-selector.spec.tsx b/web/app/components/header/account-setting/members-page/invite-modal/role-selector.spec.tsx new file mode 100644 index 0000000000..3c7a496a74 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/invite-modal/role-selector.spec.tsx @@ -0,0 +1,61 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { vi } from 'vitest' +import { useProviderContext } from '@/context/provider-context' +import RoleSelector from './role-selector' + +vi.mock('@/context/provider-context') + +type WrapperProps = { + initialRole?: 'normal' | 'editor' | 'admin' | 'dataset_operator' +} + +const RoleSelectorWrapper = ({ initialRole = 'normal' }: WrapperProps) => { + const [role, setRole] = useState<'normal' | 'editor' | 'admin' | 'dataset_operator'>(initialRole) + return +} + +describe('RoleSelector', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useProviderContext).mockReturnValue({ + datasetOperatorEnabled: true, + } as unknown as ReturnType) + }) + + it('should show current role in trigger text', () => { + render() + + expect(screen.getByText(/members\.invitedAsRole/i)).toBeInTheDocument() + }) + + it.each([ + 'common.members.admin', + 'common.members.editor', + 'common.members.datasetOperator', + ])('should update selected role after user chooses %s', async (nextRoleLabel) => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByText(/members\.invitedAsRole/i)) + await user.click(screen.getByText(nextRoleLabel)) + + expect(screen.getByText(new RegExp(nextRoleLabel.replace('.', '\\.'), 'i'))).toBeInTheDocument() + }) + + it('should hide dataset operator option when feature is disabled', async () => { + const user = userEvent.setup() + + vi.mocked(useProviderContext).mockReturnValue({ + datasetOperatorEnabled: false, + } as unknown as ReturnType) + + render() + + await user.click(screen.getByText(/members\.invitedAsRole/i)) + + expect(screen.queryByText('common.members.datasetOperator')).not.toBeInTheDocument() + }) +}) 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 new file mode 100644 index 0000000000..127c33a29f --- /dev/null +++ b/web/app/components/header/account-setting/members-page/invited-modal/index.spec.tsx @@ -0,0 +1,24 @@ +import type { InvitationResult } from '@/models/common' +import { render, screen } from '@testing-library/react' +import InvitedModal from './index' + +vi.mock('@/config', () => ({ + IS_CE_EDITION: true, +})) + +describe('InvitedModal', () => { + const mockOnCancel = vi.fn() + const results: InvitationResult[] = [ + { email: 'success@example.com', status: 'success', url: 'http://invite.com/1' }, + { email: 'failed@example.com', status: 'failed', message: 'Error msg' }, + ] + + it('should show success and failed invitation sections', async () => { + render() + + expect(await screen.findByText(/members\.invitationSent$/i)).toBeInTheDocument() + expect(await screen.findByText(/members\.invitationLink/i)).toBeInTheDocument() + expect(screen.getByText('http://invite.com/1')).toBeInTheDocument() + expect(screen.getByText('failed@example.com')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/members-page/invited-modal/invitation-link.spec.tsx b/web/app/components/header/account-setting/members-page/invited-modal/invitation-link.spec.tsx new file mode 100644 index 0000000000..4a2dbb54e7 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/invited-modal/invitation-link.spec.tsx @@ -0,0 +1,30 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import InvitationLink from './invitation-link' + +describe('InvitationLink', () => { + const value = { email: 'test@example.com', status: 'success' as const, url: '/invite/123' } + + it('should render invitation url and keep it visible after click', async () => { + const user = userEvent.setup() + + render() + + const url = screen.getByText('/invite/123') + await user.click(url) + + expect(url).toBeInTheDocument() + }) + + it('should keep link visible after copy feedback timeout passes', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByText('/invite/123')) + + await waitFor(() => { + expect(screen.getByText('/invite/123')).toBeInTheDocument() + }, { timeout: 1500 }) + }) +}) 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 fbe3959a0f..661b2fbc83 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 @@ -1,5 +1,6 @@ import type { Member } from '@/models/common' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { 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' import Operation from './index' @@ -55,20 +56,45 @@ describe('Operation', () => { }) it('shows dataset operator option when the feature flag is enabled', async () => { + const user = userEvent.setup() + mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true }) renderOperation() - fireEvent.click(screen.getByText('common.members.editor')) + await user.click(screen.getByText('common.members.editor')) expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument() }) + it('shows owner-allowed role options for admin operators', async () => { + const user = userEvent.setup() + + renderOperation({}, 'admin') + + await user.click(screen.getByText('common.members.editor')) + + expect(screen.queryByText('common.members.admin')).not.toBeInTheDocument() + expect(screen.getByText('common.members.normal')).toBeInTheDocument() + }) + + it('does not show role options for unsupported operators', async () => { + const user = userEvent.setup() + + renderOperation({}, 'normal') + + await user.click(screen.getByText('common.members.editor')) + + expect(screen.queryByText('common.members.normal')).not.toBeInTheDocument() + expect(screen.getByText('common.members.removeFromTeam')).toBeInTheDocument() + }) + it('calls updateMemberRole and onOperate when selecting another role', async () => { + const user = userEvent.setup() const onOperate = vi.fn() renderOperation({}, 'owner', onOperate) - fireEvent.click(screen.getByText('common.members.editor')) - fireEvent.click(await screen.findByText('common.members.normal')) + await user.click(screen.getByText('common.members.editor')) + await user.click(await screen.findByText('common.members.normal')) await waitFor(() => { expect(mockUpdateMemberRole).toHaveBeenCalled() @@ -77,11 +103,12 @@ describe('Operation', () => { }) it('calls deleteMemberOrCancelInvitation when removing the member', async () => { + const user = userEvent.setup() const onOperate = vi.fn() renderOperation({}, 'owner', onOperate) - fireEvent.click(screen.getByText('common.members.editor')) - fireEvent.click(await screen.findByText('common.members.removeFromTeam')) + await user.click(screen.getByText('common.members.editor')) + await user.click(await screen.findByText('common.members.removeFromTeam')) await waitFor(() => { expect(mockDeleteMemberOrCancelInvitation).toHaveBeenCalled() diff --git a/web/app/components/header/account-setting/members-page/operation/transfer-ownership.spec.tsx b/web/app/components/header/account-setting/members-page/operation/transfer-ownership.spec.tsx new file mode 100644 index 0000000000..74f86d601d --- /dev/null +++ b/web/app/components/header/account-setting/members-page/operation/transfer-ownership.spec.tsx @@ -0,0 +1,89 @@ +import type { AppContextValue } from '@/context/app-context' +import type { ICurrentWorkspace } from '@/models/common' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { vi } from 'vitest' +import { useAppContext } from '@/context/app-context' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useWorkspacePermissions } from '@/service/use-workspace' +import TransferOwnership from './transfer-ownership' + +vi.mock('@/context/app-context') +vi.mock('@/context/global-public-context') +vi.mock('@/service/use-workspace') + +describe('TransferOwnership', () => { + const setupMocks = ({ + brandingEnabled, + isFetching, + allowOwnerTransfer, + }: { + brandingEnabled: boolean + isFetching: boolean + allowOwnerTransfer?: boolean + }) => { + vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({ + systemFeatures: { branding: { enabled: brandingEnabled } }, + } as unknown as Parameters[0])) + vi.mocked(useWorkspacePermissions).mockReturnValue({ + data: allowOwnerTransfer === undefined ? null : { allow_owner_transfer: allowOwnerTransfer }, + isFetching, + } as unknown as ReturnType) + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useAppContext).mockReturnValue({ + currentWorkspace: { id: 'workspace-id' } as ICurrentWorkspace, + } as unknown as AppContextValue) + }) + + it('should show loading status while permissions are loading', () => { + setupMocks({ brandingEnabled: true, isFetching: true }) + + render() + + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('should show owner text without transfer menu when transfer is forbidden', () => { + setupMocks({ brandingEnabled: true, isFetching: false, allowOwnerTransfer: false }) + + render() + + expect(screen.getByText(/members\.owner/i)).toBeInTheDocument() + expect(screen.queryByText(/members\.transferOwnership/i)).toBeNull() + }) + + it('should open transfer dialog when transfer option is selected', async () => { + const user = userEvent.setup() + const onOperate = vi.fn() + + setupMocks({ brandingEnabled: true, isFetching: false, allowOwnerTransfer: true }) + + render() + + await user.click(screen.getByRole('button', { name: /members\.owner/i })) + const transferOptionText = await screen.findByText(/members\.transferOwnership/i) + const transferOption = transferOptionText.closest('div.cursor-pointer') + if (!transferOption) + throw new Error('Transfer option container not found') + fireEvent.click(transferOption) + + await waitFor(() => { + expect(onOperate).toHaveBeenCalledTimes(1) + }) + }) + + it('should allow transfer menu when branding is disabled', async () => { + const user = userEvent.setup() + + setupMocks({ brandingEnabled: false, isFetching: false }) + + render() + + await user.click(screen.getByRole('button', { name: /members\.owner/i })) + + expect(screen.getByText(/members\.transferOwnership/i)).toBeInTheDocument() + }) +}) 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 new file mode 100644 index 0000000000..d2ef1a6af7 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.spec.tsx @@ -0,0 +1,149 @@ +import type { AppContextValue } from '@/context/app-context' +import type { ICurrentWorkspace } from '@/models/common' +import { 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' +import { useAppContext } from '@/context/app-context' +import { ownershipTransfer, sendOwnerEmail, verifyOwnerEmail } from '@/service/common' +import TransferOwnershipModal from './index' + +vi.mock('@/context/app-context') +vi.mock('@/service/common') + +vi.mock('./member-selector', () => ({ + default: ({ onSelect }: { onSelect: (id: string) => void }) => ( + + ), +})) + +describe('TransferOwnershipModal', () => { + const mockOnClose = vi.fn() + const mockNotify = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(globalThis, 'setInterval').mockImplementation(() => 0 as unknown as ReturnType) + vi.spyOn(globalThis, 'clearInterval').mockImplementation(() => {}) + + vi.mocked(useAppContext).mockReturnValue({ + currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace, + userProfile: { email: 'owner@example.com', id: 'owner-id' }, + } as unknown as AppContextValue) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + const renderModal = () => render( + + + , + ) + + const mockEmailVerification = ({ + isValid = true, + token = 'final-token', + }: { + isValid?: boolean + token?: string + } = {}) => { + vi.mocked(sendOwnerEmail).mockResolvedValue({ + data: 'step-token', + result: 'success', + } as Awaited>) + vi.mocked(verifyOwnerEmail).mockResolvedValue({ + is_valid: isValid, + token, + result: 'success', + } as Awaited>) + } + + const goToTransferStep = async (user: ReturnType) => { + await user.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i })) + await user.type(screen.getByPlaceholderText(/members\.transferModal\.codePlaceholder/i), '123456') + await user.click(screen.getByRole('button', { name: /members\.transferModal\.continue/i })) + } + + const selectNewOwnerAndSubmit = async (user: ReturnType) => { + await user.click(screen.getByRole('button', { name: /select member/i })) + await user.click(screen.getByRole('button', { name: /members\.transferModal\.transfer$/i })) + } + + it('should complete ownership transfer flow through all steps', async () => { + const user = userEvent.setup() + + mockEmailVerification() + vi.mocked(ownershipTransfer).mockResolvedValue({ + result: 'success', + } as Awaited>) + + const mockReload = vi.fn() + vi.stubGlobal('location', { ...window.location, reload: mockReload }) + + renderModal() + + await goToTransferStep(user) + + expect(await screen.findByText(/members\.transferModal\.transferLabel/i)).toBeInTheDocument() + + await selectNewOwnerAndSubmit(user) + + await waitFor(() => { + expect(ownershipTransfer).toHaveBeenCalledWith('new-owner-id', { token: 'final-token' }) + expect(mockReload).toHaveBeenCalled() + }) + }, 15000) + + it('should show error when email verification returns invalid code', async () => { + const user = userEvent.setup() + + mockEmailVerification({ isValid: false, token: 'step-token' }) + + renderModal() + + await goToTransferStep(user) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + }) + + it('should show error when sending verification email fails', async () => { + const user = userEvent.setup() + + vi.mocked(sendOwnerEmail).mockRejectedValue(new Error('network error')) + + renderModal() + + await user.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i })) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + }) + + it('should show error when ownership transfer fails', async () => { + const user = userEvent.setup() + + mockEmailVerification() + vi.mocked(ownershipTransfer).mockRejectedValue(new Error('transfer failed')) + + renderModal() + + await goToTransferStep(user) + await selectNewOwnerAndSubmit(user) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + }) +}) 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 new file mode 100644 index 0000000000..afed247394 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.spec.tsx @@ -0,0 +1,107 @@ +import type { Member } from '@/models/common' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { vi } from 'vitest' +import { useMembers } from '@/service/use-common' +import MemberSelector from './member-selector' + +vi.mock('@/service/use-common') + +const MemberSelectorHarness = ({ initialValue = '', exclude = [] as string[] }: { initialValue?: string, exclude?: string[] }) => { + const [selected, setSelected] = useState(initialValue) + return ( + <> + + {selected && ( +
+ Selected: + {' '} + {selected} +
+ )} + + ) +} + +describe('MemberSelector', () => { + const mockMembers = [ + { id: '1', name: 'User 1', email: 'user1@example.com', role: 'admin' }, + { id: '2', name: 'User 2', email: 'user2@example.com', role: 'normal' }, + ] as Member[] + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useMembers).mockReturnValue({ + data: { accounts: mockMembers }, + } as unknown as ReturnType) + }) + + it('should show member options when selector is opened', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i)) + + expect(screen.getByPlaceholderText(/common\.operation\.search/i)).toBeInTheDocument() + expect(screen.getByText('User 1')).toBeInTheDocument() + expect(screen.getByText('User 2')).toBeInTheDocument() + }) + + it('should filter displayed members by search term', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i)) + await user.type(screen.getByPlaceholderText(/common\.operation\.search/i), 'User 2') + + expect(screen.queryByText('User 1')).not.toBeInTheDocument() + expect(screen.getByText('User 2')).toBeInTheDocument() + }) + + it('should show selected member after clicking an option', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i)) + await user.click(screen.getByText('User 1')) + + expect(screen.getByText('Selected: 1')).toBeInTheDocument() + }) + + it('should show selected value details when an initial value is provided', () => { + render() + + expect(screen.getByText('User 2')).toBeInTheDocument() + expect(screen.getByText('user2@example.com')).toBeInTheDocument() + }) + + it('should hide excluded members from options', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i)) + + expect(screen.queryByText('User 1')).not.toBeInTheDocument() + expect(screen.getByText('User 2')).toBeInTheDocument() + }) + + it('should render empty options when member data is unavailable', async () => { + const user = userEvent.setup() + + vi.mocked(useMembers).mockReturnValue({ + data: undefined, + } as unknown as ReturnType) + + render() + + await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i)) + + expect(screen.queryByText('User 1')).not.toBeInTheDocument() + expect(screen.queryByText('User 2')).not.toBeInTheDocument() + }) +}) From a0244d1390b3a14245cc92cf711424e70dad2bb7 Mon Sep 17 00:00:00 2001 From: akashseth-ifp Date: Mon, 23 Feb 2026 17:37:19 +0530 Subject: [PATCH 02/26] =?UTF-8?q?test(web):=20add=20tests=20for=20model-pr?= =?UTF-8?q?ovider-page=20files=20in=20header=20account-=E2=80=A6=20(#32360?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model-badge/index.spec.tsx | 33 ++ .../model-icon/index.spec.tsx | 108 +++++ .../model-modal/Form.spec.tsx | 447 ++++++++++++++++++ .../model-modal/Input.spec.tsx | 96 ++++ .../model-modal/index.spec.tsx | 353 ++++++++++++++ .../model-name/index.spec.tsx | 116 +++++ .../agent-model-trigger.spec.tsx | 154 ++++++ .../configuration-button.spec.tsx | 28 ++ .../model-parameter-modal/index.spec.tsx | 273 +++++++++++ .../model-display.spec.tsx | 20 + .../parameter-item.spec.tsx | 239 ++++++++++ .../model-parameter-modal/parameter-item.tsx | 16 +- .../presets-parameter.spec.tsx | 32 ++ .../status-indicators.spec.tsx | 103 ++++ .../model-parameter-modal/trigger.spec.tsx | 47 ++ web/eslint-suppressions.json | 5 - 16 files changed, 2057 insertions(+), 13 deletions(-) create mode 100644 web/app/components/header/account-setting/model-provider-page/model-badge/index.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-icon/index.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-modal/Form.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-modal/Input.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-modal/index.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-name/index.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger.spec.tsx diff --git a/web/app/components/header/account-setting/model-provider-page/model-badge/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-badge/index.spec.tsx new file mode 100644 index 0000000000..bc68d9a94d --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-badge/index.spec.tsx @@ -0,0 +1,33 @@ +import { render, screen } from '@testing-library/react' +import ModelBadge from './index' + +describe('ModelBadge', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering behavior for user-visible content. + describe('Rendering', () => { + it('should render provided text', () => { + render(Provider) + + expect(screen.getByText(/provider/i)).toBeInTheDocument() + }) + + it('should render without text when children is null', () => { + const { container } = render({null}) + + expect(container.textContent).toBe('') + }) + + it('should render nested content', () => { + render( + + Badge Label + , + ) + + expect(screen.getByText(/badge label/i)).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 new file mode 100644 index 0000000000..d397330159 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-icon/index.spec.tsx @@ -0,0 +1,108 @@ +import type { Model } from '../declarations' +import { render, screen } from '@testing-library/react' +import { Theme } from '@/types/app' +import { + ConfigurationMethodEnum, + ModelStatusEnum, + ModelTypeEnum, +} from '../declarations' +import ModelIcon from './index' + +type I18nText = { + en_US: string + zh_Hans: string +} + +let mockTheme: Theme = Theme.light +let mockLanguage = 'en_US' + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: mockTheme }), +})) + +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, +}) + +const createModel = (overrides?: Partial): Model => ({ + provider: 'test-provider', + icon_small: createI18nText('light.png'), + icon_small_dark: createI18nText('dark.png'), + label: createI18nText('Test Provider'), + models: [ + { + model: 'test-model', + label: createI18nText('Test Model'), + model_type: ModelTypeEnum.textGeneration, + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: {}, + load_balancing_enabled: false, + }, + ], + status: ModelStatusEnum.active, + ...overrides, +}) + +describe('ModelIcon', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTheme = Theme.light + mockLanguage = 'en_US' + }) + + // Rendering + it('should render the light icon when icon_small is provided', () => { + const provider = createModel({ + icon_small: createI18nText('light-only.png'), + icon_small_dark: undefined, + }) + + render() + + expect(screen.getByRole('img', { name: /model-icon/i })).toHaveAttribute('src', 'light-only.png') + }) + + // Theme selection + it('should render the dark icon when theme is dark and icon_small_dark exists', () => { + mockTheme = Theme.dark + const provider = createModel({ + icon_small: createI18nText('light.png'), + icon_small_dark: createI18nText('dark.png'), + }) + + render() + + expect(screen.getByRole('img', { name: /model-icon/i })).toHaveAttribute('src', 'dark.png') + }) + + // Provider override + it('should ignore icon_small for OpenAI models starting with "o"', () => { + const provider = createModel({ + provider: 'openai', + icon_small: createI18nText('openai.png'), + }) + + render() + + expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument() + expect(screen.getByTestId('openai-yellow-icon')).toBeInTheDocument() + }) + + // Edge case + it('should render without an icon when provider is undefined', () => { + const { container } = render() + + expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument() + expect(container.firstChild).not.toBeNull() + }) +}) 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 new file mode 100644 index 0000000000..572a2944f8 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.spec.tsx @@ -0,0 +1,447 @@ +import type { + CredentialFormSchema, + CredentialFormSchemaBase, + CredentialFormSchemaNumberInput, + CredentialFormSchemaRadio, + CredentialFormSchemaSelect, + CredentialFormSchemaTextInput, + FormValue, +} from '../declarations' +import { fireEvent, render, screen } from '@testing-library/react' +import { FormTypeEnum } from '../declarations' +import Form from './Form' + +type CustomSchema = Omit & { type: 'custom-type' } + +type MockVarPayload = { type: string } + +type AnyFormSchema = CredentialFormSchema | (CredentialFormSchemaBase & { type: FormTypeEnum }) + +vi.mock('../hooks', () => ({ + useLanguage: () => 'en_US', +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({ + default: ({ onSelect }: { onSelect: (item: { id: string }) => void }) => ( + + ), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({ + default: ({ setModel }: { setModel: (model: { model: string, model_type: string }) => void }) => ( + + ), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/multiple-tool-selector', () => ({ + default: ({ onChange }: { onChange: (items: Array<{ id: string }>) => void }) => ( + + ), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector', () => ({ + default: ({ onSelect, onDelete }: { onSelect: (item: { id: string }) => void, onDelete: () => void }) => ( +
+ + +
+ ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ + default: ({ filterVar, onChange }: { filterVar?: (payload: MockVarPayload) => boolean, onChange: (items: Array<{ name: string }>) => void }) => { + const allowed = filterVar ? filterVar({ type: 'text' }) : true + const blocked = filterVar ? filterVar({ type: 'image' }) : false + return ( +
+
{allowed ? 'allowed' : 'blocked'}
+
{blocked ? 'allowed' : 'blocked'}
+ +
+ ) + }, +})) + +vi.mock('../../key-validator/ValidateStatus', () => ({ + ValidatingTip: () =>
Validating...
, +})) + +const createI18n = (text: string) => ({ en_US: text, zh_Hans: text }) + +const createBaseSchema = ( + type: FormTypeEnum, + overrides: Partial = {}, +): CredentialFormSchemaBase => ({ + name: overrides.variable ?? 'field', + variable: overrides.variable ?? 'field', + label: createI18n('Field'), + type, + required: false, + show_on: [], + ...overrides, +}) + +const createTextSchema = (overrides: Partial & { type?: FormTypeEnum }) => ({ + ...createBaseSchema(overrides.type ?? FormTypeEnum.textInput, { variable: overrides.variable ?? 'text' }), + placeholder: createI18n('Input'), + ...overrides, +}) + +const createNumberSchema = (overrides: Partial) => ({ + ...createBaseSchema(FormTypeEnum.textNumber, { variable: overrides.variable ?? 'number' }), + placeholder: createI18n('Number'), + min: 1, + max: 9, + ...overrides, +}) + +const createRadioSchema = (overrides: Partial) => ({ + ...createBaseSchema(FormTypeEnum.radio, { variable: overrides.variable ?? 'radio' }), + options: [ + { label: createI18n('Option A'), value: 'a', show_on: [] }, + { label: createI18n('Option B'), value: 'b', show_on: [] }, + ], + ...overrides, +}) + +const createSelectSchema = (overrides: Partial) => ({ + ...createBaseSchema(FormTypeEnum.select, { variable: overrides.variable ?? 'select' }), + placeholder: createI18n('Select one'), + options: [ + { label: createI18n('Select A'), value: 'a', show_on: [] }, + { label: createI18n('Select B'), value: 'b', show_on: [] }, + ], + ...overrides, +}) + +describe('Form', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering basics + describe('Rendering', () => { + it('should render visible fields and apply default values', () => { + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'api_key', + label: createI18n('API Key'), + placeholder: createI18n('API Key'), + required: true, + default: 'default-key', + }), + createTextSchema({ + variable: 'secret', + type: FormTypeEnum.secretInput, + label: createI18n('Secret'), + placeholder: createI18n('Secret'), + }), + createNumberSchema({ + variable: 'limit', + label: createI18n('Limit'), + placeholder: createI18n('Limit'), + default: '5', + }), + createTextSchema({ + variable: 'hidden', + label: createI18n('Hidden'), + show_on: [{ variable: 'toggle', value: 'on' }], + }), + ] + const value: FormValue = { + api_key: '', + secret: 'top-secret', + limit: '', + toggle: 'off', + } + + render( +
, + ) + + expect(screen.getByPlaceholderText('API Key')).toHaveValue('default-key') + expect(screen.getByPlaceholderText('Secret')).toHaveValue('top-secret') + expect(screen.getByPlaceholderText('Limit')).toHaveValue(5) + expect(screen.queryByText('Hidden')).not.toBeInTheDocument() + expect(screen.getAllByText('*')).toHaveLength(1) + }) + }) + + // Interaction updates + describe('Interactions', () => { + it('should update values and clear dependent fields when a field changes', () => { + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'api_key', + label: createI18n('API Key'), + placeholder: createI18n('API Key'), + }), + createTextSchema({ + variable: 'dependent', + label: createI18n('Dependent'), + default: 'reset', + }), + ] + const value: FormValue = { api_key: 'old', dependent: 'keep' } + const onChange = vi.fn() + + render( + , + ) + + fireEvent.change(screen.getByPlaceholderText('API Key'), { target: { value: 'new-key' } }) + + expect(onChange).toHaveBeenCalledWith({ api_key: 'new-key', dependent: 'reset' }) + expect(screen.getByText('Validating...')).toBeInTheDocument() + }) + + it('should render radio options based on show conditions and ignore edit-locked changes', () => { + const formSchemas: AnyFormSchema[] = [ + createRadioSchema({ + variable: 'region', + label: createI18n('Region'), + options: [ + { label: createI18n('US'), value: 'us', show_on: [] }, + { label: createI18n('EU'), value: 'eu', show_on: [{ variable: 'toggle', value: 'on' }] }, + ], + }), + createRadioSchema({ + variable: 'hidden_region', + label: createI18n('Hidden Region'), + show_on: [{ variable: 'toggle', value: 'hidden' }], + options: [ + { label: createI18n('Hidden A'), value: 'a', show_on: [] }, + ], + }), + createRadioSchema({ + variable: '__model_name', + label: createI18n('Locked'), + options: [ + { label: createI18n('Locked A'), value: 'a', show_on: [] }, + ], + }), + ] + const value: FormValue = { region: 'us', toggle: 'on', __model_name: 'a' } + const onChange = vi.fn() + + render( + , + ) + + expect(screen.getByText('EU')).toBeInTheDocument() + expect(screen.queryByText('Hidden Region')).not.toBeInTheDocument() + fireEvent.click(screen.getByText('EU')) + fireEvent.click(screen.getByText('Locked A')) + + expect(onChange).toHaveBeenCalledWith({ region: 'eu', toggle: 'on', __model_name: 'a' }) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('should render select and checkbox fields and update checkbox value', () => { + const formSchemas: AnyFormSchema[] = [ + createSelectSchema({ + variable: 'model', + label: createI18n('Model'), + placeholder: createI18n('Pick model'), + show_on: [{ variable: 'toggle', value: 'on' }], + options: [ + { label: createI18n('Select A'), value: 'a', show_on: [] }, + { label: createI18n('Select B'), value: 'b', show_on: [{ variable: 'toggle', value: 'on' }] }, + ], + }), + createRadioSchema({ + variable: 'agree', + type: FormTypeEnum.checkbox, + label: createI18n('Agree'), + options: [], + show_on: [{ variable: 'toggle', value: 'on' }], + }), + ] + const value: FormValue = { model: 'a', agree: false, toggle: 'off' } + const onChange = vi.fn() + + const { rerender } = render( + , + ) + + expect(screen.queryByText('Pick model')).not.toBeInTheDocument() + expect(screen.queryByText('Agree')).not.toBeInTheDocument() + + rerender( + , + ) + + expect(screen.getByText('Select A')).toBeInTheDocument() + fireEvent.click(screen.getByText('Select A')) + fireEvent.click(screen.getByText('Select B')) + + fireEvent.click(screen.getByText('True')) + + expect(onChange).toHaveBeenCalledWith({ model: 'b', agree: false, toggle: 'on' }) + expect(onChange).toHaveBeenCalledWith({ model: 'a', agree: true, toggle: 'on' }) + }) + + it('should pass selected items from model and tool selectors to the form value', () => { + const formSchemas: AnyFormSchema[] = [ + createTextSchema({ + variable: 'model_selector', + type: FormTypeEnum.modelSelector, + label: createI18n('Model Selector'), + }), + createTextSchema({ + variable: 'tool_selector', + type: FormTypeEnum.toolSelector, + label: createI18n('Tool Selector'), + }), + createTextSchema({ + variable: 'multi_tool', + type: FormTypeEnum.multiToolSelector, + label: createI18n('Multi Tool'), + tooltip: createI18n('Tips'), + }), + createTextSchema({ + variable: 'app_selector', + type: FormTypeEnum.appSelector, + label: createI18n('App Selector'), + }), + ] + const value: FormValue = { model_selector: {}, tool_selector: null, multi_tool: [], app_selector: null } + const onChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByText('Select Model')) + fireEvent.click(screen.getByText('Select Tool')) + fireEvent.click(screen.getByText('Remove Tool')) + fireEvent.click(screen.getByText('Select Tools')) + fireEvent.click(screen.getByText('Select App')) + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ + model_selector: { model: 'gpt-1', model_type: 'llm', type: FormTypeEnum.modelSelector }, + })) + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ + tool_selector: { id: 'tool-1' }, + })) + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ + tool_selector: null, + })) + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ + multi_tool: [{ id: 'tool-1' }], + })) + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ + app_selector: { id: 'app-1', type: FormTypeEnum.appSelector }, + })) + }) + + it('should render variable picker and custom render overrides', () => { + const formSchemas: Array = [ + createTextSchema({ + variable: 'override', + label: createI18n('Override'), + type: FormTypeEnum.textInput, + }), + createTextSchema({ + variable: 'any_var', + type: FormTypeEnum.any, + label: createI18n('Any Var'), + scope: 'text&audio', + }), + createTextSchema({ + variable: 'any_without_scope', + type: FormTypeEnum.any, + label: createI18n('Any Without Scope'), + }), + { + ...createTextSchema({ + variable: 'custom_field', + label: createI18n('Custom Field'), + }), + type: 'custom-type', + }, + ] + const value: FormValue = { override: '', any_var: [], any_without_scope: [], custom_field: '' } + const onChange = vi.fn() + + render( + + value={value} + onChange={onChange} + formSchemas={formSchemas} + validating={false} + validatedSuccess={false} + showOnVariableMap={{}} + isEditMode={false} + fieldMoreInfo={() =>
Extra Info
} + override={[[FormTypeEnum.textInput], () =>
Override Field
]} + customRenderField={schema => ( +
+ Custom Render: + {schema.variable} +
+ )} + />, + ) + + expect(screen.getByText('Override Field')).toBeInTheDocument() + expect(screen.getByText(/Custom Render:.*custom_field/)).toBeInTheDocument() + expect(screen.getAllByText('allowed')).toHaveLength(3) + expect(screen.getAllByText('blocked')).toHaveLength(1) + + fireEvent.click(screen.getAllByText('Pick Variable')[0]) + + expect(onChange).toHaveBeenCalledWith({ override: '', any_var: [{ name: 'var-1' }], any_without_scope: [], custom_field: '' }) + expect(screen.getAllByText('Extra Info')).toHaveLength(2) + }) + }) +}) 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 new file mode 100644 index 0000000000..baea6732cb --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/Input.spec.tsx @@ -0,0 +1,96 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import Input from './Input' + +describe('Input', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering basics + it('should render with the provided placeholder and value', () => { + render( + , + ) + + expect(screen.getByPlaceholderText('API Key')).toHaveValue('hello') + }) + + // User interaction + it('should call onChange when the user types', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.change(screen.getByPlaceholderText('API Key'), { target: { value: 'next' } }) + + expect(onChange).toHaveBeenCalledWith('next') + }) + + // Edge cases: min/max enforcement + it('should clamp to the min value when the input is below min on blur', () => { + const onChange = vi.fn() + + render( + , + ) + + const input = screen.getByPlaceholderText('Limit') + fireEvent.change(input, { target: { value: '1' } }) + fireEvent.blur(input) + + expect(onChange).toHaveBeenLastCalledWith('2') + }) + + it('should clamp to the max value when the input is above max on blur', () => { + const onChange = vi.fn() + + render( + , + ) + + const input = screen.getByPlaceholderText('Limit') + fireEvent.change(input, { target: { value: '8' } }) + fireEvent.blur(input) + + expect(onChange).toHaveBeenLastCalledWith('6') + }) + + it('should keep the value when it is within the min/max range on blur', () => { + const onChange = vi.fn() + + render( + , + ) + + const input = screen.getByPlaceholderText('Limit') + fireEvent.change(input, { target: { value: '4' } }) + fireEvent.blur(input) + + expect(onChange).not.toHaveBeenCalledWith('2') + expect(onChange).not.toHaveBeenCalledWith('6') + }) +}) 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 new file mode 100644 index 0000000000..376c128c89 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/index.spec.tsx @@ -0,0 +1,353 @@ +import type { Credential, CredentialFormSchema, ModelProvider } from '../declarations' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { + ConfigurationMethodEnum, + CurrentSystemQuotaTypeEnum, + CustomConfigurationStatusEnum, + ModelModalModeEnum, + ModelTypeEnum, + PreferredProviderTypeEnum, + QuotaUnitEnum, +} from '../declarations' +import ModelModal from './index' + +type CredentialData = { + credentials: Record + available_credentials: Credential[] +} + +type ModelFormSchemas = { + formSchemas: CredentialFormSchema[] + formValues: Record + modelNameAndTypeFormSchemas: CredentialFormSchema[] + modelNameAndTypeFormValues: Record +} + +const mockState = vi.hoisted(() => ({ + isLoading: false, + credentialData: { credentials: {}, available_credentials: [] } as CredentialData, + doingAction: false, + deleteCredentialId: null as string | null, + isCurrentWorkspaceManager: true, + formSchemas: [] as CredentialFormSchema[], + formValues: {} as Record, + modelNameAndTypeFormSchemas: [] as CredentialFormSchema[], + modelNameAndTypeFormValues: {} as Record, +})) + +const mockHandlers = vi.hoisted(() => ({ + handleSaveCredential: vi.fn(), + handleConfirmDelete: vi.fn(), + closeConfirmDelete: vi.fn(), + openConfirmDelete: vi.fn(), + 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, + credentialData: mockState.credentialData, + }), + useAuth: () => ({ + handleSaveCredential: mockHandlers.handleSaveCredential, + handleConfirmDelete: mockHandlers.handleConfirmDelete, + deleteCredentialId: mockState.deleteCredentialId, + closeConfirmDelete: mockHandlers.closeConfirmDelete, + openConfirmDelete: mockHandlers.openConfirmDelete, + doingAction: mockState.doingAction, + handleActiveCredential: mockHandlers.handleActiveCredential, + }), + useModelFormSchemas: (): ModelFormSchemas => ({ + formSchemas: mockState.formSchemas, + formValues: mockState.formValues, + modelNameAndTypeFormSchemas: mockState.modelNameAndTypeFormSchemas, + modelNameAndTypeFormValues: mockState.modelNameAndTypeFormValues, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ isCurrentWorkspaceManager: mockState.isCurrentWorkspaceManager }), +})) + +vi.mock('@/hooks/use-i18n', () => ({ + useRenderI18nObject: () => (value: { en_US: string }) => value.en_US, +})) + +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 => ({ + provider: 'openai', + label: createI18n('OpenAI'), + help: { + title: createI18n('Help'), + url: createI18n('https://example.com'), + }, + icon_small: createI18n('icon'), + supported_model_types: [ModelTypeEnum.textGeneration], + configurate_methods: [ConfigurationMethodEnum.predefinedModel], + provider_credential_schema: { credential_form_schemas: [] }, + model_credential_schema: { + model: { label: createI18n('Model'), placeholder: createI18n('Model') }, + credential_form_schemas: [], + }, + preferred_provider_type: PreferredProviderTypeEnum.system, + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + available_credentials: [], + custom_models: [], + can_added_models: [], + }, + system_configuration: { + enabled: true, + current_quota_type: CurrentSystemQuotaTypeEnum.trial, + quota_configurations: [ + { + quota_type: CurrentSystemQuotaTypeEnum.trial, + quota_unit: QuotaUnitEnum.times, + quota_limit: 0, + quota_used: 0, + last_used: 0, + is_valid: true, + }, + ], + }, + allow_custom_token: true, + ...overrides, +}) + +const renderModal = (overrides?: Partial>) => { + const provider = createProvider() + const props = { + provider, + configurateMethod: ConfigurationMethodEnum.predefinedModel, + onCancel: vi.fn(), + onSave: vi.fn(), + onRemove: vi.fn(), + ...overrides, + } + const view = render() + return { + ...props, + unmount: view.unmount, + } +} + +describe('ModelModal', () => { + beforeEach(() => { + vi.clearAllMocks() + mockState.isLoading = false + mockState.credentialData = { credentials: {}, available_credentials: [] } + mockState.doingAction = false + mockState.deleteCredentialId = null + mockState.isCurrentWorkspaceManager = true + mockState.formSchemas = [] + mockState.formValues = {} + mockState.modelNameAndTypeFormSchemas = [] + mockState.modelNameAndTypeFormValues = {} + mockFormState.responses = [] + }) + + it('should show title, description, and loading state for predefined models', () => { + mockState.isLoading = true + + const predefined = 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 } }) + 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({ + mode: ModelModalModeEnum.configModelCredential, + model, + credential: { credential_id: 'cred-123' }, + }) + 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() + + 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', + }) + }) + 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() + + 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', + 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() + + mockState.credentialData = { credentials: { api_key: 'value' }, available_credentials: [] } + mockState.formValues = { api_key: 'value' } + const removable = renderModal({ credential: { credential_id: 'remove-1' } }) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' })) + expect(mockHandlers.openConfirmDelete).toHaveBeenCalledWith({ credential_id: 'remove-1' }, undefined) + removable.unmount() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-name/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-name/index.spec.tsx new file mode 100644 index 0000000000..9bc9b36653 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-name/index.spec.tsx @@ -0,0 +1,116 @@ +import type { ModelItem } from '../declarations' +import { render, screen } from '@testing-library/react' +import { + ConfigurationMethodEnum, + ModelFeatureEnum, + ModelStatusEnum, + ModelTypeEnum, +} from '../declarations' +import ModelName from './index' + +let mockLocale = 'en-US' + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + i18n: { + language: mockLocale, + }, + }), +})) + +const createModelItem = (overrides: Partial = {}): ModelItem => ({ + model: 'gpt-4o', + label: { + en_US: 'English Model', + zh_Hans: 'Chinese Model', + }, + model_type: ModelTypeEnum.textGeneration, + features: [], + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: {}, + load_balancing_enabled: false, + ...overrides, +}) + +describe('ModelName', () => { + beforeEach(() => { + vi.clearAllMocks() + mockLocale = 'en-US' + }) + + // Rendering scenarios for the model name label. + describe('rendering', () => { + it('should render the localized model label when translation exists', () => { + mockLocale = 'zh-Hans' + const modelItem = createModelItem() + + render() + + expect(screen.getByText('Chinese Model')).toBeInTheDocument() + }) + + it('should fall back to en_US label when localized label is missing', () => { + mockLocale = 'fr-FR' + const modelItem = createModelItem({ + label: { + en_US: 'English Only', + zh_Hans: 'Chinese Model', + }, + }) + + render() + + expect(screen.getByText('English Only')).toBeInTheDocument() + }) + + it('should render nothing when modelItem is null', () => { + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + }) + + // Badges that surface model metadata to the user. + describe('badges', () => { + it('should show model type, mode, and context size when enabled', () => { + const modelItem = createModelItem({ + model_type: ModelTypeEnum.textEmbedding, + model_properties: { + mode: 'chat', + context_size: 2000, + }, + }) + + render( + , + ) + + expect(screen.getByText('TEXT EMBEDDING')).toBeInTheDocument() + expect(screen.getByText('CHAT')).toBeInTheDocument() + expect(screen.getByText('2K')).toBeInTheDocument() + }) + + it('should render feature labels when showFeaturesLabel is enabled', () => { + const modelItem = createModelItem({ + features: [ModelFeatureEnum.vision, ModelFeatureEnum.audio], + }) + + render( + , + ) + + expect(screen.getByText('Vision')).toBeInTheDocument() + expect(screen.getByText('Audio')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger.spec.tsx new file mode 100644 index 0000000000..6b3a1724a1 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger.spec.tsx @@ -0,0 +1,154 @@ +import type { MouseEvent } from 'react' +import type { ModelProvider } from '../declarations' +import { fireEvent, render, screen } from '@testing-library/react' +import { vi } from 'vitest' +import { + CurrentSystemQuotaTypeEnum, + CustomConfigurationStatusEnum, + ModelTypeEnum, + QuotaUnitEnum, +} from '../declarations' +import AgentModelTrigger from './agent-model-trigger' + +let modelProviders: ModelProvider[] = [] +let pluginInfo: { latest_package_identifier: string } | null = null +let pluginLoading = false +let inModelList = true +const invalidateInstalledPluginList = vi.fn() +const handleOpenModal = vi.fn() +const updateModelProviders = vi.fn() +const updateModelList = vi.fn() + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + modelProviders, + }), +})) + +vi.mock('@/service/use-plugins', () => ({ + useInvalidateInstalledPluginList: () => invalidateInstalledPluginList, + useModelInList: () => ({ data: inModelList }), + usePluginInfo: () => ({ data: pluginInfo, isLoading: pluginLoading }), +})) + +vi.mock('../hooks', () => ({ + useModelModalHandler: () => handleOpenModal, + useUpdateModelList: () => updateModelList, + useUpdateModelProviders: () => updateModelProviders, +})) + +vi.mock('../model-icon', () => ({ + default: () =>
Icon
, +})) + +vi.mock('./model-display', () => ({ + default: () =>
ModelDisplay
, +})) + +vi.mock('./status-indicators', () => ({ + default: () =>
StatusIndicators
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({ + InstallPluginButton: ({ onClick, onSuccess }: { onClick: (event: MouseEvent) => void, onSuccess: () => void }) => ( + + ), +})) + +describe('AgentModelTrigger', () => { + beforeEach(() => { + vi.clearAllMocks() + modelProviders = [] + pluginInfo = null + pluginLoading = false + inModelList = true + }) + + it('should render loading state when plugin info is still fetching', () => { + pluginLoading = true + render( + , + ) + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('should render model actions for configured provider', () => { + modelProviders = [{ + provider: 'openai', + custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, + system_configuration: { + enabled: true, + current_quota_type: CurrentSystemQuotaTypeEnum.paid, + quota_configurations: [{ + quota_type: CurrentSystemQuotaTypeEnum.paid, + quota_unit: QuotaUnitEnum.times, + quota_limit: 10, + quota_used: 1, + last_used: 1, + is_valid: true, + }], + }, + }] as unknown as ModelProvider[] + render( + , + ) + expect(screen.getByText('ModelDisplay')).toBeInTheDocument() + expect(screen.getByText('StatusIndicators')).toBeInTheDocument() + }) + + it('should support plugin installation flow when provider is missing', () => { + pluginInfo = { latest_package_identifier: 'plugin/demo@1.0.0' } + render( + , + ) + + fireEvent.click(screen.getByText('Install Plugin')) + expect(updateModelList).toHaveBeenCalledWith(ModelTypeEnum.textGeneration) + expect(updateModelList).toHaveBeenCalledWith(ModelTypeEnum.tts) + expect(updateModelProviders).toHaveBeenCalledTimes(1) + expect(invalidateInstalledPluginList).toHaveBeenCalledTimes(1) + }) + + it('should show configuration action when provider requires setup', () => { + modelProviders = [{ + provider: 'openai', + custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.paid, + quota_configurations: [], + }, + }] as unknown as ModelProvider[] + + render( + , + ) + + expect(screen.getByText('workflow.nodes.agent.notAuthorized')).toBeInTheDocument() + }) + + it('should render unconfigured state when model is not selected', () => { + render() + expect(screen.getByText('workflow.nodes.agent.configureModel')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.spec.tsx new file mode 100644 index 0000000000..622697c9a2 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.spec.tsx @@ -0,0 +1,28 @@ +import type { ComponentProps } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { vi } from 'vitest' +import { ConfigurationMethodEnum } from '../declarations' +import ConfigurationButton from './configuration-button' + +describe('ConfigurationButton', () => { + it('should render and handle click', () => { + const handleOpenModal = vi.fn() + const modelProvider = { id: 1 } + + render( + ['modelProvider']} + handleOpenModal={handleOpenModal} + />, + ) + + const button = screen.getByRole('button') + fireEvent.click(button) + + expect(handleOpenModal).toHaveBeenCalledWith( + modelProvider, + ConfigurationMethodEnum.predefinedModel, + undefined, + ) + }) +}) 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 new file mode 100644 index 0000000000..111af0b497 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.spec.tsx @@ -0,0 +1,273 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { vi } from 'vitest' +import ModelParameterModal from './index' + +let isAPIKeySet = true +let parameterRules = [ + { + name: 'temperature', + label: { en_US: 'Temperature' }, + type: 'float', + default: 0.7, + min: 0, + max: 1, + help: { en_US: 'Control randomness' }, + }, +] +let isRulesLoading = false +let currentProvider: Record | undefined = { provider: 'openai', label: { en_US: 'OpenAI' } } +let currentModel: Record | undefined = { + model: 'gpt-3.5-turbo', + status: 'active', + model_properties: { mode: 'chat' }, +} +let activeTextGenerationModelList: Array> = [ + { + provider: 'openai', + models: [ + { + model: 'gpt-3.5-turbo', + model_properties: { mode: 'chat' }, + features: ['vision'], + }, + { + model: 'gpt-4.1', + model_properties: { mode: 'chat' }, + features: ['vision', 'tool-call'], + }, + ], + }, +] + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + isAPIKeySet, + }), +})) + +vi.mock('@/service/use-common', () => ({ + useModelParameterRules: () => ({ + data: { + data: parameterRules, + }, + isPending: isRulesLoading, + }), +})) + +vi.mock('../hooks', () => ({ + useTextGenerationCurrentProviderAndModelAndModelList: () => ({ + currentProvider, + currentModel, + activeTextGenerationModelList, + }), +})) + +// 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 }) => ( +
+ {parameterRule.label.en_US} + onChange(Number(e.target.value))} + /> + + +
+ ), +})) + +vi.mock('../model-selector', () => ({ + default: ({ onSelect }: { onSelect: (value: { provider: string, model: string }) => void }) => ( +
+ Model Selector + +
+ ), +})) + +vi.mock('./presets-parameter', () => ({ + default: ({ onSelect }: { onSelect: (id: number) => void }) => ( + + ), +})) + +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 + } +}) + +describe('ModelParameterModal', () => { + const defaultProps = { + isAdvancedMode: false, + modelId: 'gpt-3.5-turbo', + provider: 'openai', + setModel: vi.fn(), + completionParams: { temperature: 0.7 }, + onCompletionParamsChange: vi.fn(), + hideDebugWithMultipleModel: false, + debugWithMultipleModel: false, + onDebugWithMultipleModelChange: vi.fn(), + readonly: false, + } + + beforeEach(() => { + vi.clearAllMocks() + isAPIKeySet = true + isRulesLoading = false + parameterRules = [ + { + name: 'temperature', + label: { en_US: 'Temperature' }, + type: 'float', + default: 0.7, + min: 0, + max: 1, + help: { en_US: 'Control randomness' }, + }, + ] + currentProvider = { provider: 'openai', label: { en_US: 'OpenAI' } } + currentModel = { + model: 'gpt-3.5-turbo', + status: 'active', + model_properties: { mode: 'chat' }, + } + activeTextGenerationModelList = [ + { + provider: 'openai', + models: [ + { + model: 'gpt-3.5-turbo', + model_properties: { mode: 'chat' }, + features: ['vision'], + }, + { + model: 'gpt-4.1', + model_properties: { mode: 'chat' }, + features: ['vision', 'tool-call'], + }, + ], + }, + ] + }) + + it('should render trigger and content', () => { + render() + + expect(screen.getByText('Open Settings')).toBeInTheDocument() + expect(screen.getByText('Temperature')).toBeInTheDocument() + expect(screen.getByTestId('model-selector')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('portal-trigger')) + }) + + it('should update params when changed and handle switch add/remove', () => { + render() + + const input = screen.getByLabelText('temperature') + fireEvent.change(input, { target: { value: '0.9' } }) + + expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({ + ...defaultProps.completionParams, + temperature: 0.9, + }) + + fireEvent.click(screen.getByText('Remove')) + expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({}) + + fireEvent.click(screen.getByText('Add')) + expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({ + ...defaultProps.completionParams, + temperature: 'assigned', + }) + }) + + it('should handle preset selection', () => { + render() + + 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() + + fireEvent.click(screen.getByText('Select GPT-4.1')) + expect(defaultProps.setModel).toHaveBeenCalledWith({ + modelId: 'gpt-4.1', + provider: 'openai', + mode: 'chat', + features: ['vision', 'tool-call'], + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.spec.tsx new file mode 100644 index 0000000000..ecee8c84e5 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.spec.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react' +import { vi } from 'vitest' +import ModelDisplay from './model-display' + +vi.mock('../model-name', () => ({ + default: ({ modelItem }: { modelItem: { model: string } }) =>
{modelItem.model}
, +})) + +describe('ModelDisplay', () => { + it('should render model name when model is present', () => { + const currentModel = { model: 'gpt-4' } + render() + expect(screen.getByText('gpt-4')).toBeInTheDocument() + }) + + it('should render modelID when currentModel is missing', () => { + render() + expect(screen.getByText('unknown-model')).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 new file mode 100644 index 0000000000..bd4c902f54 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.spec.tsx @@ -0,0 +1,239 @@ +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 }) => ( + + ), +})) + +vi.mock('@/app/components/base/tag-input', () => ({ + default: ({ onChange }: { onChange: (val: string[]) => void }) => ( + onChange(e.target.value.split(','))} /> + ), +})) + +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() + 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) + }) + + it('should render boolean radio', () => { + const props = createProps({ parameterRule: createRule({ type: 'boolean', default: false }), value: true }) + render() + expect(screen.getByText('True')).toBeInTheDocument() + fireEvent.click(screen.getByText('Select False')) + expect(props.onChange).toHaveBeenCalledWith(false) + }) + + 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') + }) + + 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() + 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/parameter-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx index f2c35c1823..8ae0b99159 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx @@ -109,7 +109,7 @@ const ParameterItem: FC = ({ const handleSwitch = (checked: boolean) => { if (onSwitch) { - const assignValue: ParameterValue = localValue || getDefaultValue() + const assignValue: ParameterValue = localValue ?? getDefaultValue() onSwitch(checked, assignValue) } @@ -118,7 +118,7 @@ const ParameterItem: FC = ({ useEffect(() => { if ((parameterRule.type === 'int' || parameterRule.type === 'float') && numberInputRef.current) numberInputRef.current.value = `${renderValue}` - }, [value]) + }, [value, parameterRule.type, renderValue]) const renderInput = () => { const numberInputWithSlide = (parameterRule.type === 'int' || parameterRule.type === 'float') @@ -148,7 +148,7 @@ const ParameterItem: FC = ({ )} = ({ )} = ({ if (parameterRule.type === 'string' && !parameterRule.options?.length) { return ( @@ -213,7 +213,7 @@ const ParameterItem: FC = ({ if (parameterRule.type === 'text') { return ( diff --git a/web/app/components/base/timezone-label/index.spec.tsx b/web/app/components/base/timezone-label/index.spec.tsx new file mode 100644 index 0000000000..c43aa61936 --- /dev/null +++ b/web/app/components/base/timezone-label/index.spec.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import TimezoneLabel from './index' + +describe('TimezoneLabel', () => { + it('should render correctly with various timezones', () => { + const { rerender } = render() + const label = screen.getByTestId('timezone-label') + expect(label).toHaveTextContent('UTC+0') + expect(label).toHaveAttribute('title', 'Timezone: UTC (UTC+0)') + + rerender() + expect(label).toHaveTextContent('UTC+8') + expect(label).toHaveAttribute('title', 'Timezone: Asia/Shanghai (UTC+8)') + + rerender() + // New York is UTC-5 or UTC-4 depending on DST. + // dayjs handles this, we just check it renders some offset. + expect(label.textContent).toMatch(/UTC[-+]\d+/) + }) + + it('should apply correct styling for inline prop', () => { + render() + expect(screen.getByTestId('timezone-label')).toHaveClass('text-text-quaternary') + }) + + it('should apply custom className', () => { + render() + expect(screen.getByTestId('timezone-label')).toHaveClass('custom-test-class') + }) +}) diff --git a/web/app/components/base/timezone-label/index.tsx b/web/app/components/base/timezone-label/index.tsx index f614280b3e..bb4355f338 100644 --- a/web/app/components/base/timezone-label/index.tsx +++ b/web/app/components/base/timezone-label/index.tsx @@ -43,11 +43,12 @@ const TimezoneLabel: React.FC = ({ return ( {offsetStr} diff --git a/web/app/components/base/tooltip/content.spec.tsx b/web/app/components/base/tooltip/content.spec.tsx new file mode 100644 index 0000000000..314c773ce1 --- /dev/null +++ b/web/app/components/base/tooltip/content.spec.tsx @@ -0,0 +1,49 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { ToolTipContent } from './content' + +describe('ToolTipContent', () => { + it('should render children correctly', () => { + render( + + Tooltip body text + , + ) + expect(screen.getByTestId('tooltip-content')).toBeInTheDocument() + expect(screen.getByTestId('tooltip-content-body')).toHaveTextContent('Tooltip body text') + expect(screen.queryByTestId('tooltip-content-title')).not.toBeInTheDocument() + expect(screen.queryByTestId('tooltip-content-action')).not.toBeInTheDocument() + }) + + it('should render title when provided', () => { + render( + + Tooltip body text + , + ) + expect(screen.getByTestId('tooltip-content-title')).toHaveTextContent('Tooltip Title') + }) + + it('should render action when provided', () => { + render( + Action Text}> + Tooltip body text + , + ) + expect(screen.getByTestId('tooltip-content-action')).toHaveTextContent('Action Text') + }) + + it('should handle action click', async () => { + const user = userEvent.setup() + const handleActionClick = vi.fn() + render( + Action Text}> + Tooltip body text + , + ) + + await user.click(screen.getByText('Action Text')) + expect(handleActionClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/base/tooltip/content.tsx b/web/app/components/base/tooltip/content.tsx index 1879e077e5..a5a31a2a5c 100644 --- a/web/app/components/base/tooltip/content.tsx +++ b/web/app/components/base/tooltip/content.tsx @@ -11,12 +11,12 @@ export const ToolTipContent: FC = ({ children, }) => { return ( -
+
{!!title && ( -
{title}
+
{title}
)} -
{children}
- {!!action &&
{action}
} +
{children}
+ {!!action &&
{action}
}
) } diff --git a/web/app/components/base/video-gallery/VideoPlayer.spec.tsx b/web/app/components/base/video-gallery/VideoPlayer.spec.tsx new file mode 100644 index 0000000000..04d9ccc4c8 --- /dev/null +++ b/web/app/components/base/video-gallery/VideoPlayer.spec.tsx @@ -0,0 +1,262 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import VideoPlayer from './VideoPlayer' + +describe('VideoPlayer', () => { + const mockSrc = 'video.mp4' + const mockSrcs = ['video1.mp4', 'video2.mp4'] + + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + + // Mock HTMLVideoElement methods + window.HTMLVideoElement.prototype.play = vi.fn().mockResolvedValue(undefined) + window.HTMLVideoElement.prototype.pause = vi.fn() + window.HTMLVideoElement.prototype.load = vi.fn() + window.HTMLVideoElement.prototype.requestFullscreen = vi.fn().mockResolvedValue(undefined) + + // Mock document methods + document.exitFullscreen = vi.fn().mockResolvedValue(undefined) + + // Mock offsetWidth to avoid smallSize mode by default + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + value: 500, + }) + + // Define properties on HTMLVideoElement prototype + Object.defineProperty(window.HTMLVideoElement.prototype, 'duration', { + configurable: true, + get() { return 100 }, + }) + + // Use a descriptor check to avoid re-defining if it exists + if (!Object.getOwnPropertyDescriptor(window.HTMLVideoElement.prototype, 'currentTime')) { + Object.defineProperty(window.HTMLVideoElement.prototype, 'currentTime', { + configurable: true, + // eslint-disable-next-line ts/no-explicit-any + get() { return (this as any)._currentTime || 0 }, + // eslint-disable-next-line ts/no-explicit-any + set(v) { (this as any)._currentTime = v }, + }) + } + + if (!Object.getOwnPropertyDescriptor(window.HTMLVideoElement.prototype, 'volume')) { + Object.defineProperty(window.HTMLVideoElement.prototype, 'volume', { + configurable: true, + // eslint-disable-next-line ts/no-explicit-any + get() { return (this as any)._volume || 1 }, + // eslint-disable-next-line ts/no-explicit-any + set(v) { (this as any)._volume = v }, + }) + } + + if (!Object.getOwnPropertyDescriptor(window.HTMLVideoElement.prototype, 'muted')) { + Object.defineProperty(window.HTMLVideoElement.prototype, 'muted', { + configurable: true, + // eslint-disable-next-line ts/no-explicit-any + get() { return (this as any)._muted || false }, + // eslint-disable-next-line ts/no-explicit-any + set(v) { (this as any)._muted = v }, + }) + } + }) + + describe('Rendering', () => { + it('should render with single src', () => { + render() + const video = screen.getByTestId('video-element') as HTMLVideoElement + expect(video.src).toContain(mockSrc) + }) + + it('should render with multiple srcs', () => { + render() + const sources = screen.getByTestId('video-element').querySelectorAll('source') + expect(sources).toHaveLength(2) + expect(sources[0].src).toContain(mockSrcs[0]) + expect(sources[1].src).toContain(mockSrcs[1]) + }) + }) + + describe('Interactions', () => { + it('should toggle play/pause on button click', async () => { + const user = userEvent.setup() + render() + const playPauseBtn = screen.getByTestId('video-play-pause-button') + + await user.click(playPauseBtn) + expect(window.HTMLVideoElement.prototype.play).toHaveBeenCalled() + + await user.click(playPauseBtn) + expect(window.HTMLVideoElement.prototype.pause).toHaveBeenCalled() + }) + + it('should toggle mute on button click', async () => { + const user = userEvent.setup() + render() + const muteBtn = screen.getByTestId('video-mute-button') + + await user.click(muteBtn) + expect(muteBtn).toBeInTheDocument() + }) + + it('should toggle fullscreen on button click', async () => { + const user = userEvent.setup() + render() + const fullscreenBtn = screen.getByTestId('video-fullscreen-button') + + await user.click(fullscreenBtn) + expect(window.HTMLVideoElement.prototype.requestFullscreen).toHaveBeenCalled() + + Object.defineProperty(document, 'fullscreenElement', { + configurable: true, + get() { return {} }, + }) + await user.click(fullscreenBtn) + expect(document.exitFullscreen).toHaveBeenCalled() + + Object.defineProperty(document, 'fullscreenElement', { + configurable: true, + get() { return null }, + }) + }) + + it('should handle video metadata and time updates', () => { + render() + const video = screen.getByTestId('video-element') as HTMLVideoElement + + fireEvent(video, new Event('loadedmetadata')) + expect(screen.getByTestId('video-time-display')).toHaveTextContent('00:00 / 01:40') + + Object.defineProperty(video, 'currentTime', { value: 30, configurable: true }) + fireEvent(video, new Event('timeupdate')) + expect(screen.getByTestId('video-time-display')).toHaveTextContent('00:30 / 01:40') + }) + + it('should handle video end', async () => { + const user = userEvent.setup() + render() + const video = screen.getByTestId('video-element') + const playPauseBtn = screen.getByTestId('video-play-pause-button') + + await user.click(playPauseBtn) + fireEvent(video, new Event('ended')) + + expect(playPauseBtn).toBeInTheDocument() + }) + + it('should show/hide controls on mouse move and timeout', () => { + vi.useFakeTimers() + render() + const container = screen.getByTestId('video-player-container') + + fireEvent.mouseMove(container) + fireEvent.mouseMove(container) // Trigger clearTimeout + + act(() => { + vi.advanceTimersByTime(3001) + }) + vi.useRealTimers() + }) + + it('should handle progress bar interactions', async () => { + const user = userEvent.setup() + render() + const progressBar = screen.getByTestId('video-progress-bar') + const video = screen.getByTestId('video-element') as HTMLVideoElement + + vi.spyOn(progressBar, 'getBoundingClientRect').mockReturnValue({ + left: 0, + width: 100, + top: 0, + right: 100, + bottom: 10, + height: 10, + x: 0, + y: 0, + toJSON: () => { }, + } as DOMRect) + + // Hover + fireEvent.mouseMove(progressBar, { clientX: 50 }) + expect(screen.getByTestId('video-hover-time')).toHaveTextContent('00:50') + fireEvent.mouseLeave(progressBar) + expect(screen.queryByTestId('video-hover-time')).not.toBeInTheDocument() + + // Click + await user.click(progressBar) + // Note: user.click calculates clientX based on element position, but we mocked getBoundingClientRect + // RTL fireEvent is more direct for coordinate-based tests + fireEvent.click(progressBar, { clientX: 75 }) + expect(video.currentTime).toBe(75) + + // Drag + fireEvent.mouseDown(progressBar, { clientX: 20 }) + expect(video.currentTime).toBe(20) + fireEvent.mouseMove(document, { clientX: 40 }) + expect(video.currentTime).toBe(40) + fireEvent.mouseUp(document) + fireEvent.mouseMove(document, { clientX: 60 }) + expect(video.currentTime).toBe(40) + }) + + it('should handle volume slider change', () => { + render() + const volumeSlider = screen.getByTestId('video-volume-slider') + const video = screen.getByTestId('video-element') as HTMLVideoElement + + vi.spyOn(volumeSlider, 'getBoundingClientRect').mockReturnValue({ + left: 0, + width: 100, + top: 0, + right: 100, + bottom: 10, + height: 10, + x: 0, + y: 0, + toJSON: () => { }, + } as DOMRect) + + // Click + fireEvent.click(volumeSlider, { clientX: 50 }) + expect(video.volume).toBe(0.5) + + // MouseDown and Drag + fireEvent.mouseDown(volumeSlider, { clientX: 80 }) + expect(video.volume).toBe(0.8) + + fireEvent.mouseMove(document, { clientX: 90 }) + expect(video.volume).toBe(0.9) + + fireEvent.mouseUp(document) // Trigger cleanup + fireEvent.mouseMove(document, { clientX: 100 }) + expect(video.volume).toBe(0.9) // No change after mouseUp + }) + + it('should handle small size class based on offsetWidth', async () => { + render() + const playerContainer = screen.getByTestId('video-player-container') + + Object.defineProperty(playerContainer, 'offsetWidth', { value: 300, configurable: true }) + + act(() => { + window.dispatchEvent(new Event('resize')) + }) + + await waitFor(() => { + expect(screen.queryByTestId('video-time-display')).not.toBeInTheDocument() + }) + + Object.defineProperty(playerContainer, 'offsetWidth', { value: 500, configurable: true }) + act(() => { + window.dispatchEvent(new Event('resize')) + }) + + await waitFor(() => { + expect(screen.getByTestId('video-time-display')).toBeInTheDocument() + }) + }) + }) +}) diff --git a/web/app/components/base/video-gallery/VideoPlayer.tsx b/web/app/components/base/video-gallery/VideoPlayer.tsx index 8adaf71f58..6b2d802863 100644 --- a/web/app/components/base/video-gallery/VideoPlayer.tsx +++ b/web/app/components/base/video-gallery/VideoPlayer.tsx @@ -215,8 +215,8 @@ const VideoPlayer: React.FC = ({ src, srcs }) => { }, []) return ( -
-