From a01c384f5bf1569508e80a94f41ef53385d5e2cb Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Mon, 16 Mar 2026 14:57:25 +0800 Subject: [PATCH 01/13] chore: remove next font (#33512) --- .../InstrumentSerif-Italic-Latin.woff2 | Bin 0 -> 25064 bytes .../billing/pricing/header.module.css | 24 ++++++++++++++++++ web/app/components/billing/pricing/header.tsx | 11 ++++++-- web/app/layout.tsx | 11 +------- web/eslint-suppressions.json | 5 ---- web/tailwind-common-config.ts | 3 --- 6 files changed, 34 insertions(+), 20 deletions(-) create mode 100644 web/app/components/billing/pricing/InstrumentSerif-Italic-Latin.woff2 create mode 100644 web/app/components/billing/pricing/header.module.css diff --git a/web/app/components/billing/pricing/InstrumentSerif-Italic-Latin.woff2 b/web/app/components/billing/pricing/InstrumentSerif-Italic-Latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..5d1fd32cb0a7d72aa601b028fab4b85893e12534 GIT binary patch literal 25064 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 void @@ -20,11 +22,16 @@ const Header = ({
- + {t('plansCommon.title.plans', { ns: 'billing' })} -

+

{t('plansCommon.title.description', { ns: 'billing' })}

+ ), +})) + +vi.mock('../plan-switcher', () => ({ + default: () =>
plan-switcher
, +})) + +vi.mock('../plans', () => ({ + default: () =>
plans
, +})) + +vi.mock('../footer', () => ({ + default: () =>
footer
, +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: vi.fn(), +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: vi.fn(), +})) + +vi.mock('@/context/i18n', () => ({ + useGetPricingPageLanguage: vi.fn(), +})) + +const buildUsage = (): UsagePlanInfo => ({ + buildApps: 0, + teamMembers: 0, + annotatedResponse: 0, + documentsUploadQuota: 0, + apiRateLimit: 0, + triggerEvents: 0, + vectorSpace: 0, +}) + +describe('Pricing dialog lifecycle', () => { + beforeEach(() => { + vi.clearAllMocks() + latestOnOpenChange = undefined + ;(useAppContext as Mock).mockReturnValue({ isCurrentWorkspaceManager: true }) + ;(useProviderContext as Mock).mockReturnValue({ + plan: { + type: Plan.sandbox, + usage: buildUsage(), + total: buildUsage(), + }, + }) + ;(useGetPricingPageLanguage as Mock).mockReturnValue('en') + }) + + it('should only call onCancel when the dialog requests closing', () => { + const onCancel = vi.fn() + render() + + latestOnOpenChange?.(true) + latestOnOpenChange?.(false) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/header/account-setting/members-page/edit-workspace-modal/dialog.spec.tsx b/web/app/components/header/account-setting/members-page/edit-workspace-modal/dialog.spec.tsx new file mode 100644 index 0000000000..f489d64912 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/edit-workspace-modal/dialog.spec.tsx @@ -0,0 +1,57 @@ +import type { ReactNode } from 'react' +import { render } from '@testing-library/react' +import { ToastContext } from '@/app/components/base/toast/context' +import { useAppContext } from '@/context/app-context' +import EditWorkspaceModal from './index' + +type DialogProps = { + children: ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void +} + +let latestOnOpenChange: DialogProps['onOpenChange'] + +vi.mock('@/app/components/base/ui/dialog', () => ({ + Dialog: ({ children, onOpenChange }: DialogProps) => { + latestOnOpenChange = onOpenChange + return
{children}
+ }, + DialogCloseButton: ({ ...props }: Record) => , +})) + +vi.mock('@/app/components/base/ui/alert-dialog', () => ({ + AlertDialog: ({ children, onOpenChange }: AlertDialogProps) => { + latestAlertDialogOnOpenChange = onOpenChange + return
{children}
+ }, + AlertDialogActions: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogCancelButton: ({ children }: { children: ReactNode }) => , + AlertDialogConfirmButton: ({ children, onClick }: { children: ReactNode, onClick?: () => void }) => , + AlertDialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogTitle: ({ children }: { children: ReactNode }) =>
{children}
, +})) + +vi.mock('../model-auth/hooks', () => ({ + useCredentialData: () => ({ + isLoading: false, + credentialData: { + credentials: {}, + available_credentials: mockAvailableCredentials, + }, + }), + useAuth: () => ({ + handleSaveCredential: vi.fn(), + handleConfirmDelete: mockHandleConfirmDelete, + deleteCredentialId: mockDeleteCredentialId, + closeConfirmDelete: mockCloseConfirmDelete, + openConfirmDelete: vi.fn(), + doingAction: false, + handleActiveCredential: vi.fn(), + }), + useModelFormSchemas: () => ({ + formSchemas: [], + formValues: {}, + modelNameAndTypeFormSchemas: [], + modelNameAndTypeFormValues: {}, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: true, + }), +})) + +vi.mock('@/hooks/use-i18n', () => ({ + useRenderI18nObject: () => (value: Record) => value[mockLanguage] || value.en_US, +})) + +vi.mock('../hooks', () => ({ + useLanguage: () => mockLanguage, +})) + +const createProvider = (overrides: Partial = {}): ModelProvider => ({ + provider: 'openai', + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + help: { + title: { en_US: 'Help', zh_Hans: '帮助' }, + url: { en_US: 'https://example.com', zh_Hans: 'https://example.cn' }, + }, + icon_small: { en_US: '', zh_Hans: '' }, + supported_model_types: [], + configurate_methods: [], + provider_credential_schema: { credential_form_schemas: [] }, + model_credential_schema: { + model: { label: { en_US: 'Model', zh_Hans: '模型' }, placeholder: { en_US: 'Select', zh_Hans: '选择' } }, + credential_form_schemas: [], + }, + custom_configuration: { + status: 'active', + available_credentials: [], + custom_models: [], + can_added_models: [], + }, + system_configuration: { + enabled: true, + current_quota_type: 'trial', + quota_configurations: [], + }, + allow_custom_token: true, + ...overrides, +} as unknown as ModelProvider) + +describe('ModelModal dialog branches', () => { + beforeEach(() => { + vi.clearAllMocks() + mockLanguage = 'en_US' + latestDialogOnOpenChange = undefined + latestAlertDialogOnOpenChange = undefined + mockAvailableCredentials = [] + mockDeleteCredentialId = null + }) + + it('should only cancel when the dialog reports it has closed', () => { + const onCancel = vi.fn() + render( + , + ) + + act(() => { + latestDialogOnOpenChange?.(true) + latestDialogOnOpenChange?.(false) + }) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('should only close the confirm dialog when the alert dialog closes', () => { + mockDeleteCredentialId = 'cred-1' + + render( + , + ) + + act(() => { + latestAlertDialogOnOpenChange?.(true) + latestAlertDialogOnOpenChange?.(false) + }) + + expect(mockCloseConfirmDelete).toHaveBeenCalledTimes(1) + }) + + it('should pass an empty credential list to the selector when no credentials are available', () => { + mockAvailableCredentials = undefined + + render( + , + ) + + expect(screen.getByText('credentials:0')).toBeInTheDocument() + }) + + it('should hide the help link when provider help is missing', () => { + render( + , + ) + + expect(screen.queryByRole('link', { name: 'Help' })).not.toBeInTheDocument() + }) + + it('should prevent navigation when help text exists without a help url', () => { + mockLanguage = 'zh_Hans' + + render( + , + ) + + const link = screen.getByText('English Help').closest('a') + const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }) + expect(link).not.toBeNull() + link!.dispatchEvent(clickEvent) + + expect(clickEvent.defaultPrevented).toBe(true) + }) + + it('should fall back to localized and english help urls when titles are missing', () => { + mockLanguage = 'zh_Hans' + const { rerender } = render( + , + ) + + expect(screen.getByRole('link', { name: 'https://example.cn' })).toHaveAttribute('href', 'https://example.cn') + + rerender( + , + ) + + const link = screen.getByRole('link', { name: 'https://example.com' }) + const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }) + link.dispatchEvent(clickEvent) + + expect(link).toHaveAttribute('href', 'https://example.com') + expect(clickEvent.defaultPrevented).toBe(false) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.spec.tsx index f957ef6709..7ee66ab30c 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.spec.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import ModelParameterModal from './index' let isAPIKeySet = true @@ -77,9 +77,10 @@ vi.mock('./parameter-item', () => ({ })) vi.mock('../model-selector', () => ({ - default: ({ onSelect }: { onSelect: (value: { provider: string, model: string }) => void }) => ( + default: ({ onHide, onSelect }: { onHide: () => void, onSelect: (value: { provider: string, model: string }) => void }) => (
+
), })) @@ -231,4 +232,67 @@ describe('ModelParameterModal', () => { expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument() expect(screen.getByTestId('model-selector')).toBeInTheDocument() }) + + it('should support custom triggers, workflow mode, and missing default model values', async () => { + render( + {open ? 'Custom Open' : 'Custom Closed'}} + />, + ) + + fireEvent.click(screen.getByText('Custom Closed')) + + expect(screen.getByText('Custom Open')).toBeInTheDocument() + expect(screen.getByTestId('model-selector')).toBeInTheDocument() + + fireEvent.click(screen.getByText('hide')) + + await waitFor(() => { + expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument() + }) + }) + + it('should append the stop parameter in advanced mode and show the single-model debug label', () => { + render( + , + ) + + fireEvent.click(screen.getByText('Open Settings')) + + expect(screen.getByTestId('param-stop')).toBeInTheDocument() + expect(screen.getByText(/debugAsSingleModel/i)).toBeInTheDocument() + }) + + it('should render the empty loading fallback when rules resolve to an empty list', () => { + parameterRules = [] + isRulesLoading = true + + render() + fireEvent.click(screen.getByText('Open Settings')) + + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument() + }) + + it('should support custom trigger placement outside workflow mode', () => { + render( + {open ? 'Popup Open' : 'Popup Closed'}} + />, + ) + + fireEvent.click(screen.getByText('Popup Closed')) + + expect(screen.getByText('Popup Open')).toBeInTheDocument() + expect(screen.getByTestId('model-selector')).toBeInTheDocument() + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.select.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.select.spec.tsx new file mode 100644 index 0000000000..21703fbed6 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.select.spec.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import ParameterItem from './parameter-item' + +vi.mock('../hooks', () => ({ + useLanguage: () => 'en_US', +})) + +vi.mock('@/app/components/base/ui/select', () => ({ + Select: ({ children, onValueChange }: { children: ReactNode, onValueChange: (value: string | undefined) => void }) => ( +
+ + + {children} +
+ ), + SelectContent: ({ children }: { children: ReactNode }) =>
{children}
, + SelectItem: ({ children }: { children: ReactNode }) =>
{children}
, + SelectTrigger: ({ children }: { children: ReactNode }) =>
{children}
, + SelectValue: () =>
SelectValue
, +})) + +describe('ParameterItem select mode', () => { + it('should propagate both explicit and empty select values', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'select-updated' })) + fireEvent.click(screen.getByRole('button', { name: 'select-empty' })) + + expect(onChange).toHaveBeenNthCalledWith(1, 'updated') + expect(onChange).toHaveBeenNthCalledWith(2, undefined) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popover.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popover.spec.tsx new file mode 100644 index 0000000000..a20a066d35 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popover.spec.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from 'react' +import { act, fireEvent, render, screen } from '@testing-library/react' +import ModelSelector from './index' + +type PopoverProps = { + children: ReactNode + onOpenChange?: (open: boolean) => void +} + +let latestOnOpenChange: PopoverProps['onOpenChange'] + +vi.mock('../hooks', () => ({ + useCurrentProviderAndModel: () => ({ + currentProvider: undefined, + currentModel: undefined, + }), +})) + +vi.mock('@/app/components/base/ui/popover', () => ({ + Popover: ({ children, onOpenChange }: PopoverProps) => { + latestOnOpenChange = onOpenChange + return
{children}
+ }, + PopoverTrigger: ({ render }: { render: ReactNode }) => <>{render}, + PopoverContent: ({ children }: { children: ReactNode }) =>
{children}
, +})) + +vi.mock('./model-selector-trigger', () => ({ + default: ({ open, readonly }: { open: boolean, readonly?: boolean }) => ( + + {open ? 'open' : 'closed'} + - + {readonly ? 'readonly' : 'editable'} + + ), +})) + +vi.mock('./popup', () => ({ + default: ({ onHide }: { onHide: () => void }) => ( +
+ +
+ ), +})) + +describe('ModelSelector popover branches', () => { + beforeEach(() => { + vi.clearAllMocks() + latestOnOpenChange = undefined + }) + + it('should open and close through popover callbacks when editable', () => { + const onHide = vi.fn() + render() + + act(() => { + latestOnOpenChange?.(true) + }) + + expect(screen.getByText('open-editable')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'hide-popup' })) + + expect(screen.getByText('closed-editable')).toBeInTheDocument() + expect(onHide).toHaveBeenCalledTimes(1) + }) + + it('should ignore popover open changes when readonly', () => { + render() + + act(() => { + latestOnOpenChange?.(true) + }) + + expect(screen.getByText('closed-readonly')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.spec.tsx index b4e9220cfc..1e649a33e2 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.spec.tsx @@ -12,12 +12,13 @@ import PopupItem from './popup-item' const mockUpdateModelList = vi.hoisted(() => vi.fn()) const mockUpdateModelProviders = vi.hoisted(() => vi.fn()) +const mockUseLanguage = vi.hoisted(() => vi.fn(() => 'en_US')) vi.mock('../hooks', async () => { const actual = await vi.importActual('../hooks') return { ...actual, - useLanguage: () => 'en_US', + useLanguage: mockUseLanguage, useUpdateModelList: () => mockUpdateModelList, useUpdateModelProviders: () => mockUpdateModelProviders, } @@ -43,6 +44,12 @@ vi.mock('@/app/components/base/tooltip', () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
, })) +vi.mock('@/app/components/base/ui/popover', () => ({ + Popover: ({ children }: { children: React.ReactNode }) =>
{children}
, + PopoverTrigger: ({ render }: { render: React.ReactNode }) => <>{render}, + PopoverContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + const mockCredentialPanelState = vi.hoisted(() => vi.fn()) vi.mock('../provider-added-card/use-credential-panel-state', () => ({ useCredentialPanelState: mockCredentialPanelState, @@ -56,7 +63,7 @@ vi.mock('../provider-added-card/use-change-provider-priority', () => ({ })) vi.mock('../provider-added-card/model-auth-dropdown/dropdown-content', () => ({ - default: () => null, + default: ({ onClose }: { onClose: () => void }) => , })) const mockSetShowModelModal = vi.hoisted(() => vi.fn()) @@ -110,6 +117,7 @@ const makeProvider = (overrides: Record = {}) => ({ describe('PopupItem', () => { beforeEach(() => { vi.clearAllMocks() + mockUseLanguage.mockReturnValue('en_US') mockUseProviderContext.mockReturnValue({ modelProviders: [makeProvider()], }) @@ -215,6 +223,24 @@ describe('PopupItem', () => { expect(screen.getByText('GPT-4')).toBeInTheDocument() }) + it('should fall back to english labels when the current language is unavailable', () => { + mockUseLanguage.mockReturnValue('zh_Hans') + + render( + , + ) + + expect(screen.getByText('OpenAI only')).toBeInTheDocument() + expect(screen.getByText('GPT-4 only')).toBeInTheDocument() + }) + it('should toggle collapsed state when clicking provider header', () => { render() @@ -235,6 +261,24 @@ describe('PopupItem', () => { expect(screen.getByText('my-api-key')).toBeInTheDocument() }) + it('should render the inactive credential badge when the api key is not active', () => { + mockCredentialPanelState.mockReturnValue({ + variant: 'api-inactive', + priority: 'apiKey', + supportsCredits: false, + showPrioritySwitcher: false, + hasCredentials: true, + isCreditsExhausted: false, + credentialName: 'stale-key', + credits: 200, + }) + + render() + + expect(screen.getByText('stale-key')).toBeInTheDocument() + expect(document.querySelector('.bg-components-badge-status-light-error-bg')).not.toBeNull() + }) + it('should show configure required when no credential name', () => { mockUseProviderContext.mockReturnValue({ modelProviders: [makeProvider({ @@ -306,4 +350,14 @@ describe('PopupItem', () => { expect(screen.getByText(/modelProvider\.selector\.creditsExhausted/)).toBeInTheDocument() }) + + it('should close the dropdown through dropdown content callbacks', () => { + const onHide = vi.fn() + + render() + + fireEvent.click(screen.getByRole('button', { name: 'close dropdown' })) + + expect(onHide).toHaveBeenCalled() + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.spec.tsx index 1b134f5c9d..7cf65356d1 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.spec.tsx @@ -553,4 +553,60 @@ describe('Popup', () => { }) expect(mockRefreshPluginList).toHaveBeenCalled() }) + + it('should skip install requests when marketplace plugins are still loading', async () => { + mockMarketplacePlugins.current = [ + { plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' }, + ] + mockMarketplacePlugins.isLoading = true + + render( + , + ) + + fireEvent.click(screen.getAllByText(/common\.modelProvider\.selector\.install/)[0]) + + await waitFor(() => { + expect(mockInstallMutateAsync).not.toHaveBeenCalled() + }) + }) + + it('should skip install requests when the marketplace plugin cannot be found', async () => { + mockMarketplacePlugins.current = [] + + render( + , + ) + + fireEvent.click(screen.getAllByText(/common\.modelProvider\.selector\.install/)[0]) + + await waitFor(() => { + expect(mockInstallMutateAsync).not.toHaveBeenCalled() + }) + }) + + it('should sort the selected provider to the top when a default model is provided', () => { + render( + , + ) + + const providerLabels = screen.getAllByText(/openai|anthropic/) + expect(providerLabels[0]).toHaveTextContent('anthropic') + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.spec.tsx index 83354bc149..c1c5d33cf4 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.spec.tsx @@ -1,16 +1,41 @@ -import { render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' import CreditsExhaustedAlert from './credits-exhausted-alert' const mockTrialCredits = { credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false, nextCreditResetDate: undefined } +const mockSetShowPricingModal = vi.fn() + +vi.mock('react-i18next', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + Trans: ({ + i18nKey, + components, + }: { + i18nKey?: string + components: { upgradeLink: ReactNode } + }) => ( + <> + {i18nKey} + {components.upgradeLink} + + ), + } +}) vi.mock('../use-trial-credits', () => ({ useTrialCredits: () => mockTrialCredits, })) +vi.mock('@/context/modal-context', () => ({ + useModalContextSelector: () => mockSetShowPricingModal, +})) + describe('CreditsExhaustedAlert', () => { beforeEach(() => { vi.clearAllMocks() - Object.assign(mockTrialCredits, { credits: 0 }) + Object.assign(mockTrialCredits, { credits: 0, totalCredits: 10_000 }) }) // Without API key fallback @@ -59,5 +84,21 @@ describe('CreditsExhaustedAlert', () => { expect(screen.getByText(/9,800/)).toBeInTheDocument() expect(screen.getByText(/10,000/)).toBeInTheDocument() }) + + it('should cap progress at 100 percent when total credits are zero', () => { + Object.assign(mockTrialCredits, { credits: 0, totalCredits: 0 }) + + const { container } = render() + + expect(container.querySelector('.bg-components-progress-error-progress')).toHaveStyle({ width: '100%' }) + }) + + it('should open the pricing modal when the upgrade link is clicked', () => { + const { container } = render() + + fireEvent.click(container.querySelector('button') as HTMLButtonElement) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dialog.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dialog.spec.tsx new file mode 100644 index 0000000000..f13125bb4d --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dialog.spec.tsx @@ -0,0 +1,152 @@ +import type { ReactNode } from 'react' +import type { ModelProvider } from '../../declarations' +import type { CredentialPanelState } from '../use-credential-panel-state' +import { act, fireEvent, render, screen } from '@testing-library/react' +import DropdownContent from './dropdown-content' + +type AlertDialogProps = { + children: ReactNode + onOpenChange?: (open: boolean) => void +} + +let latestOnOpenChange: AlertDialogProps['onOpenChange'] +const mockOpenConfirmDelete = vi.fn() +const mockCloseConfirmDelete = vi.fn() +const mockHandleConfirmDelete = vi.fn() +const mockHandleOpenModal = vi.fn() + +vi.mock('../../model-auth/hooks', () => ({ + useAuth: () => ({ + openConfirmDelete: mockOpenConfirmDelete, + closeConfirmDelete: mockCloseConfirmDelete, + doingAction: false, + handleConfirmDelete: mockHandleConfirmDelete, + deleteCredentialId: 'cred-1', + handleOpenModal: mockHandleOpenModal, + }), +})) + +vi.mock('./use-activate-credential', () => ({ + useActivateCredential: () => ({ + selectedCredentialId: 'cred-1', + isActivating: false, + activate: vi.fn(), + }), +})) + +vi.mock('@/app/components/base/ui/alert-dialog', () => ({ + AlertDialog: ({ children, onOpenChange }: AlertDialogProps) => { + latestOnOpenChange = onOpenChange + return
{children}
+ }, + AlertDialogActions: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogCancelButton: ({ children }: { children: ReactNode }) => , + AlertDialogConfirmButton: ({ children, onClick }: { children: ReactNode, onClick?: () => void }) => , + AlertDialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogDescription: () =>
, + AlertDialogTitle: ({ children }: { children: ReactNode }) =>
{children}
, +})) + +vi.mock('./api-key-section', () => ({ + default: ({ credentials, onDelete }: { credentials: unknown[], onDelete: (credential?: unknown) => void }) => ( +
+ {`credentials:${credentials.length}`} + +
+ ), +})) + +vi.mock('./credits-exhausted-alert', () => ({ + default: () =>
credits alert
, +})) + +vi.mock('./credits-fallback-alert', () => ({ + default: () =>
fallback alert
, +})) + +vi.mock('./usage-priority-section', () => ({ + default: () =>
priority section
, +})) + +const createProvider = (overrides: Partial = {}): ModelProvider => ({ + provider: 'test', + custom_configuration: { + available_credentials: undefined, + }, + system_configuration: { + enabled: true, + quota_configurations: [], + current_quota_type: 'trial', + }, + configurate_methods: [], + supported_model_types: [], + ...overrides, +} as unknown as ModelProvider) + +const createState = (overrides: Partial = {}): CredentialPanelState => ({ + variant: 'api-active', + priority: 'apiKey', + supportsCredits: true, + showPrioritySwitcher: false, + hasCredentials: false, + isCreditsExhausted: false, + credentialName: undefined, + credits: 0, + ...overrides, +}) + +describe('DropdownContent dialog branches', () => { + beforeEach(() => { + vi.clearAllMocks() + latestOnOpenChange = undefined + }) + + it('should fall back to an empty credential list when the provider has no credentials', () => { + render( + , + ) + + expect(screen.getByText('credentials:0')).toBeInTheDocument() + }) + + it('should ignore delete requests without a credential payload', () => { + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'delete-undefined' })) + + expect(mockOpenConfirmDelete).not.toHaveBeenCalled() + }) + + it('should only close the confirm dialog when the alert dialog reports closed', () => { + render( + , + ) + + act(() => { + latestOnOpenChange?.(true) + latestOnOpenChange?.(false) + }) + + expect(mockCloseConfirmDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.spec.tsx index a4d2c9724f..30c477f3eb 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.spec.tsx @@ -223,4 +223,42 @@ describe('ProviderCardActions', () => { expect(mockSetTargetVersion).not.toHaveBeenCalled() expect(mockHandleUpdate).toHaveBeenCalledWith() }) + + it('should fall back to the detail name when declaration metadata is missing', () => { + render( + , + ) + + expect(mockGetMarketplaceUrl).toHaveBeenCalledWith('/plugins//provider-plugin', { + language: 'en-US', + theme: 'light', + }) + }) + + it('should leave the detail url empty when a GitHub plugin has no repo or the source is unsupported', () => { + const { rerender } = render( + , + ) + + expect(screen.getByTestId('operation-dropdown')).toHaveAttribute('data-detail-url', '') + + rerender( + , + ) + + expect(screen.getByTestId('operation-dropdown')).toHaveAttribute('data-detail-url', '') + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.spec.tsx index 01e14e29c7..13f2473ff3 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.spec.tsx @@ -12,7 +12,7 @@ let mockWorkspaceData: { next_credit_reset_date: '2024-12-31', } let mockWorkspaceIsPending = false -let mockTrialModels: string[] = ['langgenius/openai/openai'] +let mockTrialModels: string[] | undefined = ['langgenius/openai/openai'] let mockPlugins = [{ plugin_id: 'langgenius/openai', latest_package_identifier: 'openai@1.0.0', @@ -39,9 +39,7 @@ vi.mock('@/service/use-common', () => ({ vi.mock('@/context/global-public-context', () => ({ useSystemFeaturesQuery: () => ({ - data: { - trial_models: mockTrialModels, - }, + data: mockTrialModels ? { trial_models: mockTrialModels } : undefined, }), })) @@ -149,4 +147,37 @@ describe('QuotaPanel', () => { expect(screen.queryByText('install modal')).not.toBeInTheDocument() }) }) + + it('should tolerate missing trial model configuration', () => { + mockTrialModels = undefined + + render() + + expect(screen.queryByText('openai')).not.toBeInTheDocument() + }) + + it('should render installed custom providers without opening the install modal', () => { + render() + + expect(screen.getByLabelText(/modelAPI/)).toBeInTheDocument() + + fireEvent.click(screen.getByText('openai')) + + expect(screen.queryByText('install modal')).not.toBeInTheDocument() + }) + + it('should show the supported-model tooltip for installed non-custom providers', () => { + render( + , + ) + + expect(screen.getByLabelText(/modelSupported/)).toBeInTheDocument() + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state.spec.ts b/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state.spec.ts index 59fc446b5c..361501b15d 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state.spec.ts +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state.spec.ts @@ -172,6 +172,24 @@ describe('useCredentialPanelState', () => { expect(result.current.variant).toBe('api-unavailable') }) + + it('should return api-required-configure when credentials exist but the current credential is incomplete', () => { + mockTrialCredits.isExhausted = true + mockTrialCredits.credits = 0 + const provider = createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: 'cred-1', + current_credential_name: undefined, + available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }], + }, + }) + + const { result } = renderHook(() => useCredentialPanelState(provider)) + + expect(result.current.variant).toBe('api-required-configure') + }) }) // apiKeyOnly priority diff --git a/web/app/components/header/account-setting/model-provider-page/provider-icon/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-icon/index.spec.tsx index 3123fbab3b..3b05fb7dbb 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-icon/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-icon/index.spec.tsx @@ -74,6 +74,26 @@ describe('ProviderIcon', () => { expect(screen.getByTestId('openai-icon')).toBeInTheDocument() }) + it('should apply custom className to special provider wrappers', () => { + const { rerender, container } = render( + , + ) + + expect(container.firstChild).toHaveClass('custom-wrapper') + + rerender( + , + ) + + expect(container.firstChild).toHaveClass('custom-wrapper') + }) + it('should render generic provider with image and label', () => { const provider = createProvider({ label: { en_US: 'Custom', zh_Hans: '自定义' } }) render() @@ -94,4 +114,19 @@ describe('ProviderIcon', () => { const img = screen.getByAltText('provider-icon') as HTMLImageElement expect(img.src).toBe('https://example.com/dark.png') }) + + it('should fall back to localized labels when available', () => { + const mockLang = vi.mocked(useLanguage) + mockLang.mockReturnValue('zh_Hans') + + render( + , + ) + + expect(screen.getByText('自定义')).toBeInTheDocument() + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.spec.tsx index dc036b9cf9..490c951b17 100644 --- a/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.spec.tsx @@ -115,6 +115,12 @@ describe('SystemModel', () => { expect(screen.getByRole('button', { name: /system model settings/i })).toBeDisabled() }) + it('should render the primary button variant when configuration is required', () => { + render() + + expect(screen.getByRole('button', { name: /system model settings/i })).toHaveClass('btn-primary') + }) + it('should close dialog when cancel is clicked', async () => { render() fireEvent.click(screen.getByRole('button', { name: /system model settings/i })) @@ -151,6 +157,27 @@ describe('SystemModel', () => { }) }) + it('should keep the dialog open when saving does not succeed', async () => { + mockUpdateDefaultModel.mockResolvedValueOnce({ result: 'failed' }) + + render() + + fireEvent.click(screen.getByRole('button', { name: /system model settings/i })) + await waitFor(() => { + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: /save/i })) + + await waitFor(() => { + expect(mockUpdateDefaultModel).toHaveBeenCalledTimes(1) + }) + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument() + expect(mockNotify).not.toHaveBeenCalled() + expect(mockInvalidateDefaultModel).not.toHaveBeenCalled() + expect(mockUpdateModelList).not.toHaveBeenCalled() + }) + it('should disable save when user is not workspace manager', async () => { mockIsCurrentWorkspaceManager = false render() diff --git a/web/app/components/plugins/__tests__/utils.spec.ts b/web/app/components/plugins/__tests__/utils.spec.ts index 0dc166b175..c626bdff7f 100644 --- a/web/app/components/plugins/__tests__/utils.spec.ts +++ b/web/app/components/plugins/__tests__/utils.spec.ts @@ -1,7 +1,17 @@ import type { TagKey } from '../constants' +import type { Plugin } from '../types' import { describe, expect, it } from 'vitest' +import { API_PREFIX, MARKETPLACE_API_PREFIX } from '@/config' import { PluginCategoryEnum } from '../types' -import { getValidCategoryKeys, getValidTagKeys } from '../utils' +import { getPluginCardIconUrl, getValidCategoryKeys, getValidTagKeys } from '../utils' + +const createPlugin = (overrides: Partial> = {}): Pick => ({ + from: 'github', + name: 'demo-plugin', + org: 'langgenius', + type: 'plugin', + ...overrides, +}) describe('plugins/utils', () => { describe('getValidTagKeys', () => { @@ -47,4 +57,31 @@ describe('plugins/utils', () => { expect(getValidCategoryKeys('')).toBeUndefined() }) }) + + describe('getPluginCardIconUrl', () => { + it('returns an empty string when icon is missing', () => { + expect(getPluginCardIconUrl(createPlugin(), undefined, 'tenant-1')).toBe('') + }) + + it('returns absolute urls and root-relative urls as-is', () => { + expect(getPluginCardIconUrl(createPlugin(), 'https://example.com/icon.png', 'tenant-1')).toBe('https://example.com/icon.png') + expect(getPluginCardIconUrl(createPlugin(), '/icons/demo.png', 'tenant-1')).toBe('/icons/demo.png') + }) + + it('builds the marketplace icon url for plugins and bundles', () => { + expect(getPluginCardIconUrl(createPlugin({ from: 'marketplace' }), 'icon.png', 'tenant-1')) + .toBe(`${MARKETPLACE_API_PREFIX}/plugins/langgenius/demo-plugin/icon`) + expect(getPluginCardIconUrl(createPlugin({ from: 'marketplace', type: 'bundle' }), 'icon.png', 'tenant-1')) + .toBe(`${MARKETPLACE_API_PREFIX}/bundles/langgenius/demo-plugin/icon`) + }) + + it('falls back to the raw icon when tenant id is missing for non-marketplace plugins', () => { + expect(getPluginCardIconUrl(createPlugin(), 'icon.png', '')).toBe('icon.png') + }) + + it('builds the workspace icon url for tenant-scoped plugins', () => { + expect(getPluginCardIconUrl(createPlugin(), 'icon.png', 'tenant-1')) + .toBe(`${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=tenant-1&filename=icon.png`) + }) + }) }) diff --git a/web/app/components/plugins/hooks.spec.ts b/web/app/components/plugins/hooks.spec.ts new file mode 100644 index 0000000000..6eeaa99d4c --- /dev/null +++ b/web/app/components/plugins/hooks.spec.ts @@ -0,0 +1,128 @@ +import type { PluginDetail } from './types' +import { useQuery } from '@tanstack/react-query' +import { renderHook } from '@testing-library/react' +import { consoleQuery } from '@/service/client' +import { usePluginsWithLatestVersion } from './hooks' +import { PluginSource } from './types' + +vi.mock('@tanstack/react-query', () => ({ + useQuery: vi.fn(), +})) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + plugins: { + latestVersions: { + queryOptions: vi.fn((options: unknown) => options), + }, + }, + }, +})) + +const createPlugin = (overrides: Partial = {}): PluginDetail => ({ + id: 'plugin-1', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + name: 'demo-plugin', + plugin_id: 'plugin-1', + plugin_unique_identifier: 'plugin-1@1.0.0', + declaration: {} as PluginDetail['declaration'], + installation_id: 'installation-1', + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '1.0.0', + latest_unique_identifier: 'plugin-1@1.0.0', + source: PluginSource.marketplace, + meta: undefined, + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', + ...overrides, +}) + +describe('usePluginsWithLatestVersion', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useQuery).mockReturnValue({ data: undefined } as never) + }) + + it('should disable latest-version querying when there are no marketplace plugins', () => { + const plugins = [ + createPlugin({ plugin_id: 'github-plugin', source: PluginSource.github }), + ] + + const { result } = renderHook(() => usePluginsWithLatestVersion(plugins)) + + expect(consoleQuery.plugins.latestVersions.queryOptions).toHaveBeenCalledWith({ + input: { body: { plugin_ids: [] } }, + enabled: false, + }) + expect(result.current).toEqual(plugins) + }) + + it('should return the original plugins when version data is unavailable', () => { + const plugins = [createPlugin()] + + const { result } = renderHook(() => usePluginsWithLatestVersion(plugins)) + + expect(result.current).toEqual(plugins) + }) + + it('should keep plugins unchanged when a plugin has no matching latest version', () => { + const plugins = [createPlugin()] + vi.mocked(useQuery).mockReturnValue({ + data: { versions: {} }, + } as never) + + const { result } = renderHook(() => usePluginsWithLatestVersion(plugins)) + + expect(result.current).toEqual(plugins) + }) + + it('should merge latest version fields for marketplace plugins with version data', () => { + const plugins = [ + createPlugin(), + createPlugin({ + id: 'plugin-2', + plugin_id: 'plugin-2', + plugin_unique_identifier: 'plugin-2@1.0.0', + latest_version: '1.0.0', + latest_unique_identifier: 'plugin-2@1.0.0', + source: PluginSource.github, + }), + ] + vi.mocked(useQuery).mockReturnValue({ + data: { + versions: { + 'plugin-1': { + version: '1.1.0', + unique_identifier: 'plugin-1@1.1.0', + status: 'deleted', + deprecated_reason: 'replaced', + alternative_plugin_id: 'plugin-3', + }, + }, + }, + } as never) + + const { result } = renderHook(() => usePluginsWithLatestVersion(plugins)) + + expect(consoleQuery.plugins.latestVersions.queryOptions).toHaveBeenCalledWith({ + input: { body: { plugin_ids: ['plugin-1'] } }, + enabled: true, + }) + expect(result.current).toEqual([ + expect.objectContaining({ + plugin_id: 'plugin-1', + latest_version: '1.1.0', + latest_unique_identifier: 'plugin-1@1.1.0', + status: 'deleted', + deprecated_reason: 'replaced', + alternative_plugin_id: 'plugin-3', + }), + plugins[1], + ]) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/__tests__/use-install-multi-state.spec.ts b/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/__tests__/use-install-multi-state.spec.ts index 1cf3495481..47f85d35cf 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/__tests__/use-install-multi-state.spec.ts +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/__tests__/use-install-multi-state.spec.ts @@ -267,6 +267,34 @@ describe('useInstallMultiState', () => { }) }) + it('should fall back to latest_version when marketplace plugin version is missing', async () => { + mockMarketplaceData = { + data: { + list: [{ + plugin: { + plugin_id: 'test-org/plugin-0', + org: 'test-org', + name: 'Test Plugin 0', + version: '', + latest_version: '2.0.0', + }, + version: { + unique_identifier: 'plugin-0-uid', + }, + }], + }, + } + + const params = createDefaultParams({ + allPlugins: [createMarketplaceDependency(0)] as Dependency[], + }) + const { result } = renderHook(() => useInstallMultiState(params)) + + await waitFor(() => { + expect(result.current.plugins[0]?.version).toBe('2.0.0') + }) + }) + it('should resolve marketplace dependency from organization and plugin fields', async () => { mockMarketplaceData = createMarketplaceApiData([0]) @@ -293,6 +321,44 @@ describe('useInstallMultiState', () => { // ==================== Error Handling ==================== describe('Error Handling', () => { + it('should mark marketplace index as error when identifier misses plugin and version parts', async () => { + const params = createDefaultParams({ + allPlugins: [ + { + type: 'marketplace', + value: { + marketplace_plugin_unique_identifier: 'invalid-identifier', + version: '1.0.0', + }, + } as GitHubItemAndMarketPlaceDependency, + ] as Dependency[], + }) + const { result } = renderHook(() => useInstallMultiState(params)) + + await waitFor(() => { + expect(result.current.errorIndexes).toContain(0) + }) + }) + + it('should mark marketplace index as error when identifier has an empty plugin segment', async () => { + const params = createDefaultParams({ + allPlugins: [ + { + type: 'marketplace', + value: { + marketplace_plugin_unique_identifier: 'test-org/:1.0.0', + version: '1.0.0', + }, + } as GitHubItemAndMarketPlaceDependency, + ] as Dependency[], + }) + const { result } = renderHook(() => useInstallMultiState(params)) + + await waitFor(() => { + expect(result.current.errorIndexes).toContain(0) + }) + }) + it('should mark marketplace index as error when identifier is missing', async () => { const params = createDefaultParams({ allPlugins: [ @@ -344,6 +410,19 @@ describe('useInstallMultiState', () => { expect(result.current.errorIndexes).not.toContain(0) }) }) + + it('should ignore marketplace requests whose dsl index cannot be mapped', () => { + const duplicatedMarketplaceDependency = createMarketplaceDependency(0) + const allPlugins = [duplicatedMarketplaceDependency] as Dependency[] + + allPlugins.filter = vi.fn(() => [duplicatedMarketplaceDependency, duplicatedMarketplaceDependency]) as typeof allPlugins.filter + + const params = createDefaultParams({ allPlugins }) + const { result } = renderHook(() => useInstallMultiState(params)) + + expect(result.current.plugins).toHaveLength(1) + expect(result.current.errorIndexes).toEqual([]) + }) }) // ==================== Loaded All Data Notification ==================== diff --git a/web/app/components/workflow/header/checklist/index.spec.tsx b/web/app/components/workflow/header/checklist/index.spec.tsx new file mode 100644 index 0000000000..6a31bd6a74 --- /dev/null +++ b/web/app/components/workflow/header/checklist/index.spec.tsx @@ -0,0 +1,131 @@ +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { BlockEnum } from '../../types' +import WorkflowChecklist from './index' + +let mockChecklistItems = [ + { + id: 'plugin-1', + type: BlockEnum.Tool, + title: 'Missing Plugin', + errorMessages: [], + canNavigate: false, + isPluginMissing: true, + }, + { + id: 'node-1', + type: BlockEnum.LLM, + title: 'Broken Node', + errorMessages: ['Needs configuration'], + canNavigate: true, + isPluginMissing: false, + }, +] + +const mockHandleNodeSelect = vi.fn() + +type PopoverProps = { + children: ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void +} + +let latestOnOpenChange: PopoverProps['onOpenChange'] + +vi.mock('reactflow', () => ({ + useEdges: () => [], +})) + +vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ + default: () => [], +})) + +vi.mock('../../hooks', () => ({ + useChecklist: () => mockChecklistItems, + useNodesInteractions: () => ({ + handleNodeSelect: mockHandleNodeSelect, + }), +})) + +vi.mock('@/app/components/base/ui/popover', () => ({ + Popover: ({ children, onOpenChange }: PopoverProps) => { + latestOnOpenChange = onOpenChange + return
{children}
+ }, + PopoverTrigger: ({ render }: { render: ReactNode }) => <>{render}, + PopoverContent: ({ children }: { children: ReactNode }) =>
{children}
, + PopoverClose: ({ children, className }: { children: ReactNode, className?: string }) => , +})) + +vi.mock('./plugin-group', () => ({ + ChecklistPluginGroup: ({ items }: { items: Array<{ title: string }> }) =>
{items.map(item => item.title).join(',')}
, +})) + +vi.mock('./node-group', () => ({ + ChecklistNodeGroup: ({ item, onItemClick }: { item: { title: string }, onItemClick: (item: { title: string }) => void }) => ( + + ), +})) + +describe('WorkflowChecklist', () => { + beforeEach(() => { + vi.clearAllMocks() + latestOnOpenChange = undefined + mockChecklistItems = [ + { + id: 'plugin-1', + type: BlockEnum.Tool, + title: 'Missing Plugin', + errorMessages: [], + canNavigate: false, + isPluginMissing: true, + }, + { + id: 'node-1', + type: BlockEnum.LLM, + title: 'Broken Node', + errorMessages: ['Needs configuration'], + canNavigate: true, + isPluginMissing: false, + }, + ] + }) + + it('should split checklist items into plugin and node groups and delegate clicks to node selection by default', () => { + render() + + expect(screen.getByText('2')).toBeInTheDocument() + expect(screen.getByTestId('plugin-group')).toHaveTextContent('Missing Plugin') + fireEvent.click(screen.getByTestId('node-group-Broken Node')) + + expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1') + }) + + it('should use the custom item click handler when provided', () => { + const onItemClick = vi.fn() + render() + + fireEvent.click(screen.getByTestId('node-group-Broken Node')) + + expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'node-1' })) + expect(mockHandleNodeSelect).not.toHaveBeenCalled() + }) + + it('should render the resolved state when there are no checklist warnings', () => { + mockChecklistItems = [] + + render() + + expect(screen.getByText(/checklistResolved/i)).toBeInTheDocument() + }) + + it('should ignore popover open changes when the checklist is disabled', () => { + render() + + latestOnOpenChange?.(true) + + expect(screen.getByText('2').closest('button')).toBeDisabled() + }) +}) diff --git a/web/app/components/workflow/header/checklist/node-group.spec.tsx b/web/app/components/workflow/header/checklist/node-group.spec.tsx new file mode 100644 index 0000000000..25f54211b4 --- /dev/null +++ b/web/app/components/workflow/header/checklist/node-group.spec.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { BlockEnum } from '../../types' +import { ChecklistNodeGroup } from './node-group' + +vi.mock('../../block-icon', () => ({ + default: () =>
, +})) + +vi.mock('./item-indicator', () => ({ + ItemIndicator: () =>
, +})) + +const createItem = (overrides: Record = {}) => ({ + id: 'node-1', + type: BlockEnum.LLM, + title: 'Broken Node', + errorMessages: ['Needs configuration'], + canNavigate: true, + disableGoTo: false, + unConnected: false, + ...overrides, +}) + +describe('ChecklistNodeGroup', () => { + it('should render errors and the connection warning, and allow navigation when go-to is enabled', () => { + const onItemClick = vi.fn() + + render( + , + ) + + expect(screen.getByText('Needs configuration')).toBeInTheDocument() + expect(screen.getByText(/needConnectTip/i)).toBeInTheDocument() + expect(screen.getAllByText(/goToFix/i)).toHaveLength(2) + + fireEvent.click(screen.getByText('Needs configuration')) + + expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'node-1' })) + }) + + it('should not allow navigation when go-to is disabled', () => { + const onItemClick = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByText('Needs configuration')) + + expect(onItemClick).not.toHaveBeenCalled() + expect(screen.queryByText(/goToFix/i)).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/header/checklist/plugin-group.spec.tsx b/web/app/components/workflow/header/checklist/plugin-group.spec.tsx index a2f2962afe..1d9f0dc7d6 100644 --- a/web/app/components/workflow/header/checklist/plugin-group.spec.tsx +++ b/web/app/components/workflow/header/checklist/plugin-group.spec.tsx @@ -76,4 +76,21 @@ describe('ChecklistPluginGroup', () => { fireEvent.click(installButton) expect(usePluginDependencyStore.getState().dependencies).toEqual([]) }) + + it('should omit the version when the marketplace identifier does not include one', () => { + renderInPopover([createChecklistItem({ pluginUniqueIdentifier: 'langgenius/test-plugin@sha256' })]) + + fireEvent.click(getInstallButton()) + + expect(usePluginDependencyStore.getState().dependencies).toEqual([ + { + type: 'marketplace', + value: { + marketplace_plugin_unique_identifier: 'langgenius/test-plugin@sha256', + plugin_unique_identifier: 'langgenius/test-plugin@sha256', + version: undefined, + }, + }, + ]) + }) }) diff --git a/web/app/components/workflow/header/run-mode.spec.tsx b/web/app/components/workflow/header/run-mode.spec.tsx new file mode 100644 index 0000000000..2f44d4a21b --- /dev/null +++ b/web/app/components/workflow/header/run-mode.spec.tsx @@ -0,0 +1,150 @@ +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { WorkflowRunningStatus } from '@/app/components/workflow/types' +import RunMode from './run-mode' +import { TriggerType } from './test-run-menu' + +const mockHandleWorkflowStartRunInWorkflow = vi.fn() +const mockHandleWorkflowTriggerScheduleRunInWorkflow = vi.fn() +const mockHandleWorkflowTriggerWebhookRunInWorkflow = vi.fn() +const mockHandleWorkflowTriggerPluginRunInWorkflow = vi.fn() +const mockHandleWorkflowRunAllTriggersInWorkflow = vi.fn() +const mockHandleStopRun = vi.fn() +const mockNotify = vi.fn() +const mockTrackEvent = vi.fn() + +let mockWarningNodes: Array<{ id: string }> = [] +let mockWorkflowRunningData: { result: { status: WorkflowRunningStatus }, task_id: string } | undefined +let mockIsListening = false +let mockDynamicOptions = [ + { type: TriggerType.UserInput, nodeId: 'start-node' }, +] + +vi.mock('@/app/components/workflow/hooks', () => ({ + useWorkflowStartRun: () => ({ + handleWorkflowStartRunInWorkflow: mockHandleWorkflowStartRunInWorkflow, + handleWorkflowTriggerScheduleRunInWorkflow: mockHandleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerWebhookRunInWorkflow: mockHandleWorkflowTriggerWebhookRunInWorkflow, + handleWorkflowTriggerPluginRunInWorkflow: mockHandleWorkflowTriggerPluginRunInWorkflow, + handleWorkflowRunAllTriggersInWorkflow: mockHandleWorkflowRunAllTriggersInWorkflow, + }), + useWorkflowRun: () => ({ + handleStopRun: mockHandleStopRun, + }), + useWorkflowRunValidation: () => ({ + warningNodes: mockWarningNodes, + }), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { workflowRunningData?: unknown, isListening: boolean }) => unknown) => + selector({ workflowRunningData: mockWorkflowRunningData, isListening: mockIsListening }), +})) + +vi.mock('../hooks/use-dynamic-test-run-options', () => ({ + useDynamicTestRunOptions: () => mockDynamicOptions, +})) + +vi.mock('@/app/components/base/toast/context', () => ({ + useToastContext: () => ({ + notify: mockNotify, + }), +})) + +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: (...args: unknown[]) => mockTrackEvent(...args), +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { + useSubscription: vi.fn(), + }, + }), +})) + +vi.mock('@/app/components/workflow/shortcuts-name', () => ({ + default: () => Shortcut, +})) + +vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({ + StopCircle: () => , +})) + +vi.mock('./test-run-menu', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + default: React.forwardRef(({ children, options, onSelect }: { children: ReactNode, options: Array<{ type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }>, onSelect: (option: { type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }) => void }, ref) => { + React.useImperativeHandle(ref, () => ({ + toggle: vi.fn(), + })) + return ( +
+ + {children} +
+ ) + }), + } +}) + +describe('RunMode', () => { + beforeEach(() => { + vi.clearAllMocks() + mockWarningNodes = [] + mockWorkflowRunningData = undefined + mockIsListening = false + mockDynamicOptions = [ + { type: TriggerType.UserInput, nodeId: 'start-node' }, + ] + }) + + it('should render the run trigger and start the workflow when a valid trigger is selected', () => { + render() + + expect(screen.getByText(/run/i)).toBeInTheDocument() + fireEvent.click(screen.getByTestId('trigger-option')) + + expect(mockHandleWorkflowStartRunInWorkflow).toHaveBeenCalledTimes(1) + expect(mockTrackEvent).toHaveBeenCalledWith('app_start_action_time', { action_type: 'user_input' }) + }) + + it('should show an error toast instead of running when the selected trigger has checklist warnings', () => { + mockWarningNodes = [{ id: 'start-node' }] + + render() + fireEvent.click(screen.getByTestId('trigger-option')) + + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'workflow.panel.checklistTip', + }) + expect(mockHandleWorkflowStartRunInWorkflow).not.toHaveBeenCalled() + }) + + it('should render the running state and stop the workflow when it is already running', () => { + mockWorkflowRunningData = { + result: { status: WorkflowRunningStatus.Running }, + task_id: 'task-1', + } + + render() + + expect(screen.getByText(/running/i)).toBeInTheDocument() + fireEvent.click(screen.getByTestId('stop-circle').closest('button') as HTMLButtonElement) + + expect(mockHandleStopRun).toHaveBeenCalledWith('task-1') + }) + + it('should render the listening label when the workflow is listening', () => { + mockIsListening = true + + render() + + expect(screen.getByText(/listening/i)).toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-node-plugin-installation.spec.ts b/web/app/components/workflow/hooks/__tests__/use-node-plugin-installation.spec.ts new file mode 100644 index 0000000000..4577055098 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-node-plugin-installation.spec.ts @@ -0,0 +1,253 @@ +import type { CommonNodeType } from '../../types' +import { act } from '@testing-library/react' +import { CollectionType } from '@/app/components/tools/types' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { BlockEnum } from '../../types' +import { useNodePluginInstallation } from '../use-node-plugin-installation' + +const mockBuiltInTools = vi.fn() +const mockCustomTools = vi.fn() +const mockWorkflowTools = vi.fn() +const mockMcpTools = vi.fn() +const mockInvalidToolsByType = vi.fn() +const mockTriggerPlugins = vi.fn() +const mockInvalidateTriggers = vi.fn() +const mockInvalidDataSourceList = vi.fn() + +vi.mock('@/service/use-tools', () => ({ + useAllBuiltInTools: (enabled: boolean) => mockBuiltInTools(enabled), + useAllCustomTools: (enabled: boolean) => mockCustomTools(enabled), + useAllWorkflowTools: (enabled: boolean) => mockWorkflowTools(enabled), + useAllMCPTools: (enabled: boolean) => mockMcpTools(enabled), + useInvalidToolsByType: (providerType?: string) => mockInvalidToolsByType(providerType), +})) + +vi.mock('@/service/use-triggers', () => ({ + useAllTriggerPlugins: (enabled: boolean) => mockTriggerPlugins(enabled), + useInvalidateAllTriggerPlugins: () => mockInvalidateTriggers, +})) + +vi.mock('@/service/use-pipeline', () => ({ + useInvalidDataSourceList: () => mockInvalidDataSourceList, +})) + +const makeToolNode = (overrides: Partial = {}) => ({ + type: BlockEnum.Tool, + title: 'Tool node', + desc: '', + provider_type: CollectionType.builtIn, + provider_id: 'search', + provider_name: 'search', + plugin_id: 'plugin-search', + plugin_unique_identifier: 'plugin-search@1.0.0', + ...overrides, +}) as CommonNodeType + +const makeTriggerNode = (overrides: Partial = {}) => ({ + type: BlockEnum.TriggerPlugin, + title: 'Trigger node', + desc: '', + provider_id: 'trigger-provider', + provider_name: 'trigger-provider', + plugin_id: 'trigger-plugin', + plugin_unique_identifier: 'trigger-plugin@1.0.0', + ...overrides, +}) as CommonNodeType + +const makeDataSourceNode = (overrides: Partial = {}) => ({ + type: BlockEnum.DataSource, + title: 'Data source node', + desc: '', + provider_name: 'knowledge-provider', + plugin_id: 'knowledge-plugin', + plugin_unique_identifier: 'knowledge-plugin@1.0.0', + ...overrides, +}) as CommonNodeType + +const matchedTool = { + plugin_id: 'plugin-search', + provider: 'search', + name: 'search', + plugin_unique_identifier: 'plugin-search@1.0.0', +} + +const matchedTriggerProvider = { + id: 'trigger-provider', + name: 'trigger-provider', + plugin_id: 'trigger-plugin', +} + +const matchedDataSource = { + provider: 'knowledge-provider', + plugin_id: 'knowledge-plugin', + plugin_unique_identifier: 'knowledge-plugin@1.0.0', +} + +describe('useNodePluginInstallation', () => { + beforeEach(() => { + vi.clearAllMocks() + mockBuiltInTools.mockReturnValue({ data: undefined, isLoading: false }) + mockCustomTools.mockReturnValue({ data: undefined, isLoading: false }) + mockWorkflowTools.mockReturnValue({ data: undefined, isLoading: false }) + mockMcpTools.mockReturnValue({ data: undefined, isLoading: false }) + mockInvalidToolsByType.mockReturnValue(undefined) + mockTriggerPlugins.mockReturnValue({ data: undefined, isLoading: false }) + mockInvalidateTriggers.mockReset() + mockInvalidDataSourceList.mockReset() + }) + + it('should return the noop installation state for non plugin-dependent nodes', () => { + const { result } = renderWorkflowHook(() => + useNodePluginInstallation({ + type: BlockEnum.LLM, + title: 'LLM', + desc: '', + } as CommonNodeType), + ) + + expect(result.current).toEqual({ + isChecking: false, + isMissing: false, + uniqueIdentifier: undefined, + canInstall: false, + onInstallSuccess: expect.any(Function), + shouldDim: false, + }) + }) + + it('should report loading and invalidate built-in tools while the collection is resolving', () => { + const invalidateTools = vi.fn() + mockBuiltInTools.mockReturnValue({ data: undefined, isLoading: true }) + mockInvalidToolsByType.mockReturnValue(invalidateTools) + + const { result } = renderWorkflowHook(() => useNodePluginInstallation(makeToolNode())) + + expect(mockBuiltInTools).toHaveBeenCalledWith(true) + expect(result.current.isChecking).toBe(true) + expect(result.current.isMissing).toBe(false) + expect(result.current.uniqueIdentifier).toBe('plugin-search@1.0.0') + expect(result.current.canInstall).toBe(true) + expect(result.current.shouldDim).toBe(true) + + act(() => { + result.current.onInstallSuccess() + }) + + expect(invalidateTools).toHaveBeenCalled() + }) + + it.each([ + [CollectionType.custom, mockCustomTools], + [CollectionType.workflow, mockWorkflowTools], + [CollectionType.mcp, mockMcpTools], + ])('should resolve matched %s tool collections without dimming', (providerType, hookMock) => { + hookMock.mockReturnValue({ data: [matchedTool], isLoading: false }) + + const { result } = renderWorkflowHook(() => + useNodePluginInstallation(makeToolNode({ provider_type: providerType })), + ) + + expect(result.current.isChecking).toBe(false) + expect(result.current.isMissing).toBe(false) + expect(result.current.shouldDim).toBe(false) + }) + + it('should keep unknown tool collection types installable without collection state', () => { + const { result } = renderWorkflowHook(() => + useNodePluginInstallation(makeToolNode({ + provider_type: 'unknown' as CollectionType, + plugin_unique_identifier: undefined, + plugin_id: undefined, + provider_id: 'legacy-provider', + })), + ) + + expect(result.current.isChecking).toBe(false) + expect(result.current.isMissing).toBe(false) + expect(result.current.uniqueIdentifier).toBe('legacy-provider') + expect(result.current.canInstall).toBe(false) + expect(result.current.shouldDim).toBe(false) + }) + + it('should flag missing trigger plugins and invalidate trigger data after installation', () => { + mockTriggerPlugins.mockReturnValue({ data: [matchedTriggerProvider], isLoading: false }) + + const { result } = renderWorkflowHook(() => + useNodePluginInstallation(makeTriggerNode({ + provider_id: 'missing-trigger', + provider_name: 'missing-trigger', + plugin_id: 'missing-trigger', + })), + ) + + expect(mockTriggerPlugins).toHaveBeenCalledWith(true) + expect(result.current.isChecking).toBe(false) + expect(result.current.isMissing).toBe(true) + expect(result.current.shouldDim).toBe(true) + + act(() => { + result.current.onInstallSuccess() + }) + + expect(mockInvalidateTriggers).toHaveBeenCalled() + }) + + it('should treat the trigger plugin list as still loading when it has not resolved yet', () => { + mockTriggerPlugins.mockReturnValue({ data: undefined, isLoading: true }) + + const { result } = renderWorkflowHook(() => + useNodePluginInstallation(makeTriggerNode({ plugin_unique_identifier: undefined, plugin_id: 'trigger-plugin' })), + ) + + expect(result.current.isChecking).toBe(true) + expect(result.current.isMissing).toBe(false) + expect(result.current.uniqueIdentifier).toBe('trigger-plugin') + expect(result.current.canInstall).toBe(false) + expect(result.current.shouldDim).toBe(true) + }) + + it('should track missing and matched data source providers based on workflow store state', () => { + const missingRender = renderWorkflowHook( + () => useNodePluginInstallation(makeDataSourceNode({ + provider_name: 'missing-provider', + plugin_id: 'missing-plugin', + plugin_unique_identifier: 'missing-plugin@1.0.0', + })), + { + initialStoreState: { + dataSourceList: [matchedDataSource] as never, + }, + }, + ) + + expect(missingRender.result.current.isChecking).toBe(false) + expect(missingRender.result.current.isMissing).toBe(true) + expect(missingRender.result.current.shouldDim).toBe(true) + + const matchedRender = renderWorkflowHook( + () => useNodePluginInstallation(makeDataSourceNode()), + { + initialStoreState: { + dataSourceList: [matchedDataSource] as never, + }, + }, + ) + + expect(matchedRender.result.current.isMissing).toBe(false) + expect(matchedRender.result.current.shouldDim).toBe(false) + + act(() => { + matchedRender.result.current.onInstallSuccess() + }) + + expect(mockInvalidDataSourceList).toHaveBeenCalled() + }) + + it('should keep data sources in checking state before the list is loaded', () => { + const { result } = renderWorkflowHook(() => useNodePluginInstallation(makeDataSourceNode())) + + expect(result.current.isChecking).toBe(true) + expect(result.current.isMissing).toBe(false) + expect(result.current.shouldDim).toBe(true) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/field.spec.tsx b/web/app/components/workflow/nodes/_base/components/field.spec.tsx new file mode 100644 index 0000000000..a34a862118 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/field.spec.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import Field from './field' + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ popupContent }: { popupContent: React.ReactNode }) =>
{popupContent}
, +})) + +describe('Field', () => { + it('should render subtitle styling, tooltip, operations, warning dot and required marker', () => { + const { container } = render( + operation} + required + warningDot + isSubTitle + />, + ) + + expect(screen.getByText('Knowledge')).toBeInTheDocument() + expect(screen.getByText('tooltip text')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'operation' })).toBeInTheDocument() + expect(screen.getByText('*')).toBeInTheDocument() + expect(container.querySelector('.system-xs-medium-uppercase')).not.toBeNull() + expect(container.querySelector('.bg-text-warning-secondary')).not.toBeNull() + }) + + it('should toggle folded children when supportFold is enabled', () => { + const { container } = render( + +
folded content
+
, + ) + + expect(screen.queryByText('folded content')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('Foldable').closest('.cursor-pointer')!) + expect(screen.getByText('folded content')).toBeInTheDocument() + expect(container.querySelector('svg')).toHaveStyle({ transform: 'rotate(0deg)' }) + + fireEvent.click(screen.getByText('Foldable').closest('.cursor-pointer')!) + expect(screen.queryByText('folded content')).not.toBeInTheDocument() + }) + + it('should render inline children without folding support', () => { + const { container } = render( + +
always visible
+
, + ) + + expect(screen.getByText('always visible')).toBeInTheDocument() + expect(container.firstChild).toHaveClass('flex') + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/layout/field-title.spec.tsx b/web/app/components/workflow/nodes/_base/components/layout/field-title.spec.tsx new file mode 100644 index 0000000000..3b1be0040e --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/layout/field-title.spec.tsx @@ -0,0 +1,67 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { FieldTitle } from './field-title' + +vi.mock('@/app/components/base/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}, + TooltipContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +describe('FieldTitle', () => { + it('should render title, subtitle, operation, tooltip and warning dot', () => { + render( + subtitle
} + operation={} + tooltip="Tooltip copy" + warningDot + />, + ) + + expect(screen.getByText('Embedding')).toBeInTheDocument() + expect(screen.getByText('subtitle')).toBeInTheDocument() + expect(screen.getByText('Tooltip copy')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'action' })).toBeInTheDocument() + expect(document.querySelector('.bg-text-warning-secondary')).not.toBeNull() + }) + + it('should toggle local collapsed state and notify onCollapse when enabled', () => { + const onCollapse = vi.fn() + const { container } = render( + , + ) + + const header = screen.getByText('Models').closest('.group\\/collapse') + const arrow = container.querySelector('[aria-hidden="true"]') + + expect(arrow).toHaveClass('rotate-[270deg]') + + fireEvent.click(header!) + + expect(onCollapse).toHaveBeenCalledWith(false) + expect(arrow).not.toHaveClass('rotate-[270deg]') + }) + + it('should respect controlled collapsed state and ignore clicks when disabled', () => { + const onCollapse = vi.fn() + const { container } = render( + , + ) + + fireEvent.click(screen.getByText('Controlled').closest('.group\\/collapse')!) + + expect(onCollapse).not.toHaveBeenCalled() + expect(container.querySelector('[aria-hidden="true"]')).not.toHaveClass('rotate-[270deg]') + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/node-control.spec.tsx b/web/app/components/workflow/nodes/_base/components/node-control.spec.tsx new file mode 100644 index 0000000000..5f36ff2568 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/node-control.spec.tsx @@ -0,0 +1,130 @@ +import type { CommonNodeType } from '../../../types' +import { fireEvent, render, screen } from '@testing-library/react' +import { BlockEnum, NodeRunningStatus } from '../../../types' +import NodeControl from './node-control' + +const mockHandleNodeSelect = vi.fn() +const mockSetInitShowLastRunTab = vi.fn() +const mockSetPendingSingleRun = vi.fn() +const mockCanRunBySingle = vi.fn(() => true) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => ( +
{children}
+ ), +})) + +vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({ + Stop: ({ className }: { className?: string }) =>
, +})) + +vi.mock('../../../hooks', () => ({ + useNodesInteractions: () => ({ + handleNodeSelect: mockHandleNodeSelect, + }), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + getState: () => ({ + setInitShowLastRunTab: mockSetInitShowLastRunTab, + setPendingSingleRun: mockSetPendingSingleRun, + }), + }), +})) + +vi.mock('../../../utils', () => ({ + canRunBySingle: mockCanRunBySingle, +})) + +vi.mock('./panel-operator', () => ({ + default: ({ onOpenChange }: { onOpenChange: (open: boolean) => void }) => ( + <> + + + + ), +})) + +const makeData = (overrides: Partial = {}): CommonNodeType => ({ + type: BlockEnum.Code, + title: 'Node', + desc: '', + selected: false, + _singleRunningStatus: undefined, + isInIteration: false, + isInLoop: false, + ...overrides, +}) + +describe('NodeControl', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCanRunBySingle.mockReturnValue(true) + }) + + it('should trigger a single run and show the hover control when plugins are not locked', () => { + const { container } = render( + , + ) + + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('group-hover:flex') + expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'panel.runThisStep') + + fireEvent.click(screen.getByTestId('tooltip').parentElement!) + + expect(mockSetInitShowLastRunTab).toHaveBeenCalledWith(true) + expect(mockSetPendingSingleRun).toHaveBeenCalledWith({ nodeId: 'node-1', action: 'run' }) + expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1') + }) + + it('should render the stop action, keep locked controls hidden by default, and stay open when panel operator opens', () => { + const { container } = render( + , + ) + + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).not.toContain('group-hover:flex') + expect(wrapper.className).toContain('!flex') + expect(screen.getByTestId('stop-icon')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('stop-icon').parentElement!) + + expect(mockSetPendingSingleRun).toHaveBeenCalledWith({ nodeId: 'node-2', action: 'stop' }) + + fireEvent.click(screen.getByRole('button', { name: 'open panel' })) + expect(wrapper.className).toContain('!flex') + }) + + it('should hide the run control when single-node execution is not supported', () => { + mockCanRunBySingle.mockReturnValue(false) + + render( + , + ) + + expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'open panel' })).toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/index.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/index.spec.tsx new file mode 100644 index 0000000000..f93344ca60 --- /dev/null +++ b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/index.spec.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import { ChunkStructureEnum } from '../../types' +import ChunkStructure from './index' + +const mockUseChunkStructure = vi.hoisted(() => vi.fn()) + +vi.mock('@/app/components/workflow/nodes/_base/components/layout', () => ({ + Field: ({ children, fieldTitleProps }: { children: ReactNode, fieldTitleProps: { title: string, warningDot?: boolean, operation?: ReactNode } }) => ( +
+
{fieldTitleProps.title}
+ {fieldTitleProps.operation} + {children} +
+ ), +})) + +vi.mock('./hooks', () => ({ + useChunkStructure: mockUseChunkStructure, +})) + +vi.mock('../option-card', () => ({ + default: ({ title }: { title: string }) =>
{title}
, +})) + +vi.mock('./selector', () => ({ + default: ({ trigger, value }: { trigger?: ReactNode, value?: string }) => ( +
+ {value ?? 'no-value'} + {trigger} +
+ ), +})) + +vi.mock('./instruction', () => ({ + default: ({ className }: { className?: string }) =>
Instruction
, +})) + +describe('ChunkStructure', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseChunkStructure.mockReturnValue({ + options: [{ value: ChunkStructureEnum.general, label: 'General' }], + optionMap: { + [ChunkStructureEnum.general]: { + title: 'General Chunk Structure', + }, + }, + }) + }) + + it('should render the selected option and warning dot metadata when a chunk structure is chosen', () => { + render( + , + ) + + expect(screen.getByTestId('field')).toHaveAttribute('data-warning-dot', 'true') + expect(screen.getByTestId('selector')).toHaveTextContent(ChunkStructureEnum.general) + expect(screen.getByTestId('option-card')).toHaveTextContent('General Chunk Structure') + expect(screen.queryByTestId('instruction')).not.toBeInTheDocument() + }) + + it('should render the add trigger and instruction when no chunk structure is selected', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: /chooseChunkStructure/i })).toBeInTheDocument() + expect(screen.getByTestId('instruction')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/embedding-model.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/embedding-model.spec.tsx new file mode 100644 index 0000000000..fe8cacd76e --- /dev/null +++ b/web/app/components/workflow/nodes/knowledge-base/components/embedding-model.spec.tsx @@ -0,0 +1,62 @@ +import type { ReactNode } from 'react' +import { render } from '@testing-library/react' +import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import EmbeddingModel from './embedding-model' + +const mockUseModelList = vi.hoisted(() => vi.fn()) +const mockModelSelector = vi.hoisted(() => vi.fn(() =>
selector
)) + +vi.mock('@/app/components/workflow/nodes/_base/components/layout', () => ({ + Field: ({ children, fieldTitleProps }: { children: ReactNode, fieldTitleProps: { warningDot?: boolean } }) => ( +
+ {children} +
+ ), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelList: mockUseModelList, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ + default: mockModelSelector, +})) + +describe('EmbeddingModel', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseModelList.mockReturnValue({ data: [{ provider: 'openai', model: 'text-embedding-3-large' }] }) + }) + + it('should pass the selected model configuration and warning state to the selector field', () => { + const onEmbeddingModelChange = vi.fn() + + render( + , + ) + + expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.textEmbedding) + expect(mockModelSelector).toHaveBeenCalledWith(expect.objectContaining({ + defaultModel: { + provider: 'openai', + model: 'text-embedding-3-large', + }, + modelList: [{ provider: 'openai', model: 'text-embedding-3-large' }], + readonly: false, + showDeprecatedWarnIcon: true, + }), undefined) + }) + + it('should pass an undefined default model when the embedding model is incomplete', () => { + render() + + expect(mockModelSelector).toHaveBeenCalledWith(expect.objectContaining({ + defaultModel: undefined, + }), undefined) + }) +}) diff --git a/web/app/components/workflow/nodes/knowledge-base/default.spec.ts b/web/app/components/workflow/nodes/knowledge-base/default.spec.ts new file mode 100644 index 0000000000..becc6cb9d8 --- /dev/null +++ b/web/app/components/workflow/nodes/knowledge-base/default.spec.ts @@ -0,0 +1,74 @@ +import type { KnowledgeBaseNodeType } from './types' +import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { + ConfigurationMethodEnum, + ModelStatusEnum, + ModelTypeEnum, +} from '@/app/components/header/account-setting/model-provider-page/declarations' +import nodeDefault from './default' +import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from './types' + +const t = (key: string) => key + +const makeEmbeddingModelList = (status: ModelStatusEnum): Model[] => [{ + provider: 'openai', + icon_small: { en_US: '', zh_Hans: '' }, + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + models: [{ + model: 'text-embedding-3-large', + label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' }, + model_type: ModelTypeEnum.textEmbedding, + fetch_from: ConfigurationMethodEnum.predefinedModel, + status, + model_properties: {}, + load_balancing_enabled: false, + }], + status, +}] + +const makeEmbeddingProviderModelList = (status: ModelStatusEnum): ModelItem[] => [{ + model: 'text-embedding-3-large', + label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' }, + model_type: ModelTypeEnum.textEmbedding, + fetch_from: ConfigurationMethodEnum.predefinedModel, + status, + model_properties: {}, + load_balancing_enabled: false, +}] + +const createPayload = (overrides: Partial = {}): KnowledgeBaseNodeType => ({ + ...nodeDefault.defaultValue, + index_chunk_variable_selector: ['chunks', 'results'], + chunk_structure: ChunkStructureEnum.general, + indexing_technique: IndexMethodEnum.QUALIFIED, + embedding_model: 'text-embedding-3-large', + embedding_model_provider: 'openai', + retrieval_model: { + ...nodeDefault.defaultValue.retrieval_model, + search_method: RetrievalSearchMethodEnum.semantic, + }, + _embeddingModelList: makeEmbeddingModelList(ModelStatusEnum.active), + _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.active), + _rerankModelList: [], + ...overrides, +}) as KnowledgeBaseNodeType + +describe('knowledge-base default node validation', () => { + it('should return an invalid result when the payload has a validation issue', () => { + const result = nodeDefault.checkValid(createPayload({ chunk_structure: undefined }), t) + + expect(result).toEqual({ + isValid: false, + errorMessage: 'nodes.knowledgeBase.chunkIsRequired', + }) + }) + + it('should return a valid result when the payload is complete', () => { + const result = nodeDefault.checkValid(createPayload(), t) + + expect(result).toEqual({ + isValid: true, + errorMessage: '', + }) + }) +}) diff --git a/web/app/components/workflow/nodes/knowledge-base/node.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/node.spec.tsx index 6ce55ac59d..19cf6a0626 100644 --- a/web/app/components/workflow/nodes/knowledge-base/node.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/node.spec.tsx @@ -158,4 +158,76 @@ describe('KnowledgeBaseNode', () => { expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument() }) }) + + describe('Validation warnings', () => { + it('should render a warning banner when chunk structure is missing', () => { + render( + , + ) + + expect(screen.getByText(/chunkIsRequired/i)).toBeInTheDocument() + }) + + it('should render a warning value for the chunks input row when no chunk variable is selected', () => { + render( + , + ) + + expect(screen.getByText(/chunksVariableIsRequired/i)).toBeInTheDocument() + }) + + it('should render a warning value for retrieval settings when reranking is incomplete', () => { + mockUseModelList.mockImplementation((modelType: ModelTypeEnum) => { + if (modelType === ModelTypeEnum.textEmbedding) { + return { + data: [{ + provider: 'openai', + models: [createModelItem()], + }], + } + } + return { data: [] } + }) + + render( + , + ) + + expect(screen.getByText(/rerankingModelIsRequired/i)).toBeInTheDocument() + }) + + it('should hide the embedding model row when the index method is not qualified', () => { + render( + , + ) + + expect(screen.queryByText('Text Embedding 3 Large')).not.toBeInTheDocument() + }) + }) }) diff --git a/web/app/components/workflow/nodes/knowledge-base/panel.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/panel.spec.tsx new file mode 100644 index 0000000000..2f76449b6c --- /dev/null +++ b/web/app/components/workflow/nodes/knowledge-base/panel.spec.tsx @@ -0,0 +1,198 @@ +import type { ReactNode } from 'react' +import type { PanelProps } from '@/types/workflow' +import { render, screen } from '@testing-library/react' +import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import Panel from './panel' +import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from './types' + +const mockUseModelList = vi.hoisted(() => vi.fn()) +const mockUseQuery = vi.hoisted(() => vi.fn()) +const mockUseEmbeddingModelStatus = vi.hoisted(() => vi.fn()) +const mockChunkStructure = vi.hoisted(() => vi.fn(() =>
)) +const mockEmbeddingModel = vi.hoisted(() => vi.fn(() =>
)) +const mockSummaryIndexSetting = vi.hoisted(() => vi.fn(() =>
)) +const mockQueryOptions = vi.hoisted(() => vi.fn((options: unknown) => options)) + +vi.mock('@tanstack/react-query', () => ({ + useQuery: mockUseQuery, +})) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + modelProviders: { + models: { + queryOptions: mockQueryOptions, + }, + }, + }, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelList: mockUseModelList, +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesReadOnly: () => ({ nodesReadOnly: false }), +})) + +vi.mock('./hooks/use-config', () => ({ + useConfig: () => ({ + handleChunkStructureChange: vi.fn(), + handleIndexMethodChange: vi.fn(), + handleKeywordNumberChange: vi.fn(), + handleEmbeddingModelChange: vi.fn(), + handleRetrievalSearchMethodChange: vi.fn(), + handleHybridSearchModeChange: vi.fn(), + handleRerankingModelEnabledChange: vi.fn(), + handleWeighedScoreChange: vi.fn(), + handleRerankingModelChange: vi.fn(), + handleTopKChange: vi.fn(), + handleScoreThresholdChange: vi.fn(), + handleScoreThresholdEnabledChange: vi.fn(), + handleInputVariableChange: vi.fn(), + handleSummaryIndexSettingChange: vi.fn(), + }), +})) + +vi.mock('./hooks/use-embedding-model-status', () => ({ + useEmbeddingModelStatus: mockUseEmbeddingModelStatus, +})) + +vi.mock('@/app/components/datasets/settings/utils', () => ({ + checkShowMultiModalTip: () => false, +})) + +vi.mock('@/config', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + IS_CE_EDITION: true, + } +}) + +vi.mock('@/app/components/workflow/nodes/_base/components/layout', () => ({ + Group: ({ children }: { children: ReactNode }) =>
{children}
, + BoxGroup: ({ children }: { children: ReactNode }) =>
{children}
, + BoxGroupField: ({ children, fieldProps }: { children: ReactNode, fieldProps: { fieldTitleProps: { warningDot?: boolean } } }) => ( +
+ {children} +
+ ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({ + default: mockSummaryIndexSetting, +})) + +vi.mock('./components/chunk-structure', () => ({ + default: mockChunkStructure, +})) + +vi.mock('./components/index-method', () => ({ + default: () =>
, +})) + +vi.mock('./components/embedding-model', () => ({ + default: mockEmbeddingModel, +})) + +vi.mock('./components/retrieval-setting', () => ({ + default: () =>
, +})) + +const createData = (overrides: Record = {}) => ({ + index_chunk_variable_selector: ['chunks', 'results'], + chunk_structure: ChunkStructureEnum.general, + indexing_technique: IndexMethodEnum.QUALIFIED, + embedding_model: 'text-embedding-3-large', + embedding_model_provider: 'openai', + keyword_number: 10, + retrieval_model: { + search_method: RetrievalSearchMethodEnum.semantic, + reranking_enable: false, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + ...overrides, +}) + +const panelProps: PanelProps = { + getInputVars: () => [], + toVarInputs: () => [], + runInputData: {}, + runInputDataRef: { current: {} }, + setRunInputData: vi.fn(), + runResult: undefined, +} + +describe('KnowledgeBasePanel', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseQuery.mockReturnValue({ data: undefined }) + mockUseModelList.mockImplementation((modelType: ModelTypeEnum) => { + if (modelType === ModelTypeEnum.textEmbedding) { + return { + data: [{ + provider: 'openai', + models: [{ model: 'text-embedding-3-large' }], + }], + } + } + return { data: [] } + }) + mockUseEmbeddingModelStatus.mockReturnValue({ status: 'active' }) + }) + + it('should show a warning dot on chunk structure and skip nested sections when chunk structure is missing', () => { + render() + + expect(mockChunkStructure).toHaveBeenCalledWith(expect.objectContaining({ + warningDot: true, + }), undefined) + expect(screen.queryByTestId('box-group-field')).not.toBeInTheDocument() + expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ + enabled: true, + })) + }) + + it('should pass warning dots and render summary settings when the qualified configuration needs attention', () => { + mockUseEmbeddingModelStatus.mockReturnValue({ status: 'disabled' }) + + render() + + expect(screen.getByTestId('box-group-field')).toHaveAttribute('data-warning-dot', 'true') + expect(mockEmbeddingModel).toHaveBeenCalledWith(expect.objectContaining({ + warningDot: true, + }), undefined) + expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ + input: { params: { provider: 'openai' } }, + enabled: true, + })) + expect(screen.getByTestId('summary-index-setting')).toBeInTheDocument() + }) + + it('should hide embedding and summary settings for non-qualified index methods', () => { + render( + , + ) + + expect(screen.queryByTestId('embedding-model')).not.toBeInTheDocument() + expect(screen.queryByTestId('summary-index-setting')).not.toBeInTheDocument() + expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ + enabled: false, + })) + }) +}) diff --git a/web/app/components/workflow/nodes/knowledge-base/utils.spec.ts b/web/app/components/workflow/nodes/knowledge-base/utils.spec.ts index 4abdbe8714..fc911e0133 100644 --- a/web/app/components/workflow/nodes/knowledge-base/utils.spec.ts +++ b/web/app/components/workflow/nodes/knowledge-base/utils.spec.ts @@ -12,6 +12,9 @@ import { } from './types' import { getKnowledgeBaseValidationIssue, + getKnowledgeBaseValidationMessage, + isHighQualitySearchMethod, + isKnowledgeBaseEmbeddingIssue, KnowledgeBaseValidationIssueCode, } from './utils' @@ -69,6 +72,13 @@ const makePayload = (overrides: Partial = {}): KnowledgeB } describe('knowledge-base validation issue', () => { + it('identifies high quality retrieval methods', () => { + expect(isHighQualitySearchMethod(RetrievalSearchMethodEnum.semantic)).toBe(true) + expect(isHighQualitySearchMethod(RetrievalSearchMethodEnum.hybrid)).toBe(true) + expect(isHighQualitySearchMethod(RetrievalSearchMethodEnum.fullText)).toBe(true) + expect(isHighQualitySearchMethod('unknown-method' as RetrievalSearchMethodEnum)).toBe(false) + }) + it('returns chunk structure issue when chunk structure is missing', () => { const issue = getKnowledgeBaseValidationIssue(makePayload({ chunk_structure: undefined })) expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.chunkStructureRequired) @@ -123,4 +133,94 @@ describe('knowledge-base validation issue', () => { ) expect(issue).toBeNull() }) + + it('returns embedding-model-not-configured when the qualified index is missing provider details', () => { + const issue = getKnowledgeBaseValidationIssue( + makePayload({ embedding_model: undefined }), + ) + + expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured) + }) + + it('maps no-permission embedding models to incompatible', () => { + const issue = getKnowledgeBaseValidationIssue( + makePayload({ _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.noPermission) }), + ) + + expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelIncompatible) + }) + + it('returns retrieval-setting-required when retrieval search method is missing', () => { + const issue = getKnowledgeBaseValidationIssue( + makePayload({ retrieval_model: undefined as never }), + ) + + expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.retrievalSettingRequired) + }) + + it('returns reranking-model-required when reranking is enabled without a model', () => { + const issue = getKnowledgeBaseValidationIssue( + makePayload({ + retrieval_model: { + ...makePayload().retrieval_model, + reranking_enable: true, + }, + }), + ) + + expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.rerankingModelRequired) + }) + + it('returns reranking-model-invalid when the configured reranking model is unavailable', () => { + const issue = getKnowledgeBaseValidationIssue( + makePayload({ + retrieval_model: { + ...makePayload().retrieval_model, + reranking_enable: true, + reranking_model: { + reranking_provider_name: 'missing-provider', + reranking_model_name: 'missing-model', + }, + }, + }), + ) + + expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.rerankingModelInvalid) + }) +}) + +describe('knowledge-base validation messaging', () => { + const t = (key: string) => key + + it.each([ + [KnowledgeBaseValidationIssueCode.chunkStructureRequired, 'nodes.knowledgeBase.chunkIsRequired'], + [KnowledgeBaseValidationIssueCode.chunksVariableRequired, 'nodes.knowledgeBase.chunksVariableIsRequired'], + [KnowledgeBaseValidationIssueCode.indexMethodRequired, 'nodes.knowledgeBase.indexMethodIsRequired'], + [KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured, 'nodes.knowledgeBase.embeddingModelNotConfigured'], + [KnowledgeBaseValidationIssueCode.embeddingModelConfigureRequired, 'modelProvider.selector.configureRequired'], + [KnowledgeBaseValidationIssueCode.embeddingModelApiKeyUnavailable, 'modelProvider.selector.apiKeyUnavailable'], + [KnowledgeBaseValidationIssueCode.embeddingModelCreditsExhausted, 'modelProvider.selector.creditsExhausted'], + [KnowledgeBaseValidationIssueCode.embeddingModelDisabled, 'modelProvider.selector.disabled'], + [KnowledgeBaseValidationIssueCode.embeddingModelIncompatible, 'modelProvider.selector.incompatible'], + [KnowledgeBaseValidationIssueCode.retrievalSettingRequired, 'nodes.knowledgeBase.retrievalSettingIsRequired'], + [KnowledgeBaseValidationIssueCode.rerankingModelRequired, 'nodes.knowledgeBase.rerankingModelIsRequired'], + [KnowledgeBaseValidationIssueCode.rerankingModelInvalid, 'nodes.knowledgeBase.rerankingModelIsInvalid'], + ] as const)('maps %s to the expected translation key', (code, expectedKey) => { + expect(getKnowledgeBaseValidationMessage({ code }, t as never)).toBe(expectedKey) + }) + + it('returns an empty string when there is no issue', () => { + expect(getKnowledgeBaseValidationMessage(undefined, t as never)).toBe('') + }) +}) + +describe('isKnowledgeBaseEmbeddingIssue', () => { + it('returns true for embedding-related issues', () => { + expect(isKnowledgeBaseEmbeddingIssue({ code: KnowledgeBaseValidationIssueCode.embeddingModelDisabled })).toBe(true) + }) + + it('returns false for non-embedding issues and missing values', () => { + expect(isKnowledgeBaseEmbeddingIssue({ code: KnowledgeBaseValidationIssueCode.rerankingModelInvalid })).toBe(false) + expect(isKnowledgeBaseEmbeddingIssue(undefined)).toBe(false) + }) }) diff --git a/web/app/components/workflow/nodes/llm/default.spec.ts b/web/app/components/workflow/nodes/llm/default.spec.ts new file mode 100644 index 0000000000..938b20be10 --- /dev/null +++ b/web/app/components/workflow/nodes/llm/default.spec.ts @@ -0,0 +1,47 @@ +import type { LLMNodeType } from './types' +import { AppModeEnum } from '@/types/app' +import { EditionType, PromptRole } from '../../types' +import nodeDefault from './default' + +const t = (key: string) => key + +const createPayload = (overrides: Partial = {}): LLMNodeType => ({ + ...nodeDefault.defaultValue, + model: { + ...nodeDefault.defaultValue.model, + provider: 'langgenius/openai/gpt-4.1', + mode: AppModeEnum.CHAT, + }, + prompt_template: [{ + role: PromptRole.system, + text: 'You are helpful.', + edition_type: EditionType.basic, + }], + ...overrides, +}) as LLMNodeType + +describe('llm default node validation', () => { + it('should require a model provider before validating the prompt', () => { + const result = nodeDefault.checkValid(createPayload({ + model: { + ...nodeDefault.defaultValue.model, + provider: '', + name: 'gpt-4.1', + mode: AppModeEnum.CHAT, + completion_params: { + temperature: 0.7, + }, + }, + }), t) + + expect(result.isValid).toBe(false) + expect(result.errorMessage).toBe('errorMsg.fieldRequired') + }) + + it('should return a valid result when the provider and prompt are configured', () => { + const result = nodeDefault.checkValid(createPayload(), t) + + expect(result.isValid).toBe(true) + expect(result.errorMessage).toBe('') + }) +}) diff --git a/web/app/components/workflow/nodes/llm/utils.spec.ts b/web/app/components/workflow/nodes/llm/utils.spec.ts new file mode 100644 index 0000000000..4c916651f6 --- /dev/null +++ b/web/app/components/workflow/nodes/llm/utils.spec.ts @@ -0,0 +1,43 @@ +import { getLLMModelIssue, isLLMModelProviderInstalled, LLMModelIssueCode } from './utils' + +describe('llm utils', () => { + describe('getLLMModelIssue', () => { + it('returns provider-required when the model provider is missing', () => { + expect(getLLMModelIssue({ modelProvider: undefined })).toBe(LLMModelIssueCode.providerRequired) + }) + + it('returns provider-plugin-unavailable when the provider plugin is not installed', () => { + expect(getLLMModelIssue({ + modelProvider: 'langgenius/openai/gpt-4.1', + isModelProviderInstalled: false, + })).toBe(LLMModelIssueCode.providerPluginUnavailable) + }) + + it('returns null when the provider is present and installed', () => { + expect(getLLMModelIssue({ + modelProvider: 'langgenius/openai/gpt-4.1', + isModelProviderInstalled: true, + })).toBeNull() + }) + }) + + describe('isLLMModelProviderInstalled', () => { + it('returns true when the model provider is missing', () => { + expect(isLLMModelProviderInstalled(undefined, new Set())).toBe(true) + }) + + it('matches installed plugin ids using the provider plugin prefix', () => { + expect(isLLMModelProviderInstalled( + 'langgenius/openai/gpt-4.1', + new Set(['langgenius/openai']), + )).toBe(true) + }) + + it('returns false when the provider plugin id is not installed', () => { + expect(isLLMModelProviderInstalled( + 'langgenius/openai/gpt-4.1', + new Set(['langgenius/anthropic']), + )).toBe(false) + }) + }) +}) diff --git a/web/app/components/workflow/utils/plugin-install-check.spec.ts b/web/app/components/workflow/utils/plugin-install-check.spec.ts index 0a8e740825..e37315328e 100644 --- a/web/app/components/workflow/utils/plugin-install-check.spec.ts +++ b/web/app/components/workflow/utils/plugin-install-check.spec.ts @@ -146,6 +146,18 @@ describe('plugin install check', () => { expect(isNodePluginMissing(node, { triggerPlugins: [createTriggerProvider()] })).toBe(true) }) + it('should keep trigger plugin nodes installable when the provider list has not loaded yet', () => { + const node = { + type: BlockEnum.TriggerPlugin, + title: 'Trigger', + desc: '', + provider_id: 'missing-trigger', + plugin_unique_identifier: 'trigger-plugin@1.0.0', + } as CommonNodeType + + expect(isNodePluginMissing(node, { triggerPlugins: undefined })).toBe(false) + }) + it('should report missing data source plugins when the list is loaded but unmatched', () => { const node = { type: BlockEnum.DataSource, @@ -158,6 +170,18 @@ describe('plugin install check', () => { expect(isNodePluginMissing(node, { dataSourceList: [createTool()] })).toBe(true) }) + it('should keep data source nodes installable when the list has not loaded yet', () => { + const node = { + type: BlockEnum.DataSource, + title: 'Data Source', + desc: '', + provider_name: 'missing-provider', + plugin_unique_identifier: 'missing-data-source@1.0.0', + } as CommonNodeType + + expect(isNodePluginMissing(node, { dataSourceList: undefined })).toBe(false) + }) + it('should return false for unsupported node types', () => { const node = { type: BlockEnum.LLM, From 548b20f70b2d533567d418168d398067a2a9187e Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Mon, 16 Mar 2026 16:32:07 +0800 Subject: [PATCH 06/13] test: fix flaky CI failures in node-control and firecrawl specs --- .../firecrawl/__tests__/index.spec.tsx | 53 ++++++++++++-- .../create/website/firecrawl/index.tsx | 70 ++++++++++++++----- .../_base/components/node-control.spec.tsx | 15 ++-- 3 files changed, 113 insertions(+), 25 deletions(-) diff --git a/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx b/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx index 7df3881824..c154c1a534 100644 --- a/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import type { CrawlOptions, CrawlResultItem } from '@/models/datasets' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +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' @@ -55,6 +55,21 @@ const createMockCrawlResultItem = (overrides: Partial = {}): Cr ...overrides, }) +const createDeferred = () => { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + + return { + promise, + resolve, + reject, + } +} + // FireCrawl Component Tests describe('FireCrawl', () => { @@ -217,7 +232,7 @@ describe('FireCrawl', () => { await user.click(runButton) await waitFor(() => { - expect(mockCreateFirecrawlTask).toHaveBeenCalled() + expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([]) }) }) @@ -241,7 +256,7 @@ describe('FireCrawl', () => { await user.click(runButton) await waitFor(() => { - expect(mockCreateFirecrawlTask).toHaveBeenCalled() + expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([]) }) }) }) @@ -277,6 +292,10 @@ describe('FireCrawl', () => { }), }) }) + + await waitFor(() => { + expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([]) + }) }) it('should call onJobIdChange with job_id from API response', async () => { @@ -301,6 +320,10 @@ describe('FireCrawl', () => { await waitFor(() => { expect(mockOnJobIdChange).toHaveBeenCalledWith('my-job-123') }) + + await waitFor(() => { + expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([]) + }) }) it('should remove empty max_depth from crawlOptions before sending to API', async () => { @@ -334,11 +357,23 @@ describe('FireCrawl', () => { }), }) }) + + await waitFor(() => { + expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([]) + }) }) it('should show loading state while running', async () => { const user = userEvent.setup() - mockCreateFirecrawlTask.mockImplementation(() => new Promise(() => {})) // Never resolves + const createTaskDeferred = createDeferred<{ job_id: string }>() + mockCreateFirecrawlTask.mockImplementation(() => createTaskDeferred.promise) + mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({ + status: 'completed', + data: [], + total: 0, + current: 0, + time_consuming: 1, + }) render() @@ -352,6 +387,14 @@ describe('FireCrawl', () => { await waitFor(() => { expect(runButton).not.toHaveTextContent(/run/i) }) + + await act(async () => { + createTaskDeferred.resolve({ job_id: 'test-job-id' }) + }) + + await waitFor(() => { + expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([]) + }) }) }) @@ -656,7 +699,7 @@ describe('FireCrawl', () => { await waitFor(() => { // Total should be capped to limit (5) - expect(mockCheckFirecrawlTaskStatus).toHaveBeenCalled() + expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([]) }) }) }) diff --git a/web/app/components/datasets/create/website/firecrawl/index.tsx b/web/app/components/datasets/create/website/firecrawl/index.tsx index 3c5c453b51..09fdbb00c2 100644 --- a/web/app/components/datasets/create/website/firecrawl/index.tsx +++ b/web/app/components/datasets/create/website/firecrawl/index.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react' import type { CrawlOptions, CrawlResultItem } from '@/models/datasets' import * as React from 'react' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Toast from '@/app/components/base/toast' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' @@ -35,6 +35,22 @@ enum Step { finished = 'finished', } +type CrawlState = { + current: number + total: number + data: CrawlResultItem[] + time_consuming: number | string +} + +type CrawlFinishedResult = { + isCancelled?: boolean + isError: boolean + errorMessage?: string + data: Partial & { + data: CrawlResultItem[] + } +} + const FireCrawl: FC = ({ onPreview, checkedCrawlResult, @@ -46,10 +62,16 @@ const FireCrawl: FC = ({ const { t } = useTranslation() const [step, setStep] = useState(Step.init) const [controlFoldOptions, setControlFoldOptions] = useState(0) + const isMountedRef = useRef(true) useEffect(() => { if (step !== Step.init) setControlFoldOptions(Date.now()) }, [step]) + useEffect(() => { + return () => { + isMountedRef.current = false + } + }, []) const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal) const handleSetting = useCallback(() => { setShowAccountSettingModal({ @@ -85,16 +107,19 @@ const FireCrawl: FC = ({ const isInit = step === Step.init const isCrawlFinished = step === Step.finished const isRunning = step === Step.running - const [crawlResult, setCrawlResult] = useState<{ - current: number - total: number - data: CrawlResultItem[] - time_consuming: number | string - } | undefined>(undefined) + const [crawlResult, setCrawlResult] = useState(undefined) const [crawlErrorMessage, setCrawlErrorMessage] = useState('') const showError = isCrawlFinished && crawlErrorMessage - const waitForCrawlFinished = useCallback(async (jobId: string) => { + const waitForCrawlFinished = useCallback(async (jobId: string): Promise => { + const cancelledResult: CrawlFinishedResult = { + isCancelled: true, + isError: false, + data: { + data: [], + }, + } + try { const res = await checkFirecrawlTaskStatus(jobId) as any if (res.status === 'completed') { @@ -104,7 +129,7 @@ const FireCrawl: FC = ({ ...res, total: Math.min(res.total, Number.parseFloat(crawlOptions.limit as string)), }, - } + } satisfies CrawlFinishedResult } if (res.status === 'error' || !res.status) { // can't get the error message from the firecrawl api @@ -114,12 +139,14 @@ const FireCrawl: FC = ({ data: { data: [], }, - } + } satisfies CrawlFinishedResult } res.data = res.data.map((item: any) => ({ ...item, content: item.markdown, })) + if (!isMountedRef.current) + return cancelledResult // update the progress setCrawlResult({ ...res, @@ -127,17 +154,21 @@ const FireCrawl: FC = ({ }) onCheckedCrawlResultChange(res.data || []) // default select the crawl result await sleep(2500) + if (!isMountedRef.current) + return cancelledResult return await waitForCrawlFinished(jobId) } catch (e: any) { - const errorBody = await e.json() + if (!isMountedRef.current) + return cancelledResult + const errorBody = typeof e?.json === 'function' ? await e.json() : undefined return { isError: true, - errorMessage: errorBody.message, + errorMessage: errorBody?.message, data: { data: [], }, - } + } satisfies CrawlFinishedResult } }, [crawlOptions.limit, onCheckedCrawlResultChange]) @@ -162,24 +193,31 @@ const FireCrawl: FC = ({ url, options: passToServerCrawlOptions, }) as any + if (!isMountedRef.current) + return const jobId = res.job_id onJobIdChange(jobId) - const { isError, data, errorMessage } = await waitForCrawlFinished(jobId) + const { isCancelled, isError, data, errorMessage } = await waitForCrawlFinished(jobId) + if (isCancelled || !isMountedRef.current) + return if (isError) { setCrawlErrorMessage(errorMessage || t(`${I18N_PREFIX}.unknownError`, { ns: 'datasetCreation' })) } else { - setCrawlResult(data) + setCrawlResult(data as CrawlState) onCheckedCrawlResultChange(data.data || []) // default select the crawl result setCrawlErrorMessage('') } } catch (e) { + if (!isMountedRef.current) + return setCrawlErrorMessage(t(`${I18N_PREFIX}.unknownError`, { ns: 'datasetCreation' })!) console.log(e) } finally { - setStep(Step.finished) + if (isMountedRef.current) + setStep(Step.finished) } }, [checkValid, crawlOptions, onJobIdChange, t, waitForCrawlFinished, onCheckedCrawlResultChange]) diff --git a/web/app/components/workflow/nodes/_base/components/node-control.spec.tsx b/web/app/components/workflow/nodes/_base/components/node-control.spec.tsx index 5f36ff2568..a76eba69ef 100644 --- a/web/app/components/workflow/nodes/_base/components/node-control.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-control.spec.tsx @@ -3,10 +3,17 @@ import { fireEvent, render, screen } from '@testing-library/react' import { BlockEnum, NodeRunningStatus } from '../../../types' import NodeControl from './node-control' -const mockHandleNodeSelect = vi.fn() -const mockSetInitShowLastRunTab = vi.fn() -const mockSetPendingSingleRun = vi.fn() -const mockCanRunBySingle = vi.fn(() => true) +const { + mockHandleNodeSelect, + mockSetInitShowLastRunTab, + mockSetPendingSingleRun, + mockCanRunBySingle, +} = vi.hoisted(() => ({ + mockHandleNodeSelect: vi.fn(), + mockSetInitShowLastRunTab: vi.fn(), + mockSetPendingSingleRun: vi.fn(), + mockCanRunBySingle: vi.fn(() => true), +})) vi.mock('react-i18next', () => ({ useTranslation: () => ({ From 4822d550b6b1010ec3879ca119f8387777b28a0b Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Mon, 16 Mar 2026 16:48:22 +0800 Subject: [PATCH 07/13] chore: remove next img (#33517) --- .../components/app/create-app-modal/index.tsx | 25 ++-- .../header/__tests__/index.spec.tsx | 11 -- .../checkbox-list/__tests__/index.spec.tsx | 6 - .../components/base/checkbox-list/index.tsx | 3 +- .../base/file-thumb/__tests__/index.spec.tsx | 7 - .../components/with-icon-card-item.spec.tsx | 4 - .../components/with-icon-card-item.tsx | 8 +- .../markdown-with-directive/index.spec.tsx | 4 - .../__tests__/index.spec.tsx | 2 +- .../__tests__/index.spec.tsx | 27 ++-- .../common/retrieval-method-info/index.tsx | 3 +- .../common/retrieval-param-config/index.tsx | 13 +- .../__tests__/index.spec.tsx | 12 +- .../create/embedding-process/rule-detail.tsx | 5 +- web/app/components/datasets/create/icons.ts | 10 +- .../components/__tests__/option-card.spec.tsx | 7 - .../components/general-chunking-options.tsx | 11 +- .../components/indexing-mode-section.tsx | 21 ++- .../step-two/components/option-card.tsx | 7 +- .../components/parent-child-options.tsx | 9 +- .../__tests__/rule-detail.spec.tsx | 32 ++-- .../embedding-process/rule-detail.tsx | 5 +- .../embedding/components/rule-detail.tsx | 5 +- .../components/query-input/index.tsx | 7 +- .../try-app/app-info/__tests__/index.spec.tsx | 16 -- .../explore/try-app/app-info/index.tsx | 4 +- .../plugin-page/SerpapiPlugin.tsx | 3 +- .../rag-pipeline/components/screenshot.tsx | 3 +- web/docs/test.md | 2 +- web/eslint-suppressions.json | 27 ---- web/eslint.config.mjs | 139 +++++++++++------- web/next.config.ts | 16 -- web/plugins/vite/next-static-image-test.ts | 30 ++++ web/proxy.ts | 4 +- web/vite.config.ts | 2 + web/vitest.setup.ts | 3 - 36 files changed, 206 insertions(+), 287 deletions(-) create mode 100644 web/plugins/vite/next-static-image-test.ts diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index 16ca4bdaff..1c22913bb1 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -4,7 +4,6 @@ import type { AppIconSelection } from '../../base/app-icon-picker' import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react' import { useDebounceFn, useKeyPress } from 'ahooks' -import Image from 'next/image' import { useRouter } from 'next/navigation' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -117,10 +116,10 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
- {t('newApp.startFromBlank', { ns: 'app' })} + {t('newApp.startFromBlank', { ns: 'app' })}
- {t('newApp.chooseAppType', { ns: 'app' })} + {t('newApp.chooseAppType', { ns: 'app' })}
@@ -160,7 +159,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: className="flex cursor-pointer items-center border-0 bg-transparent p-0" onClick={() => setIsAppTypeExpanded(!isAppTypeExpanded)} > - {t('newApp.forBeginners', { ns: 'app' })} + {t('newApp.forBeginners', { ns: 'app' })}
@@ -212,7 +211,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
- +
- - + + ( {t('newApp.optional', { ns: 'app' })} ) @@ -260,7 +259,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
{isAppsFull && }
-
+
{t('newApp.noIdeaTip', { ns: 'app' })}
@@ -334,8 +333,8 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP onClick={onClick} > {icon} -
{title}
-
{description}
+
{title}
+
{description}
) } @@ -367,8 +366,8 @@ function AppPreview({ mode }: { mode: AppModeEnum }) { const previewInfo = modeToPreviewInfoMap[mode] return (
-

{previewInfo.title}

-
+

{previewInfo.title}

+
{previewInfo.description}
@@ -389,7 +388,7 @@ function AppScreenShot({ mode, show }: { mode: AppModeEnum, show: boolean }) { - App Screen Shot
, })) -// Mock next/image to render a normal img tag for testing -vi.mock('next/image', () => ({ - __esModule: true, - default: (props: ImgHTMLAttributes & { unoptimized?: boolean }) => { - const { unoptimized: _, ...rest } = props - return - }, -})) - type GlobalPublicStoreMock = { systemFeatures: SystemFeatures setSystemFeatures: (systemFeatures: SystemFeatures) => void diff --git a/web/app/components/base/checkbox-list/__tests__/index.spec.tsx b/web/app/components/base/checkbox-list/__tests__/index.spec.tsx index 7c588f6a33..b4f816dda8 100644 --- a/web/app/components/base/checkbox-list/__tests__/index.spec.tsx +++ b/web/app/components/base/checkbox-list/__tests__/index.spec.tsx @@ -1,13 +1,7 @@ -/* eslint-disable next/no-img-element */ -import type { ImgHTMLAttributes } from 'react' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import CheckboxList from '..' -vi.mock('next/image', () => ({ - default: (props: ImgHTMLAttributes) => , -})) - describe('checkbox list component', () => { const options = [ { label: 'Option 1', value: 'option1' }, diff --git a/web/app/components/base/checkbox-list/index.tsx b/web/app/components/base/checkbox-list/index.tsx index ed328244a1..6eda2aebd0 100644 --- a/web/app/components/base/checkbox-list/index.tsx +++ b/web/app/components/base/checkbox-list/index.tsx @@ -1,6 +1,5 @@ 'use client' import type { FC } from 'react' -import Image from 'next/image' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' @@ -169,7 +168,7 @@ const CheckboxList: FC = ({ {searchQuery ? (
- search menu + search menu {t('operation.noSearchResults', { ns: 'common', content: title })}
diff --git a/web/app/components/base/file-thumb/__tests__/index.spec.tsx b/web/app/components/base/file-thumb/__tests__/index.spec.tsx index 368f14ae75..f67f291579 100644 --- a/web/app/components/base/file-thumb/__tests__/index.spec.tsx +++ b/web/app/components/base/file-thumb/__tests__/index.spec.tsx @@ -1,14 +1,7 @@ -/* eslint-disable next/no-img-element */ -import type { ImgHTMLAttributes } from 'react' import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import FileThumb from '../index' -vi.mock('next/image', () => ({ - __esModule: true, - default: (props: ImgHTMLAttributes) => , -})) - describe('FileThumb Component', () => { const mockImageFile = { name: 'test-image.jpg', diff --git a/web/app/components/base/markdown-with-directive/components/with-icon-card-item.spec.tsx b/web/app/components/base/markdown-with-directive/components/with-icon-card-item.spec.tsx index 58eb24d75e..dbe293dcf6 100644 --- a/web/app/components/base/markdown-with-directive/components/with-icon-card-item.spec.tsx +++ b/web/app/components/base/markdown-with-directive/components/with-icon-card-item.spec.tsx @@ -1,10 +1,6 @@ import { render, screen } from '@testing-library/react' import WithIconCardItem from './with-icon-card-item' -vi.mock('next/image', () => ({ - default: ({ unoptimized: _unoptimized, ...props }: React.ImgHTMLAttributes & { unoptimized?: boolean }) => , -})) - describe('WithIconCardItem', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/components/base/markdown-with-directive/components/with-icon-card-item.tsx b/web/app/components/base/markdown-with-directive/components/with-icon-card-item.tsx index 915c31f160..9eac1282a9 100644 --- a/web/app/components/base/markdown-with-directive/components/with-icon-card-item.tsx +++ b/web/app/components/base/markdown-with-directive/components/with-icon-card-item.tsx @@ -1,6 +1,5 @@ import type { ReactNode } from 'react' import type { WithIconCardItemProps } from './markdown-with-directive-schema' -import Image from 'next/image' import { cn } from '@/utils/classnames' type WithIconItemProps = WithIconCardItemProps & { @@ -11,18 +10,13 @@ type WithIconItemProps = WithIconCardItemProps & { function WithIconCardItem({ icon, children, className, iconAlt }: WithIconItemProps) { return (
- {/* - * unoptimized to "url parameter is not allowed" for external domains despite correct remotePatterns configuration. - * https://github.com/vercel/next.js/issues/88873 - */} - {iconAlt
{children} diff --git a/web/app/components/base/markdown-with-directive/index.spec.tsx b/web/app/components/base/markdown-with-directive/index.spec.tsx index 0ca608727f..fc4b813247 100644 --- a/web/app/components/base/markdown-with-directive/index.spec.tsx +++ b/web/app/components/base/markdown-with-directive/index.spec.tsx @@ -7,10 +7,6 @@ import { MarkdownWithDirective } from './index' const FOUR_COLON_RE = /:{4}/ -vi.mock('next/image', () => ({ - default: (props: React.ImgHTMLAttributes) => , -})) - function expectDecorativeIcon(container: HTMLElement, src: string) { const icon = container.querySelector('img') expect(icon).toBeInTheDocument() diff --git a/web/app/components/base/notion-page-selector/credential-selector/__tests__/index.spec.tsx b/web/app/components/base/notion-page-selector/credential-selector/__tests__/index.spec.tsx index efcf015ea5..f1f1cf08d2 100644 --- a/web/app/components/base/notion-page-selector/credential-selector/__tests__/index.spec.tsx +++ b/web/app/components/base/notion-page-selector/credential-selector/__tests__/index.spec.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' import CredentialSelector from '../index' -// Mock CredentialIcon since it's likely a complex component or uses next/image +// Mock CredentialIcon since it's likely a complex component. vi.mock('@/app/components/datasets/common/credential-icon', () => ({ CredentialIcon: ({ name }: { name: string }) =>
{name}
, })) diff --git a/web/app/components/datasets/common/retrieval-method-info/__tests__/index.spec.tsx b/web/app/components/datasets/common/retrieval-method-info/__tests__/index.spec.tsx index 36120de738..ad230fb596 100644 --- a/web/app/components/datasets/common/retrieval-method-info/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/retrieval-method-info/__tests__/index.spec.tsx @@ -4,13 +4,6 @@ import { RETRIEVE_METHOD } from '@/types/app' import { retrievalIcon } from '../../../create/icons' import RetrievalMethodInfo, { getIcon } from '../index' -// Override global next/image auto-mock: tests assert on rendered src attributes via data-testid -vi.mock('next/image', () => ({ - default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => ( - {alt - ), -})) - // Mock RadioCard vi.mock('@/app/components/base/radio-card', () => ({ default: ({ title, description, chosenConfig, icon }: { title: string, description: string, chosenConfig: ReactNode, icon: ReactNode }) => ( @@ -50,7 +43,7 @@ describe('RetrievalMethodInfo', () => { }) it('should render correctly with full config', () => { - render() + const { container } = render() expect(screen.getByTestId('radio-card')).toBeInTheDocument() @@ -59,7 +52,7 @@ describe('RetrievalMethodInfo', () => { expect(screen.getByTestId('card-description')).toHaveTextContent('dataset.retrieval.semantic_search.description') // Check Icon - const icon = screen.getByTestId('method-icon') + const icon = container.querySelector('img') expect(icon).toHaveAttribute('src', 'vector-icon.png') // Check Config Details @@ -87,18 +80,18 @@ describe('RetrievalMethodInfo', () => { it('should handle different retrieval methods', () => { // Test Hybrid const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid } - const { unmount } = render() + const { container, unmount } = render() expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.hybrid_search.title') - expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'hybrid-icon.png') + expect(container.querySelector('img')).toHaveAttribute('src', 'hybrid-icon.png') unmount() // Test FullText const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText } - render() + const { container: fullTextContainer } = render() expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.full_text_search.title') - expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'fulltext-icon.png') + expect(fullTextContainer.querySelector('img')).toHaveAttribute('src', 'fulltext-icon.png') }) describe('getIcon utility', () => { @@ -132,17 +125,17 @@ describe('RetrievalMethodInfo', () => { it('should render correctly with invertedIndex search method', () => { const invertedIndexConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.invertedIndex } - render() + const { container } = render() // invertedIndex uses vector icon - expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png') + expect(container.querySelector('img')).toHaveAttribute('src', 'vector-icon.png') }) it('should render correctly with keywordSearch search method', () => { const keywordSearchConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.keywordSearch } - render() + const { container } = render() // keywordSearch uses vector icon - expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png') + expect(container.querySelector('img')).toHaveAttribute('src', 'vector-icon.png') }) }) diff --git a/web/app/components/datasets/common/retrieval-method-info/index.tsx b/web/app/components/datasets/common/retrieval-method-info/index.tsx index 398b79975f..d23d247307 100644 --- a/web/app/components/datasets/common/retrieval-method-info/index.tsx +++ b/web/app/components/datasets/common/retrieval-method-info/index.tsx @@ -1,7 +1,6 @@ 'use client' import type { FC } from 'react' import type { RetrievalConfig } from '@/types/app' -import Image from 'next/image' import * as React from 'react' import { useTranslation } from 'react-i18next' import RadioCard from '@/app/components/base/radio-card' @@ -28,7 +27,7 @@ const EconomicalRetrievalMethodConfig: FC = ({ }) => { const { t } = useTranslation() const type = value.search_method - const icon = + const icon = return (
= ({ /> )}
- {t('modelProvider.rerankModel.key', { ns: 'common' })} + {t('modelProvider.rerankModel.key', { ns: 'common' })} {t('modelProvider.rerankModel.tip', { ns: 'common' })}
@@ -157,7 +156,7 @@ const RetrievalParamConfig: FC = ({
- + {t('form.retrievalSetting.multiModalTip', { ns: 'datasetSettings' })}
@@ -215,11 +214,11 @@ const RetrievalParamConfig: FC = ({ isChosen={value.reranking_mode === option.value} onChosen={() => handleChangeRerankMode(option.value)} icon={( - @@ -281,7 +280,7 @@ const RetrievalParamConfig: FC = ({
- + {t('form.retrievalSetting.multiModalTip', { ns: 'datasetSettings' })}
diff --git a/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx b/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx index 9f06abdc41..686139250a 100644 --- a/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx @@ -20,14 +20,6 @@ vi.mock('next/navigation', () => ({ useRouter: () => mockRouter, })) -// Override global next/image auto-mock: test asserts on data-testid="next-image" -vi.mock('next/image', () => ({ - default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => ( - // eslint-disable-next-line next/no-img-element - {alt} - ), -})) - // Mock API service const mockFetchIndexingStatusBatch = vi.fn() vi.mock('@/service/datasets', () => ({ @@ -979,9 +971,9 @@ describe('RuleDetail', () => { }) it('should render correct icon for indexing type', () => { - render() + const { container } = render() - const images = screen.getAllByTestId('next-image') + const images = container.querySelectorAll('img') expect(images.length).toBeGreaterThan(0) }) }) diff --git a/web/app/components/datasets/create/embedding-process/rule-detail.tsx b/web/app/components/datasets/create/embedding-process/rule-detail.tsx index dff35100cb..553c751056 100644 --- a/web/app/components/datasets/create/embedding-process/rule-detail.tsx +++ b/web/app/components/datasets/create/embedding-process/rule-detail.tsx @@ -1,6 +1,5 @@ import type { FC } from 'react' import type { ProcessRuleResponse } from '@/models/datasets' -import Image from 'next/image' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { FieldInfo } from '@/app/components/datasets/documents/detail/metadata' @@ -119,12 +118,12 @@ const RuleDetail: FC = ({ sourceData, indexingType, retrievalMe } + valueIcon={} /> } + valueIcon={} />
) diff --git a/web/app/components/datasets/create/icons.ts b/web/app/components/datasets/create/icons.ts index 10f3a319dc..75cbba0c6b 100644 --- a/web/app/components/datasets/create/icons.ts +++ b/web/app/components/datasets/create/icons.ts @@ -5,12 +5,12 @@ import Research from './assets/research-mod.svg' import Selection from './assets/selection-mod.svg' export const indexMethodIcon = { - high_quality: GoldIcon, - economical: Piggybank, + high_quality: GoldIcon.src, + economical: Piggybank.src, } export const retrievalIcon = { - vector: Selection, - fullText: Research, - hybrid: PatternRecognition, + vector: Selection.src, + fullText: Research.src, + hybrid: PatternRecognition.src, } diff --git a/web/app/components/datasets/create/step-two/components/__tests__/option-card.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/option-card.spec.tsx index e543efec86..d59e759ab1 100644 --- a/web/app/components/datasets/create/step-two/components/__tests__/option-card.spec.tsx +++ b/web/app/components/datasets/create/step-two/components/__tests__/option-card.spec.tsx @@ -2,13 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { OptionCard, OptionCardHeader } from '../option-card' -// Override global next/image auto-mock: tests assert on rendered elements -vi.mock('next/image', () => ({ - default: ({ src, alt, ...props }: { src?: string, alt?: string, width?: number, height?: number }) => ( - {alt} - ), -})) - describe('OptionCardHeader', () => { const defaultProps = { icon: icon, diff --git a/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx b/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx index 0beda8f5c8..650fd3ebfb 100644 --- a/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx +++ b/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx @@ -6,7 +6,6 @@ import { RiAlertFill, RiSearchEyeLine, } from '@remixicon/react' -import Image from 'next/image' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Checkbox from '@/app/components/base/checkbox' @@ -26,7 +25,7 @@ type TextLabelProps = { } const TextLabel: FC = ({ children }) => { - return + return } type GeneralChunkingOptionsProps = { @@ -97,7 +96,7 @@ export const GeneralChunkingOptions: FC = ({ } + icon={{t('stepTwo.general',} activeHeaderClassName="bg-dataset-option-card-blue-gradient" description={t('stepTwo.generalTip', { ns: 'datasetCreation' })} isActive={isActive} @@ -148,7 +147,7 @@ export const GeneralChunkingOptions: FC = ({ onClick={() => onRuleToggle(rule.id)} > -
@@ -183,7 +182,7 @@ export const GeneralChunkingOptions: FC = ({ checked={currentDocForm === ChunkingMode.qa} disabled={hasCurrentDatasetDocForm} /> -
@@ -202,7 +201,7 @@ export const GeneralChunkingOptions: FC = ({ className="mt-2 flex h-10 items-center gap-2 rounded-xl border border-components-panel-border px-3 text-xs shadow-xs backdrop-blur-[5px]" > - + {t('stepTwo.QATip', { ns: 'datasetCreation' })}
diff --git a/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx b/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx index b172778f54..da309348cc 100644 --- a/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx +++ b/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx @@ -3,7 +3,6 @@ import type { FC } from 'react' import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { RetrievalConfig } from '@/types/app' -import Image from 'next/image' import Link from 'next/link' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' @@ -70,7 +69,7 @@ export const IndexingModeSection: FC = ({ return ( <> {/* Index Mode */} -
+
{t('stepTwo.indexMode', { ns: 'datasetCreation' })}
@@ -98,7 +97,7 @@ export const IndexingModeSection: FC = ({
)} description={t('stepTwo.qualifiedTip', { ns: 'datasetCreation' })} - icon={} + icon={} isActive={!hasSetIndexType && indexType === IndexingType.QUALIFIED} disabled={hasSetIndexType} onSwitched={() => onIndexTypeChange(IndexingType.QUALIFIED)} @@ -143,7 +142,7 @@ export const IndexingModeSection: FC = ({ className="h-full" title={t('stepTwo.economical', { ns: 'datasetCreation' })} description={t('stepTwo.economicalTip', { ns: 'datasetCreation' })} - icon={} + icon={} isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL} disabled={hasSetIndexType || docForm !== ChunkingMode.text} onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)} @@ -160,7 +159,7 @@ export const IndexingModeSection: FC = ({
- + {t('stepTwo.highQualityTip', { ns: 'datasetCreation' })}
@@ -168,7 +167,7 @@ export const IndexingModeSection: FC = ({ {/* Economical index setting tip */} {hasSetIndexType && indexType === IndexingType.ECONOMICAL && ( -
+
{t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })} {t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })} @@ -179,7 +178,7 @@ export const IndexingModeSection: FC = ({ {/* Embedding model */} {indexType === IndexingType.QUALIFIED && (
-
+
{t('form.embeddingModel', { ns: 'datasetSettings' })}
= ({ onSelect={onEmbeddingModelChange} /> {isModelAndRetrievalConfigDisabled && ( -
+
{t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })} {t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })} @@ -207,10 +206,10 @@ export const IndexingModeSection: FC = ({ {!isModelAndRetrievalConfigDisabled ? (
-
+
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
-
+ ) : ( -
+
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
)} diff --git a/web/app/components/datasets/create/step-two/components/option-card.tsx b/web/app/components/datasets/create/step-two/components/option-card.tsx index 55cbf5276f..320c7be44f 100644 --- a/web/app/components/datasets/create/step-two/components/option-card.tsx +++ b/web/app/components/datasets/create/step-two/components/option-card.tsx @@ -1,5 +1,4 @@ import type { ComponentProps, FC, ReactNode } from 'react' -import Image from 'next/image' import { cn } from '@/utils/classnames' const TriangleArrow: FC> = props => ( @@ -23,7 +22,7 @@ export const OptionCardHeader: FC = (props) => { return (
- {isActive && effectImg && } + {isActive && effectImg && }
{icon} @@ -34,8 +33,8 @@ export const OptionCardHeader: FC = (props) => { className={cn('absolute -bottom-1.5 left-4 text-transparent', isActive && 'text-components-panel-bg')} />
-
{title}
-
{description}
+
{title}
+
{description}
) diff --git a/web/app/components/datasets/create/step-two/components/parent-child-options.tsx b/web/app/components/datasets/create/step-two/components/parent-child-options.tsx index b7b965a4fd..eb542fd3d5 100644 --- a/web/app/components/datasets/create/step-two/components/parent-child-options.tsx +++ b/web/app/components/datasets/create/step-two/components/parent-child-options.tsx @@ -4,7 +4,6 @@ import type { FC } from 'react' import type { ParentChildConfig } from '../hooks' import type { ParentMode, PreProcessingRule, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets' import { RiSearchEyeLine } from '@remixicon/react' -import Image from 'next/image' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Checkbox from '@/app/components/base/checkbox' @@ -26,7 +25,7 @@ type TextLabelProps = { } const TextLabel: FC = ({ children }) => { - return + return } type ParentChildOptionsProps = { @@ -118,7 +117,7 @@ export const ParentChildOptions: FC = ({
} + icon={} title={t('stepTwo.paragraph', { ns: 'datasetCreation' })} description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })} isChosen={parentChildConfig.chunkForContext === 'paragraph'} @@ -140,7 +139,7 @@ export const ParentChildOptions: FC = ({ /> } + icon={} title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })} description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })} onChosen={() => onChunkForContextChange('full-doc')} @@ -186,7 +185,7 @@ export const ParentChildOptions: FC = ({ onClick={() => onRuleToggle(rule.id)} > -
diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx index c11caeb156..c0873f2c5d 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx @@ -6,14 +6,6 @@ import { ProcessMode } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' import RuleDetail from '../rule-detail' -// Override global next/image auto-mock: tests assert on data-testid="next-image" and src attributes -vi.mock('next/image', () => ({ - default: function MockImage({ src, alt, className }: { src: string, alt: string, className?: string }) { - // eslint-disable-next-line next/no-img-element - return {alt} - }, -})) - // Mock FieldInfo component vi.mock('@/app/components/datasets/documents/detail/metadata', () => ({ FieldInfo: ({ label, displayedValue, valueIcon }: { label: string, displayedValue: string, valueIcon?: React.ReactNode }) => ( @@ -184,16 +176,16 @@ describe('RuleDetail', () => { }) it('should show high_quality icon for qualified indexing', () => { - render() + const { container } = render() - const images = screen.getAllByTestId('next-image') + const images = container.querySelectorAll('img') expect(images[0]).toHaveAttribute('src', '/icons/high_quality.svg') }) it('should show economical icon for economical indexing', () => { - render() + const { container } = render() - const images = screen.getAllByTestId('next-image') + const images = container.querySelectorAll('img') expect(images[0]).toHaveAttribute('src', '/icons/economical.svg') }) }) @@ -256,38 +248,38 @@ describe('RuleDetail', () => { }) it('should show vector icon for semantic search', () => { - render( + const { container } = render( , ) - const images = screen.getAllByTestId('next-image') + const images = container.querySelectorAll('img') expect(images[1]).toHaveAttribute('src', '/icons/vector.svg') }) it('should show fullText icon for full text search', () => { - render( + const { container } = render( , ) - const images = screen.getAllByTestId('next-image') + const images = container.querySelectorAll('img') expect(images[1]).toHaveAttribute('src', '/icons/fullText.svg') }) it('should show hybrid icon for hybrid search', () => { - render( + const { container } = render( , ) - const images = screen.getAllByTestId('next-image') + const images = container.querySelectorAll('img') expect(images[1]).toHaveAttribute('src', '/icons/hybrid.svg') }) }) @@ -308,9 +300,9 @@ describe('RuleDetail', () => { }) it('should handle undefined retrievalMethod with defined indexingType', () => { - render() + const { container } = render() - const images = screen.getAllByTestId('next-image') + const images = container.querySelectorAll('img') // When retrievalMethod is undefined, vector icon is used as default expect(images[1]).toHaveAttribute('src', '/icons/vector.svg') }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx index 8fe6af6170..526d31f3fe 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx @@ -1,5 +1,4 @@ import type { ProcessRuleResponse } from '@/models/datasets' -import Image from 'next/image' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' @@ -50,7 +49,7 @@ const RuleDetail = ({ label={t('stepTwo.indexMode', { ns: 'datasetCreation' })} displayedValue={t(`stepTwo.${indexingType === IndexingType.ECONOMICAL ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string} valueIcon={( - = React.memo(({ label={t('stepTwo.indexMode', { ns: 'datasetCreation' })} displayedValue={t(`stepTwo.${isEconomical ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string} valueIcon={( - = React.memo(({ label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })} displayedValue={t(`retrieval.${isEconomical ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })} valueIcon={( - + const icon = const TextAreaComp = useMemo(() => { return (