From 5549ab66ffb849e9c4ce6eee924f773d6a26caa1 Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 25 Dec 2025 15:34:24 +0800 Subject: [PATCH 01/22] chore: some test (#30144) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../billing/billing-page/index.spec.tsx | 84 ++++++++ .../billing/header-billing-btn/index.spec.tsx | 92 ++++++++ .../billing/partner-stack/index.spec.tsx | 44 ++++ .../partner-stack/use-ps-info.spec.tsx | 197 ++++++++++++++++++ .../components/billing/plan/index.spec.tsx | 130 ++++++++++++ .../billing/progress-bar/index.spec.tsx | 25 +++ .../trigger-events-limit-modal/index.spec.tsx | 70 +++++++ .../billing/usage-info/apps-info.spec.tsx | 35 ++++ .../billing/usage-info/index.spec.tsx | 114 ++++++++++ .../billing/vector-space-full/index.spec.tsx | 58 ++++++ 10 files changed, 849 insertions(+) create mode 100644 web/app/components/billing/billing-page/index.spec.tsx create mode 100644 web/app/components/billing/header-billing-btn/index.spec.tsx create mode 100644 web/app/components/billing/partner-stack/index.spec.tsx create mode 100644 web/app/components/billing/partner-stack/use-ps-info.spec.tsx create mode 100644 web/app/components/billing/plan/index.spec.tsx create mode 100644 web/app/components/billing/progress-bar/index.spec.tsx create mode 100644 web/app/components/billing/trigger-events-limit-modal/index.spec.tsx create mode 100644 web/app/components/billing/usage-info/apps-info.spec.tsx create mode 100644 web/app/components/billing/usage-info/index.spec.tsx create mode 100644 web/app/components/billing/vector-space-full/index.spec.tsx diff --git a/web/app/components/billing/billing-page/index.spec.tsx b/web/app/components/billing/billing-page/index.spec.tsx new file mode 100644 index 0000000000..2310baa4f4 --- /dev/null +++ b/web/app/components/billing/billing-page/index.spec.tsx @@ -0,0 +1,84 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import Billing from './index' + +let currentBillingUrl: string | null = 'https://billing' +let fetching = false +let isManager = true +let enableBilling = true + +const refetchMock = vi.fn() +const openAsyncWindowMock = vi.fn() + +vi.mock('@/service/use-billing', () => ({ + useBillingUrl: () => ({ + data: currentBillingUrl, + isFetching: fetching, + refetch: refetchMock, + }), +})) + +vi.mock('@/hooks/use-async-window-open', () => ({ + useAsyncWindowOpen: () => openAsyncWindowMock, +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: isManager, + }), +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + enableBilling, + }), +})) + +vi.mock('../plan', () => ({ + __esModule: true, + default: ({ loc }: { loc: string }) =>
, +})) + +describe('Billing', () => { + beforeEach(() => { + vi.clearAllMocks() + currentBillingUrl = 'https://billing' + fetching = false + isManager = true + enableBilling = true + refetchMock.mockResolvedValue({ data: 'https://billing' }) + }) + + it('hides the billing action when user is not manager or billing is disabled', () => { + isManager = false + render() + expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument() + + vi.clearAllMocks() + isManager = true + enableBilling = false + render() + expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument() + }) + + it('opens the billing window with the immediate url when the button is clicked', async () => { + render() + + const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ }) + fireEvent.click(actionButton) + + await waitFor(() => expect(openAsyncWindowMock).toHaveBeenCalled()) + const [, options] = openAsyncWindowMock.mock.calls[0] + expect(options).toMatchObject({ + immediateUrl: currentBillingUrl, + features: 'noopener,noreferrer', + }) + }) + + it('disables the button while billing url is fetching', () => { + fetching = true + render() + + const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ }) + expect(actionButton).toBeDisabled() + }) +}) diff --git a/web/app/components/billing/header-billing-btn/index.spec.tsx b/web/app/components/billing/header-billing-btn/index.spec.tsx new file mode 100644 index 0000000000..b87b733353 --- /dev/null +++ b/web/app/components/billing/header-billing-btn/index.spec.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { Plan } from '../type' +import HeaderBillingBtn from './index' + +type HeaderGlobal = typeof globalThis & { + __mockProviderContext?: ReturnType +} + +function getHeaderGlobal(): HeaderGlobal { + return globalThis as HeaderGlobal +} + +const ensureProviderContextMock = () => { + const globals = getHeaderGlobal() + if (!globals.__mockProviderContext) + throw new Error('Provider context mock not set') + return globals.__mockProviderContext +} + +vi.mock('@/context/provider-context', () => { + const mock = vi.fn() + const globals = getHeaderGlobal() + globals.__mockProviderContext = mock + return { + useProviderContext: () => mock(), + } +}) + +vi.mock('../upgrade-btn', () => ({ + __esModule: true, + default: () => , +})) + +describe('HeaderBillingBtn', () => { + beforeEach(() => { + vi.clearAllMocks() + ensureProviderContextMock().mockReturnValue({ + plan: { + type: Plan.professional, + }, + enableBilling: true, + isFetchedPlan: true, + }) + }) + + it('renders nothing when billing is disabled or plan is not fetched', () => { + ensureProviderContextMock().mockReturnValueOnce({ + plan: { + type: Plan.professional, + }, + enableBilling: false, + isFetchedPlan: true, + }) + + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + it('renders upgrade button for sandbox plan', () => { + ensureProviderContextMock().mockReturnValueOnce({ + plan: { + type: Plan.sandbox, + }, + enableBilling: true, + isFetchedPlan: true, + }) + + render() + + expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument() + }) + + it('renders plan badge and forwards clicks when not display-only', () => { + const onClick = vi.fn() + + const { rerender } = render() + + const badge = screen.getByText('pro').closest('div') + + expect(badge).toHaveClass('cursor-pointer') + + fireEvent.click(badge!) + expect(onClick).toHaveBeenCalledTimes(1) + + rerender() + expect(screen.getByText('pro').closest('div')).toHaveClass('cursor-default') + + fireEvent.click(screen.getByText('pro').closest('div')!) + expect(onClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/billing/partner-stack/index.spec.tsx b/web/app/components/billing/partner-stack/index.spec.tsx new file mode 100644 index 0000000000..7b4658cf0f --- /dev/null +++ b/web/app/components/billing/partner-stack/index.spec.tsx @@ -0,0 +1,44 @@ +import { render } from '@testing-library/react' +import PartnerStack from './index' + +let isCloudEdition = true + +const saveOrUpdate = vi.fn() +const bind = vi.fn() + +vi.mock('@/config', () => ({ + get IS_CLOUD_EDITION() { + return isCloudEdition + }, +})) + +vi.mock('./use-ps-info', () => ({ + __esModule: true, + default: () => ({ + saveOrUpdate, + bind, + }), +})) + +describe('PartnerStack', () => { + beforeEach(() => { + vi.clearAllMocks() + isCloudEdition = true + }) + + it('does not call partner stack helpers when not in cloud edition', () => { + isCloudEdition = false + + render() + + expect(saveOrUpdate).not.toHaveBeenCalled() + expect(bind).not.toHaveBeenCalled() + }) + + it('calls saveOrUpdate and bind once when running in cloud edition', () => { + render() + + expect(saveOrUpdate).toHaveBeenCalledTimes(1) + expect(bind).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/billing/partner-stack/use-ps-info.spec.tsx b/web/app/components/billing/partner-stack/use-ps-info.spec.tsx new file mode 100644 index 0000000000..14215f2514 --- /dev/null +++ b/web/app/components/billing/partner-stack/use-ps-info.spec.tsx @@ -0,0 +1,197 @@ +import { act, renderHook } from '@testing-library/react' +import { PARTNER_STACK_CONFIG } from '@/config' +import usePSInfo from './use-ps-info' + +let searchParamsValues: Record = {} +const setSearchParams = (values: Record) => { + searchParamsValues = values +} + +type PartnerStackGlobal = typeof globalThis & { + __partnerStackCookieMocks?: { + get: ReturnType + set: ReturnType + remove: ReturnType + } + __partnerStackMutateAsync?: ReturnType +} + +function getPartnerStackGlobal(): PartnerStackGlobal { + return globalThis as PartnerStackGlobal +} + +const ensureCookieMocks = () => { + const globals = getPartnerStackGlobal() + if (!globals.__partnerStackCookieMocks) + throw new Error('Cookie mocks not initialized') + return globals.__partnerStackCookieMocks +} + +const ensureMutateAsync = () => { + const globals = getPartnerStackGlobal() + if (!globals.__partnerStackMutateAsync) + throw new Error('Mutate mock not initialized') + return globals.__partnerStackMutateAsync +} + +vi.mock('js-cookie', () => { + const get = vi.fn() + const set = vi.fn() + const remove = vi.fn() + const globals = getPartnerStackGlobal() + globals.__partnerStackCookieMocks = { get, set, remove } + const cookieApi = { get, set, remove } + return { + __esModule: true, + default: cookieApi, + get, + set, + remove, + } +}) +vi.mock('next/navigation', () => ({ + useSearchParams: () => ({ + get: (key: string) => searchParamsValues[key] ?? null, + }), +})) +vi.mock('@/service/use-billing', () => { + const mutateAsync = vi.fn() + const globals = getPartnerStackGlobal() + globals.__partnerStackMutateAsync = mutateAsync + return { + useBindPartnerStackInfo: () => ({ + mutateAsync, + }), + } +}) + +describe('usePSInfo', () => { + const originalLocationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location') + + beforeAll(() => { + Object.defineProperty(globalThis, 'location', { + value: { hostname: 'cloud.dify.ai' }, + configurable: true, + }) + }) + + beforeEach(() => { + setSearchParams({}) + const { get, set, remove } = ensureCookieMocks() + get.mockReset() + set.mockReset() + remove.mockReset() + const mutate = ensureMutateAsync() + mutate.mockReset() + mutate.mockResolvedValue(undefined) + get.mockReturnValue('{}') + }) + + afterAll(() => { + if (originalLocationDescriptor) + Object.defineProperty(globalThis, 'location', originalLocationDescriptor) + }) + + it('saves partner info when query params change', () => { + const { get, set } = ensureCookieMocks() + get.mockReturnValue(JSON.stringify({ partnerKey: 'old', clickId: 'old-click' })) + setSearchParams({ + ps_partner_key: 'new-partner', + ps_xid: 'new-click', + }) + + const { result } = renderHook(() => usePSInfo()) + + expect(result.current.psPartnerKey).toBe('new-partner') + expect(result.current.psClickId).toBe('new-click') + + act(() => { + result.current.saveOrUpdate() + }) + + expect(set).toHaveBeenCalledWith( + PARTNER_STACK_CONFIG.cookieName, + JSON.stringify({ + partnerKey: 'new-partner', + clickId: 'new-click', + }), + { + expires: PARTNER_STACK_CONFIG.saveCookieDays, + path: '/', + domain: '.dify.ai', + }, + ) + }) + + it('does not overwrite cookie when params do not change', () => { + setSearchParams({ + ps_partner_key: 'existing', + ps_xid: 'existing-click', + }) + const { get } = ensureCookieMocks() + get.mockReturnValue(JSON.stringify({ + partnerKey: 'existing', + clickId: 'existing-click', + })) + + const { result } = renderHook(() => usePSInfo()) + + act(() => { + result.current.saveOrUpdate() + }) + + const { set } = ensureCookieMocks() + expect(set).not.toHaveBeenCalled() + }) + + it('binds partner info and clears cookie once', async () => { + setSearchParams({ + ps_partner_key: 'bind-partner', + ps_xid: 'bind-click', + }) + + const { result } = renderHook(() => usePSInfo()) + + const mutate = ensureMutateAsync() + const { remove } = ensureCookieMocks() + await act(async () => { + await result.current.bind() + }) + + expect(mutate).toHaveBeenCalledWith({ + partnerKey: 'bind-partner', + clickId: 'bind-click', + }) + expect(remove).toHaveBeenCalledWith(PARTNER_STACK_CONFIG.cookieName, { + path: '/', + domain: '.dify.ai', + }) + + await act(async () => { + await result.current.bind() + }) + + expect(mutate).toHaveBeenCalledTimes(1) + }) + + it('still removes cookie when bind fails with status 400', async () => { + const mutate = ensureMutateAsync() + mutate.mockRejectedValueOnce({ status: 400 }) + setSearchParams({ + ps_partner_key: 'bind-partner', + ps_xid: 'bind-click', + }) + + const { result } = renderHook(() => usePSInfo()) + + await act(async () => { + await result.current.bind() + }) + + const { remove } = ensureCookieMocks() + expect(remove).toHaveBeenCalledWith(PARTNER_STACK_CONFIG.cookieName, { + path: '/', + domain: '.dify.ai', + }) + }) +}) diff --git a/web/app/components/billing/plan/index.spec.tsx b/web/app/components/billing/plan/index.spec.tsx new file mode 100644 index 0000000000..bcdb83b5df --- /dev/null +++ b/web/app/components/billing/plan/index.spec.tsx @@ -0,0 +1,130 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants' +import { Plan } from '../type' +import PlanComp from './index' + +let currentPath = '/billing' + +const push = vi.fn() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push }), + usePathname: () => currentPath, +})) + +const setShowAccountSettingModalMock = vi.fn() +vi.mock('@/context/modal-context', () => ({ + // eslint-disable-next-line ts/no-explicit-any + useModalContextSelector: (selector: any) => selector({ + setShowAccountSettingModal: setShowAccountSettingModalMock, + }), +})) + +const providerContextMock = vi.fn() +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => providerContextMock(), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + userProfile: { email: 'user@example.com' }, + isCurrentWorkspaceManager: true, + }), +})) + +const mutateAsyncMock = vi.fn() +let isPending = false +vi.mock('@/service/use-education', () => ({ + useEducationVerify: () => ({ + mutateAsync: mutateAsyncMock, + isPending, + }), +})) + +const verifyStateModalMock = vi.fn(props => ( +
+ {props.isShow ? 'visible' : 'hidden'} +
+)) +vi.mock('@/app/education-apply/verify-state-modal', () => ({ + __esModule: true, + // eslint-disable-next-line ts/no-explicit-any + default: (props: any) => verifyStateModalMock(props), +})) + +vi.mock('../upgrade-btn', () => ({ + __esModule: true, + default: () => , +})) + +describe('PlanComp', () => { + const planMock = { + type: Plan.professional, + usage: { + teamMembers: 4, + documentsUploadQuota: 3, + vectorSpace: 8, + annotatedResponse: 5, + triggerEvents: 60, + apiRateLimit: 100, + }, + total: { + teamMembers: 10, + documentsUploadQuota: 20, + vectorSpace: 10, + annotatedResponse: 500, + triggerEvents: 100, + apiRateLimit: 200, + }, + reset: { + triggerEvents: 2, + apiRateLimit: 1, + }, + } + + beforeEach(() => { + vi.clearAllMocks() + currentPath = '/billing' + isPending = false + providerContextMock.mockReturnValue({ + plan: planMock, + enableEducationPlan: true, + allowRefreshEducationVerify: false, + isEducationAccount: false, + }) + mutateAsyncMock.mockReset() + mutateAsyncMock.mockResolvedValue({ token: 'token' }) + }) + + it('renders plan info and handles education verify success', async () => { + render() + + expect(screen.getByText('billing.plans.professional.name')).toBeInTheDocument() + expect(screen.getByTestId('plan-upgrade-btn')).toBeInTheDocument() + + const verifyBtn = screen.getByText('education.toVerified') + fireEvent.click(verifyBtn) + + await waitFor(() => expect(mutateAsyncMock).toHaveBeenCalled()) + await waitFor(() => expect(push).toHaveBeenCalledWith('/education-apply?token=token')) + expect(localStorage.removeItem).toHaveBeenCalledWith(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) + }) + + it('shows modal when education verify fails', async () => { + mutateAsyncMock.mockRejectedValueOnce(new Error('boom')) + render() + + const verifyBtn = screen.getByText('education.toVerified') + fireEvent.click(verifyBtn) + + await waitFor(() => expect(mutateAsyncMock).toHaveBeenCalled()) + await waitFor(() => expect(screen.getByTestId('verify-modal').getAttribute('data-is-show')).toBe('true')) + }) + + it('resets modal context when on education apply path', () => { + currentPath = '/education-apply/setup' + render() + + expect(setShowAccountSettingModalMock).toHaveBeenCalledWith(null) + }) +}) diff --git a/web/app/components/billing/progress-bar/index.spec.tsx b/web/app/components/billing/progress-bar/index.spec.tsx new file mode 100644 index 0000000000..a9c91468de --- /dev/null +++ b/web/app/components/billing/progress-bar/index.spec.tsx @@ -0,0 +1,25 @@ +import { render, screen } from '@testing-library/react' +import ProgressBar from './index' + +describe('ProgressBar', () => { + it('renders with provided percent and color', () => { + render() + + const bar = screen.getByTestId('billing-progress-bar') + expect(bar).toHaveClass('bg-test-color') + expect(bar.getAttribute('style')).toContain('width: 42%') + }) + + it('caps width at 100% when percent exceeds max', () => { + render() + + const bar = screen.getByTestId('billing-progress-bar') + expect(bar.getAttribute('style')).toContain('width: 100%') + }) + + it('uses the default color when no color prop is provided', () => { + render() + + expect(screen.getByTestId('billing-progress-bar')).toHaveClass('#2970FF') + }) +}) diff --git a/web/app/components/billing/trigger-events-limit-modal/index.spec.tsx b/web/app/components/billing/trigger-events-limit-modal/index.spec.tsx new file mode 100644 index 0000000000..a3d04c6031 --- /dev/null +++ b/web/app/components/billing/trigger-events-limit-modal/index.spec.tsx @@ -0,0 +1,70 @@ +import { render, screen } from '@testing-library/react' +import TriggerEventsLimitModal from './index' + +const mockOnClose = vi.fn() +const mockOnUpgrade = vi.fn() + +const planUpgradeModalMock = vi.fn((props: { show: boolean, title: string, description: string, extraInfo?: React.ReactNode, onClose: () => void, onUpgrade: () => void }) => ( +
+ {props.extraInfo} +
+)) + +vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({ + __esModule: true, + // eslint-disable-next-line ts/no-explicit-any + default: (props: any) => planUpgradeModalMock(props), +})) + +describe('TriggerEventsLimitModal', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('passes the trigger usage props to the upgrade modal', () => { + render( + , + ) + + const modal = screen.getByTestId('plan-upgrade-modal') + expect(modal.getAttribute('data-show')).toBe('true') + expect(modal.getAttribute('data-title')).toContain('billing.triggerLimitModal.title') + expect(modal.getAttribute('data-description')).toContain('billing.triggerLimitModal.description') + expect(planUpgradeModalMock).toHaveBeenCalled() + + const passedProps = planUpgradeModalMock.mock.calls[0][0] + expect(passedProps.onClose).toBe(mockOnClose) + expect(passedProps.onUpgrade).toBe(mockOnUpgrade) + + expect(screen.getByText('billing.triggerLimitModal.usageTitle')).toBeInTheDocument() + expect(screen.getByText('12')).toBeInTheDocument() + expect(screen.getByText('20')).toBeInTheDocument() + }) + + it('renders even when trigger modal is hidden', () => { + render( + , + ) + + expect(planUpgradeModalMock).toHaveBeenCalled() + expect(screen.getByTestId('plan-upgrade-modal').getAttribute('data-show')).toBe('false') + }) +}) diff --git a/web/app/components/billing/usage-info/apps-info.spec.tsx b/web/app/components/billing/usage-info/apps-info.spec.tsx new file mode 100644 index 0000000000..7289b474e5 --- /dev/null +++ b/web/app/components/billing/usage-info/apps-info.spec.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react' +import { defaultPlan } from '../config' +import AppsInfo from './apps-info' + +const appsUsage = 7 +const appsTotal = 15 + +const mockPlan = { + ...defaultPlan, + usage: { + ...defaultPlan.usage, + buildApps: appsUsage, + }, + total: { + ...defaultPlan.total, + buildApps: appsTotal, + }, +} + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + plan: mockPlan, + }), +})) + +describe('AppsInfo', () => { + it('renders build apps usage information with context data', () => { + render() + + expect(screen.getByText('billing.usagePage.buildApps')).toBeInTheDocument() + expect(screen.getByText(`${appsUsage}`)).toBeInTheDocument() + expect(screen.getByText(`${appsTotal}`)).toBeInTheDocument() + expect(screen.getByText('billing.usagePage.buildApps').closest('.apps-info-class')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/billing/usage-info/index.spec.tsx b/web/app/components/billing/usage-info/index.spec.tsx new file mode 100644 index 0000000000..3137c4865f --- /dev/null +++ b/web/app/components/billing/usage-info/index.spec.tsx @@ -0,0 +1,114 @@ +import { render, screen } from '@testing-library/react' +import { NUM_INFINITE } from '../config' +import UsageInfo from './index' + +const TestIcon = () => + +describe('UsageInfo', () => { + it('renders the metric with a suffix unit and tooltip text', () => { + render( + , + ) + + expect(screen.getByTestId('usage-icon')).toBeInTheDocument() + expect(screen.getByText('Apps')).toBeInTheDocument() + expect(screen.getByText('30')).toBeInTheDocument() + expect(screen.getByText('100')).toBeInTheDocument() + expect(screen.getByText('GB')).toBeInTheDocument() + }) + + it('renders inline unit when unitPosition is inline', () => { + render( + , + ) + + expect(screen.getByText('100GB')).toBeInTheDocument() + }) + + it('shows reset hint text instead of the unit when resetHint is provided', () => { + const resetHint = 'Resets in 3 days' + render( + , + ) + + expect(screen.getByText(resetHint)).toBeInTheDocument() + expect(screen.queryByText('GB')).not.toBeInTheDocument() + }) + + it('displays unlimited text when total is infinite', () => { + render( + , + ) + + expect(screen.getByText('billing.plansCommon.unlimited')).toBeInTheDocument() + }) + + it('applies warning color when usage is close to the limit', () => { + render( + , + ) + + const progressBar = screen.getByTestId('billing-progress-bar') + expect(progressBar).toHaveClass('bg-components-progress-warning-progress') + }) + + it('applies error color when usage exceeds the limit', () => { + render( + , + ) + + const progressBar = screen.getByTestId('billing-progress-bar') + expect(progressBar).toHaveClass('bg-components-progress-error-progress') + }) + + it('does not render the icon when hideIcon is true', () => { + render( + , + ) + + expect(screen.queryByTestId('usage-icon')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/billing/vector-space-full/index.spec.tsx b/web/app/components/billing/vector-space-full/index.spec.tsx new file mode 100644 index 0000000000..de5607df41 --- /dev/null +++ b/web/app/components/billing/vector-space-full/index.spec.tsx @@ -0,0 +1,58 @@ +import { render, screen } from '@testing-library/react' +import VectorSpaceFull from './index' + +type VectorProviderGlobal = typeof globalThis & { + __vectorProviderContext?: ReturnType +} + +function getVectorGlobal(): VectorProviderGlobal { + return globalThis as VectorProviderGlobal +} + +vi.mock('@/context/provider-context', () => { + const mock = vi.fn() + getVectorGlobal().__vectorProviderContext = mock + return { + useProviderContext: () => mock(), + } +}) + +vi.mock('../upgrade-btn', () => ({ + __esModule: true, + default: () => , +})) + +describe('VectorSpaceFull', () => { + const planMock = { + type: 'team', + usage: { + vectorSpace: 8, + }, + total: { + vectorSpace: 10, + }, + } + + beforeEach(() => { + vi.clearAllMocks() + const globals = getVectorGlobal() + globals.__vectorProviderContext?.mockReturnValue({ + plan: planMock, + }) + }) + + it('renders tip text and upgrade button', () => { + render() + + expect(screen.getByText('billing.vectorSpace.fullTip')).toBeInTheDocument() + expect(screen.getByText('billing.vectorSpace.fullSolution')).toBeInTheDocument() + expect(screen.getByTestId('vector-upgrade-btn')).toBeInTheDocument() + }) + + it('shows vector usage and total', () => { + render() + + expect(screen.getByText('8')).toBeInTheDocument() + expect(screen.getByText('10MB')).toBeInTheDocument() + }) +}) From d1f9911848dbda4ef6bad45ea52dcff7f47c9573 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Thu, 25 Dec 2025 15:53:38 +0800 Subject: [PATCH 02/22] feat: make the SegmentService.get_segments sort stable (#30152) --- api/services/dataset_service.py | 2 +- .../test_dataset_service_get_segments.py | 472 ++++++++++++++++++ 2 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 api/tests/unit_tests/services/test_dataset_service_get_segments.py diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py index 970192fde5..ac4b25c5dc 100644 --- a/api/services/dataset_service.py +++ b/api/services/dataset_service.py @@ -3458,7 +3458,7 @@ class SegmentService: if keyword: query = query.where(DocumentSegment.content.ilike(f"%{keyword}%")) - query = query.order_by(DocumentSegment.position.asc()) + query = query.order_by(DocumentSegment.position.asc(), DocumentSegment.id.asc()) paginated_segments = db.paginate(select=query, page=page, per_page=limit, max_per_page=100, error_out=False) return paginated_segments.items, paginated_segments.total diff --git a/api/tests/unit_tests/services/test_dataset_service_get_segments.py b/api/tests/unit_tests/services/test_dataset_service_get_segments.py new file mode 100644 index 0000000000..360c8a3c7d --- /dev/null +++ b/api/tests/unit_tests/services/test_dataset_service_get_segments.py @@ -0,0 +1,472 @@ +""" +Unit tests for SegmentService.get_segments method. + +Tests the retrieval of document segments with pagination and filtering: +- Basic pagination (page, limit) +- Status filtering +- Keyword search +- Ordering by position and id (to avoid duplicate data) +""" + +from unittest.mock import Mock, create_autospec, patch + +import pytest + +from models.dataset import DocumentSegment + + +class SegmentServiceTestDataFactory: + """ + Factory class for creating test data and mock objects for segment tests. + """ + + @staticmethod + def create_segment_mock( + segment_id: str = "segment-123", + document_id: str = "doc-123", + tenant_id: str = "tenant-123", + dataset_id: str = "dataset-123", + position: int = 1, + content: str = "Test content", + status: str = "completed", + **kwargs, + ) -> Mock: + """ + Create a mock document segment. + + Args: + segment_id: Unique identifier for the segment + document_id: Parent document ID + tenant_id: Tenant ID the segment belongs to + dataset_id: Parent dataset ID + position: Position within the document + content: Segment text content + status: Indexing status + **kwargs: Additional attributes + + Returns: + Mock: DocumentSegment mock object + """ + segment = create_autospec(DocumentSegment, instance=True) + segment.id = segment_id + segment.document_id = document_id + segment.tenant_id = tenant_id + segment.dataset_id = dataset_id + segment.position = position + segment.content = content + segment.status = status + for key, value in kwargs.items(): + setattr(segment, key, value) + return segment + + +class TestSegmentServiceGetSegments: + """ + Comprehensive unit tests for SegmentService.get_segments method. + + Tests cover: + - Basic pagination functionality + - Status list filtering + - Keyword search filtering + - Ordering (position + id for uniqueness) + - Empty results + - Combined filters + """ + + @pytest.fixture + def mock_segment_service_dependencies(self): + """ + Common mock setup for segment service dependencies. + + Patches: + - db: Database operations and pagination + - select: SQLAlchemy query builder + """ + with ( + patch("services.dataset_service.db") as mock_db, + patch("services.dataset_service.select") as mock_select, + ): + yield { + "db": mock_db, + "select": mock_select, + } + + def test_get_segments_basic_pagination(self, mock_segment_service_dependencies): + """ + Test basic pagination functionality. + + Verifies: + - Query is built with document_id and tenant_id filters + - Pagination uses correct page and limit parameters + - Returns segments and total count + """ + # Arrange + document_id = "doc-123" + tenant_id = "tenant-123" + page = 1 + limit = 20 + + # Create mock segments + segment1 = SegmentServiceTestDataFactory.create_segment_mock( + segment_id="seg-1", position=1, content="First segment" + ) + segment2 = SegmentServiceTestDataFactory.create_segment_mock( + segment_id="seg-2", position=2, content="Second segment" + ) + + # Mock pagination result + mock_paginated = Mock() + mock_paginated.items = [segment1, segment2] + mock_paginated.total = 2 + + mock_segment_service_dependencies["db"].paginate.return_value = mock_paginated + + # Mock select builder + mock_query = Mock() + mock_segment_service_dependencies["select"].return_value = mock_query + mock_query.where.return_value = mock_query + mock_query.order_by.return_value = mock_query + + # Act + from services.dataset_service import SegmentService + + items, total = SegmentService.get_segments(document_id=document_id, tenant_id=tenant_id, page=page, limit=limit) + + # Assert + assert len(items) == 2 + assert total == 2 + assert items[0].id == "seg-1" + assert items[1].id == "seg-2" + mock_segment_service_dependencies["db"].paginate.assert_called_once() + call_kwargs = mock_segment_service_dependencies["db"].paginate.call_args[1] + assert call_kwargs["page"] == page + assert call_kwargs["per_page"] == limit + assert call_kwargs["max_per_page"] == 100 + assert call_kwargs["error_out"] is False + + def test_get_segments_with_status_filter(self, mock_segment_service_dependencies): + """ + Test filtering by status list. + + Verifies: + - Status list filter is applied to query + - Only segments with matching status are returned + """ + # Arrange + document_id = "doc-123" + tenant_id = "tenant-123" + status_list = ["completed", "indexing"] + + segment1 = SegmentServiceTestDataFactory.create_segment_mock(segment_id="seg-1", status="completed") + segment2 = SegmentServiceTestDataFactory.create_segment_mock(segment_id="seg-2", status="indexing") + + mock_paginated = Mock() + mock_paginated.items = [segment1, segment2] + mock_paginated.total = 2 + + mock_segment_service_dependencies["db"].paginate.return_value = mock_paginated + + mock_query = Mock() + mock_segment_service_dependencies["select"].return_value = mock_query + mock_query.where.return_value = mock_query + mock_query.order_by.return_value = mock_query + + # Act + from services.dataset_service import SegmentService + + items, total = SegmentService.get_segments( + document_id=document_id, tenant_id=tenant_id, status_list=status_list + ) + + # Assert + assert len(items) == 2 + assert total == 2 + # Verify where was called multiple times (base filters + status filter) + assert mock_query.where.call_count >= 2 + + def test_get_segments_with_empty_status_list(self, mock_segment_service_dependencies): + """ + Test with empty status list. + + Verifies: + - Empty status list is handled correctly + - No status filter is applied to avoid WHERE false condition + """ + # Arrange + document_id = "doc-123" + tenant_id = "tenant-123" + status_list = [] + + segment = SegmentServiceTestDataFactory.create_segment_mock(segment_id="seg-1") + + mock_paginated = Mock() + mock_paginated.items = [segment] + mock_paginated.total = 1 + + mock_segment_service_dependencies["db"].paginate.return_value = mock_paginated + + mock_query = Mock() + mock_segment_service_dependencies["select"].return_value = mock_query + mock_query.where.return_value = mock_query + mock_query.order_by.return_value = mock_query + + # Act + from services.dataset_service import SegmentService + + items, total = SegmentService.get_segments( + document_id=document_id, tenant_id=tenant_id, status_list=status_list + ) + + # Assert + assert len(items) == 1 + assert total == 1 + # Should only be called once (base filters, no status filter) + assert mock_query.where.call_count == 1 + + def test_get_segments_with_keyword_search(self, mock_segment_service_dependencies): + """ + Test keyword search functionality. + + Verifies: + - Keyword filter uses ilike for case-insensitive search + - Search pattern includes wildcards (%keyword%) + """ + # Arrange + document_id = "doc-123" + tenant_id = "tenant-123" + keyword = "search term" + + segment = SegmentServiceTestDataFactory.create_segment_mock( + segment_id="seg-1", content="This contains search term" + ) + + mock_paginated = Mock() + mock_paginated.items = [segment] + mock_paginated.total = 1 + + mock_segment_service_dependencies["db"].paginate.return_value = mock_paginated + + mock_query = Mock() + mock_segment_service_dependencies["select"].return_value = mock_query + mock_query.where.return_value = mock_query + mock_query.order_by.return_value = mock_query + + # Act + from services.dataset_service import SegmentService + + items, total = SegmentService.get_segments(document_id=document_id, tenant_id=tenant_id, keyword=keyword) + + # Assert + assert len(items) == 1 + assert total == 1 + # Verify where was called for base filters + keyword filter + assert mock_query.where.call_count == 2 + + def test_get_segments_ordering_by_position_and_id(self, mock_segment_service_dependencies): + """ + Test ordering by position and id. + + Verifies: + - Results are ordered by position ASC + - Results are secondarily ordered by id ASC to ensure uniqueness + - This prevents duplicate data across pages when positions are not unique + """ + # Arrange + document_id = "doc-123" + tenant_id = "tenant-123" + + # Create segments with same position but different ids + segment1 = SegmentServiceTestDataFactory.create_segment_mock( + segment_id="seg-1", position=1, content="Content 1" + ) + segment2 = SegmentServiceTestDataFactory.create_segment_mock( + segment_id="seg-2", position=1, content="Content 2" + ) + segment3 = SegmentServiceTestDataFactory.create_segment_mock( + segment_id="seg-3", position=2, content="Content 3" + ) + + mock_paginated = Mock() + mock_paginated.items = [segment1, segment2, segment3] + mock_paginated.total = 3 + + mock_segment_service_dependencies["db"].paginate.return_value = mock_paginated + + mock_query = Mock() + mock_segment_service_dependencies["select"].return_value = mock_query + mock_query.where.return_value = mock_query + mock_query.order_by.return_value = mock_query + + # Act + from services.dataset_service import SegmentService + + items, total = SegmentService.get_segments(document_id=document_id, tenant_id=tenant_id) + + # Assert + assert len(items) == 3 + assert total == 3 + mock_query.order_by.assert_called_once() + + def test_get_segments_empty_results(self, mock_segment_service_dependencies): + """ + Test when no segments match the criteria. + + Verifies: + - Empty list is returned for items + - Total count is 0 + """ + # Arrange + document_id = "non-existent-doc" + tenant_id = "tenant-123" + + mock_paginated = Mock() + mock_paginated.items = [] + mock_paginated.total = 0 + + mock_segment_service_dependencies["db"].paginate.return_value = mock_paginated + + mock_query = Mock() + mock_segment_service_dependencies["select"].return_value = mock_query + mock_query.where.return_value = mock_query + mock_query.order_by.return_value = mock_query + + # Act + from services.dataset_service import SegmentService + + items, total = SegmentService.get_segments(document_id=document_id, tenant_id=tenant_id) + + # Assert + assert items == [] + assert total == 0 + + def test_get_segments_combined_filters(self, mock_segment_service_dependencies): + """ + Test with multiple filters combined. + + Verifies: + - All filters work together correctly + - Status list and keyword search both applied + """ + # Arrange + document_id = "doc-123" + tenant_id = "tenant-123" + status_list = ["completed"] + keyword = "important" + page = 2 + limit = 10 + + segment = SegmentServiceTestDataFactory.create_segment_mock( + segment_id="seg-1", + status="completed", + content="This is important information", + ) + + mock_paginated = Mock() + mock_paginated.items = [segment] + mock_paginated.total = 1 + + mock_segment_service_dependencies["db"].paginate.return_value = mock_paginated + + mock_query = Mock() + mock_segment_service_dependencies["select"].return_value = mock_query + mock_query.where.return_value = mock_query + mock_query.order_by.return_value = mock_query + + # Act + from services.dataset_service import SegmentService + + items, total = SegmentService.get_segments( + document_id=document_id, + tenant_id=tenant_id, + status_list=status_list, + keyword=keyword, + page=page, + limit=limit, + ) + + # Assert + assert len(items) == 1 + assert total == 1 + # Verify filters: base + status + keyword + assert mock_query.where.call_count == 3 + # Verify pagination parameters + call_kwargs = mock_segment_service_dependencies["db"].paginate.call_args[1] + assert call_kwargs["page"] == page + assert call_kwargs["per_page"] == limit + + def test_get_segments_with_none_status_list(self, mock_segment_service_dependencies): + """ + Test with None status list. + + Verifies: + - None status list is handled correctly + - No status filter is applied + """ + # Arrange + document_id = "doc-123" + tenant_id = "tenant-123" + + segment = SegmentServiceTestDataFactory.create_segment_mock(segment_id="seg-1") + + mock_paginated = Mock() + mock_paginated.items = [segment] + mock_paginated.total = 1 + + mock_segment_service_dependencies["db"].paginate.return_value = mock_paginated + + mock_query = Mock() + mock_segment_service_dependencies["select"].return_value = mock_query + mock_query.where.return_value = mock_query + mock_query.order_by.return_value = mock_query + + # Act + from services.dataset_service import SegmentService + + items, total = SegmentService.get_segments( + document_id=document_id, + tenant_id=tenant_id, + status_list=None, + ) + + # Assert + assert len(items) == 1 + assert total == 1 + # Should only be called once (base filters only, no status filter) + assert mock_query.where.call_count == 1 + + def test_get_segments_pagination_max_per_page_limit(self, mock_segment_service_dependencies): + """ + Test that max_per_page is correctly set to 100. + + Verifies: + - max_per_page parameter is set to 100 + - This prevents excessive page sizes + """ + # Arrange + document_id = "doc-123" + tenant_id = "tenant-123" + limit = 200 # Request more than max_per_page + + mock_paginated = Mock() + mock_paginated.items = [] + mock_paginated.total = 0 + + mock_segment_service_dependencies["db"].paginate.return_value = mock_paginated + + mock_query = Mock() + mock_segment_service_dependencies["select"].return_value = mock_query + mock_query.where.return_value = mock_query + mock_query.order_by.return_value = mock_query + + # Act + from services.dataset_service import SegmentService + + SegmentService.get_segments( + document_id=document_id, + tenant_id=tenant_id, + limit=limit, + ) + + # Assert + call_kwargs = mock_segment_service_dependencies["db"].paginate.call_args[1] + assert call_kwargs["max_per_page"] == 100 From f5fdd02022c425e88d584dae9b1f407b5d933eff Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 25 Dec 2025 16:16:24 +0800 Subject: [PATCH 03/22] chore: bump version to 1.11.2 (#30088) --- api/pyproject.toml | 2 +- api/uv.lock | 2 +- docker/docker-compose-template.yaml | 8 ++++---- docker/docker-compose.yaml | 8 ++++---- web/package.json | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 6716603dd4..dbc6a2eb83 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dify-api" -version = "1.11.1" +version = "1.11.2" requires-python = ">=3.11,<3.13" dependencies = [ diff --git a/api/uv.lock b/api/uv.lock index 4c2cb3c3f1..c31b7fe445 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1368,7 +1368,7 @@ wheels = [ [[package]] name = "dify-api" -version = "1.11.1" +version = "1.11.2" source = { virtual = "." } dependencies = [ { name = "aliyun-log-python-sdk" }, diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 0de9d3e939..3c88cddf8c 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -21,7 +21,7 @@ services: # API service api: - image: langgenius/dify-api:1.11.1 + image: langgenius/dify-api:1.11.2 restart: always environment: # Use the shared environment variables. @@ -63,7 +63,7 @@ services: # worker service # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: - image: langgenius/dify-api:1.11.1 + image: langgenius/dify-api:1.11.2 restart: always environment: # Use the shared environment variables. @@ -102,7 +102,7 @@ services: # worker_beat service # Celery beat for scheduling periodic tasks. worker_beat: - image: langgenius/dify-api:1.11.1 + image: langgenius/dify-api:1.11.2 restart: always environment: # Use the shared environment variables. @@ -132,7 +132,7 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.11.1 + image: langgenius/dify-web:1.11.2 restart: always environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 964b9fe724..3f2031dbd9 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -692,7 +692,7 @@ services: # API service api: - image: langgenius/dify-api:1.11.1 + image: langgenius/dify-api:1.11.2 restart: always environment: # Use the shared environment variables. @@ -734,7 +734,7 @@ services: # worker service # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: - image: langgenius/dify-api:1.11.1 + image: langgenius/dify-api:1.11.2 restart: always environment: # Use the shared environment variables. @@ -773,7 +773,7 @@ services: # worker_beat service # Celery beat for scheduling periodic tasks. worker_beat: - image: langgenius/dify-api:1.11.1 + image: langgenius/dify-api:1.11.2 restart: always environment: # Use the shared environment variables. @@ -803,7 +803,7 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.11.1 + image: langgenius/dify-web:1.11.2 restart: always environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} diff --git a/web/package.json b/web/package.json index a27ac234ad..0d52fc63dd 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "dify-web", "type": "module", - "version": "1.11.1", + "version": "1.11.2", "private": true, "packageManager": "pnpm@10.26.1+sha512.664074abc367d2c9324fdc18037097ce0a8f126034160f709928e9e9f95d98714347044e5c3164d65bd5da6c59c6be362b107546292a8eecb7999196e5ce58fa", "engines": { From b90e6aa14cb13e7019e85275c432ced5e5ba84c5 Mon Sep 17 00:00:00 2001 From: Maries Date: Thu, 25 Dec 2025 16:21:25 +0800 Subject: [PATCH 04/22] fix(api): move cache invalidation outside redis lock to prevent timeout (#30150) Co-authored-by: Claude Opus 4.5 --- api/services/tools/builtin_tools_manage_service.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/services/tools/builtin_tools_manage_service.py b/api/services/tools/builtin_tools_manage_service.py index cf1d39fa25..87951d53e6 100644 --- a/api/services/tools/builtin_tools_manage_service.py +++ b/api/services/tools/builtin_tools_manage_service.py @@ -286,12 +286,12 @@ class BuiltinToolManageService: session.add(db_provider) session.commit() - - # Invalidate tool providers cache - ToolProviderListCache.invalidate_cache(tenant_id) except Exception as e: session.rollback() raise ValueError(str(e)) + + # Invalidate tool providers cache + ToolProviderListCache.invalidate_cache(tenant_id, "builtin") return {"result": "success"} @staticmethod From 0f85ce3d0ec36a11e8e1240bf13215e3e8d33fd5 Mon Sep 17 00:00:00 2001 From: JeeekXY <18005585108@163.com> Date: Thu, 25 Dec 2025 16:22:42 +0800 Subject: [PATCH 05/22] fix: prioritize copying selected text (#30141) --- web/app/components/workflow/hooks/use-shortcuts.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/app/components/workflow/hooks/use-shortcuts.ts b/web/app/components/workflow/hooks/use-shortcuts.ts index 00ed25bad0..1b3d141d8d 100644 --- a/web/app/components/workflow/hooks/use-shortcuts.ts +++ b/web/app/components/workflow/hooks/use-shortcuts.ts @@ -63,6 +63,11 @@ export const useShortcuts = (): void => { return !isEventTargetInputArea(e.target as HTMLElement) }, []) + const shouldHandleCopy = useCallback(() => { + const selection = document.getSelection() + return !selection || selection.isCollapsed + }, []) + useKeyPress(['delete', 'backspace'], (e) => { if (shouldHandleShortcut(e)) { e.preventDefault() @@ -73,7 +78,7 @@ export const useShortcuts = (): void => { useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.c`, (e) => { const { showDebugAndPreviewPanel } = workflowStore.getState() - if (shouldHandleShortcut(e) && !showDebugAndPreviewPanel) { + if (shouldHandleShortcut(e) && shouldHandleCopy() && !showDebugAndPreviewPanel) { e.preventDefault() handleNodesCopy() } From 0c4233e7df51f146b9d007132f3aca71d36919cb Mon Sep 17 00:00:00 2001 From: Maries Date: Thu, 25 Dec 2025 16:35:26 +0800 Subject: [PATCH 06/22] fix(web): disable cache for trigger dynamic select options (#30161) Co-authored-by: Claude Opus 4.5 --- web/service/use-triggers.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/service/use-triggers.ts b/web/service/use-triggers.ts index 6036f5ab34..24717cc50b 100644 --- a/web/service/use-triggers.ts +++ b/web/service/use-triggers.ts @@ -371,6 +371,8 @@ export const useTriggerPluginDynamicOptions = (payload: { }, enabled: enabled && !!payload.plugin_id && !!payload.provider && !!payload.action && !!payload.parameter && !!payload.credential_id, retry: 0, + staleTime: 0, + gcTime: 0, }) } From 996c7d9e163526474c685b380a7a3273ceb61afb Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Thu, 25 Dec 2025 17:04:37 +0800 Subject: [PATCH 07/22] perf: using pipeline to delete redis cache (#30159) --- api/core/helper/tool_provider_cache.py | 12 +++++++----- .../core/helper/test_tool_provider_cache.py | 3 --- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/api/core/helper/tool_provider_cache.py b/api/core/helper/tool_provider_cache.py index eef5937407..c5447c2b3f 100644 --- a/api/core/helper/tool_provider_cache.py +++ b/api/core/helper/tool_provider_cache.py @@ -1,6 +1,6 @@ import json import logging -from typing import Any +from typing import Any, cast from core.tools.entities.api_entities import ToolProviderTypeApiLiteral from extensions.ext_redis import redis_client, redis_fallback @@ -50,7 +50,9 @@ class ToolProviderListCache: redis_client.delete(cache_key) else: # Invalidate all caches for this tenant - pattern = f"tool_providers:tenant_id:{tenant_id}:*" - keys = list(redis_client.scan_iter(pattern)) - if keys: - redis_client.delete(*keys) + keys = ["builtin", "model", "api", "workflow", "mcp"] + pipeline = redis_client.pipeline() + for key in keys: + cache_key = ToolProviderListCache._generate_cache_key(tenant_id, cast(ToolProviderTypeApiLiteral, key)) + pipeline.delete(cache_key) + pipeline.execute() diff --git a/api/tests/unit_tests/core/helper/test_tool_provider_cache.py b/api/tests/unit_tests/core/helper/test_tool_provider_cache.py index 00f7c9d7e9..d237c68f35 100644 --- a/api/tests/unit_tests/core/helper/test_tool_provider_cache.py +++ b/api/tests/unit_tests/core/helper/test_tool_provider_cache.py @@ -96,9 +96,6 @@ class TestToolProviderListCache: ToolProviderListCache.invalidate_cache(tenant_id) - mock_redis_client.scan_iter.assert_called_once_with(f"tool_providers:tenant_id:{tenant_id}:*") - mock_redis_client.delete.assert_called_once_with(*mock_keys) - def test_invalidate_cache_no_keys(self, mock_redis_client): """Test invalidate cache - no cache keys for tenant""" tenant_id = "tenant_123" From c3bb95d71d7e31540d28ad6df8d0af2260606e94 Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 25 Dec 2025 17:26:21 +0800 Subject: [PATCH 08/22] fix: update permission in member list caused page crash (#30164) --- .../members-page/operation/index.spec.tsx | 91 +++++++++++++++ .../members-page/operation/index.tsx | 106 +++++++++--------- 2 files changed, 141 insertions(+), 56 deletions(-) create mode 100644 web/app/components/header/account-setting/members-page/operation/index.spec.tsx diff --git a/web/app/components/header/account-setting/members-page/operation/index.spec.tsx b/web/app/components/header/account-setting/members-page/operation/index.spec.tsx new file mode 100644 index 0000000000..fbe3959a0f --- /dev/null +++ b/web/app/components/header/account-setting/members-page/operation/index.spec.tsx @@ -0,0 +1,91 @@ +import type { Member } from '@/models/common' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { vi } from 'vitest' +import { ToastContext } from '@/app/components/base/toast' +import Operation from './index' + +const mockUpdateMemberRole = vi.fn() +const mockDeleteMemberOrCancelInvitation = vi.fn() + +vi.mock('@/service/common', () => ({ + deleteMemberOrCancelInvitation: () => mockDeleteMemberOrCancelInvitation(), + updateMemberRole: () => mockUpdateMemberRole(), +})) + +const mockUseProviderContext = vi.fn(() => ({ + datasetOperatorEnabled: false, +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => mockUseProviderContext(), +})) + +const defaultMember: Member = { + id: 'member-id', + name: 'Test Member', + email: 'test@example.com', + avatar: '', + avatar_url: null, + status: 'active', + role: 'editor', + last_login_at: '', + last_active_at: '', + created_at: '', +} + +const renderOperation = (propsOverride: Partial = {}, operatorRole = 'owner', onOperate?: () => void) => { + const mergedMember = { ...defaultMember, ...propsOverride } + return render( + + + , + ) +} + +describe('Operation', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: false }) + }) + + it('renders the current role label', () => { + renderOperation() + + expect(screen.getByText('common.members.editor')).toBeInTheDocument() + }) + + it('shows dataset operator option when the feature flag is enabled', async () => { + mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true }) + renderOperation() + + fireEvent.click(screen.getByText('common.members.editor')) + + expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument() + }) + + it('calls updateMemberRole and onOperate when selecting another role', async () => { + const onOperate = vi.fn() + renderOperation({}, 'owner', onOperate) + + fireEvent.click(screen.getByText('common.members.editor')) + fireEvent.click(await screen.findByText('common.members.normal')) + + await waitFor(() => { + expect(mockUpdateMemberRole).toHaveBeenCalled() + expect(onOperate).toHaveBeenCalled() + }) + }) + + it('calls deleteMemberOrCancelInvitation when removing the member', async () => { + const onOperate = vi.fn() + renderOperation({}, 'owner', onOperate) + + fireEvent.click(screen.getByText('common.members.editor')) + fireEvent.click(await screen.findByText('common.members.removeFromTeam')) + + await waitFor(() => { + expect(mockDeleteMemberOrCancelInvitation).toHaveBeenCalled() + expect(onOperate).toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/header/account-setting/members-page/operation/index.tsx b/web/app/components/header/account-setting/members-page/operation/index.tsx index da61746685..6effe8b058 100644 --- a/web/app/components/header/account-setting/members-page/operation/index.tsx +++ b/web/app/components/header/account-setting/members-page/operation/index.tsx @@ -1,10 +1,14 @@ 'use client' import type { Member } from '@/models/common' -import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react' import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/outline' -import { Fragment, useMemo } from 'react' +import { memo, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' import { ToastContext } from '@/app/components/base/toast' import { useProviderContext } from '@/context/provider-context' import { deleteMemberOrCancelInvitation, updateMemberRole } from '@/service/common' @@ -21,6 +25,7 @@ const Operation = ({ operatorRole, onOperate, }: IOperationProps) => { + const [open, setOpen] = useState(false) const { t } = useTranslation() const { datasetOperatorEnabled } = useProviderContext() const RoleMap = { @@ -51,6 +56,7 @@ const Operation = ({ const { notify } = useContext(ToastContext) const toHump = (name: string) => name.replace(/_(\w)/g, (all, letter) => letter.toUpperCase()) const handleDeleteMemberOrCancelInvitation = async () => { + setOpen(false) try { await deleteMemberOrCancelInvitation({ url: `/workspaces/current/members/${member.id}` }) onOperate() @@ -61,6 +67,7 @@ const Operation = ({ } } const handleUpdateMemberRole = async (role: string) => { + setOpen(false) try { await updateMemberRole({ url: `/workspaces/current/members/${member.id}/update-role`, @@ -75,63 +82,50 @@ const Operation = ({ } return ( - - { - ({ open }) => ( - <> - - {RoleMap[member.role] || RoleMap.normal} - - - - -
+ + setOpen(prev => !prev)}> +
+ {RoleMap[member.role] || RoleMap.normal} + +
+
+ +
+
+ { + roleList.map(role => ( +
handleUpdateMemberRole(role)}> { - roleList.map(role => ( - -
handleUpdateMemberRole(role)}> - { - role === member.role - ? - :
- } -
-
{t(`common.members.${toHump(role)}` as any)}
-
{t(`common.members.${toHump(role)}Tip` as any)}
-
-
- - )) + role === member.role + ? + :
} -
- -
-
-
-
-
{t('common.members.removeFromTeam')}
-
{t('common.members.removeFromTeamTip')}
-
-
+
+
{t(`common.members.${toHump(role)}` as any)}
+
{t(`common.members.${toHump(role)}Tip` as any)}
- - - - - ) - } -
+
+ )) + } + +
+
+
+
+
{t('common.members.removeFromTeam')}
+
{t('common.members.removeFromTeamTip')}
+
+
+
+
+ + ) } -export default Operation +export default memo(Operation) From f2555b0bb1588ae0cbc2a2736bfec6ec56192c4e Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Thu, 25 Dec 2025 18:19:28 +0800 Subject: [PATCH 09/22] feat(refactoring): introduce comprehensive guidelines and tools for component refactoring in Dify (#30162) Co-authored-by: CodingOnStar Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: yyh --- .claude/skills/component-refactoring/SKILL.md | 483 +++++++++++++++++ .../references/complexity-patterns.md | 493 ++++++++++++++++++ .../references/component-splitting.md | 477 +++++++++++++++++ .../references/hook-extraction.md | 317 +++++++++++ .claude/skills/frontend-testing/SKILL.md | 2 +- web/package.json | 3 +- web/{testing => scripts}/analyze-component.js | 476 +---------------- web/scripts/component-analyzer.js | 484 +++++++++++++++++ web/scripts/refactor-component.js | 420 +++++++++++++++ web/testing/testing.md | 2 +- 10 files changed, 2687 insertions(+), 470 deletions(-) create mode 100644 .claude/skills/component-refactoring/SKILL.md create mode 100644 .claude/skills/component-refactoring/references/complexity-patterns.md create mode 100644 .claude/skills/component-refactoring/references/component-splitting.md create mode 100644 .claude/skills/component-refactoring/references/hook-extraction.md rename web/{testing => scripts}/analyze-component.js (64%) create mode 100644 web/scripts/component-analyzer.js create mode 100644 web/scripts/refactor-component.js diff --git a/.claude/skills/component-refactoring/SKILL.md b/.claude/skills/component-refactoring/SKILL.md new file mode 100644 index 0000000000..ea695ea442 --- /dev/null +++ b/.claude/skills/component-refactoring/SKILL.md @@ -0,0 +1,483 @@ +--- +name: component-refactoring +description: Refactor high-complexity React components in Dify frontend. Use when `pnpm analyze-component --json` shows complexity > 50 or lineCount > 300, when the user asks for code splitting, hook extraction, or complexity reduction, or when `pnpm analyze-component` warns to refactor before testing; avoid for simple/well-structured components, third-party wrappers, or when the user explicitly wants testing without refactoring. +--- + +# Dify Component Refactoring Skill + +Refactor high-complexity React components in the Dify frontend codebase with the patterns and workflow below. + +> **Complexity Threshold**: Components with complexity > 50 (measured by `pnpm analyze-component`) should be refactored before testing. + +## Quick Reference + +### Commands (run from `web/`) + +Use paths relative to `web/` (e.g., `app/components/...`). +Use `refactor-component` for refactoring prompts and `analyze-component` for testing prompts and metrics. + +```bash +cd web + +# Generate refactoring prompt +pnpm refactor-component + +# Output refactoring analysis as JSON +pnpm refactor-component --json + +# Generate testing prompt (after refactoring) +pnpm analyze-component + +# Output testing analysis as JSON +pnpm analyze-component --json +``` + +### Complexity Analysis + +```bash +# Analyze component complexity +pnpm analyze-component --json + +# Key metrics to check: +# - complexity: normalized score 0-100 (target < 50) +# - maxComplexity: highest single function complexity +# - lineCount: total lines (target < 300) +``` + +### Complexity Score Interpretation + +| Score | Level | Action | +|-------|-------|--------| +| 0-25 | 🟢 Simple | Ready for testing | +| 26-50 | 🟡 Medium | Consider minor refactoring | +| 51-75 | 🟠 Complex | **Refactor before testing** | +| 76-100 | 🔴 Very Complex | **Must refactor** | + +## Core Refactoring Patterns + +### Pattern 1: Extract Custom Hooks + +**When**: Component has complex state management, multiple `useState`/`useEffect`, or business logic mixed with UI. + +**Dify Convention**: Place hooks in a `hooks/` subdirectory or alongside the component as `use-.ts`. + +```typescript +// ❌ Before: Complex state logic in component +const Configuration: FC = () => { + const [modelConfig, setModelConfig] = useState(...) + const [datasetConfigs, setDatasetConfigs] = useState(...) + const [completionParams, setCompletionParams] = useState({}) + + // 50+ lines of state management logic... + + return
...
+} + +// ✅ After: Extract to custom hook +// hooks/use-model-config.ts +export const useModelConfig = (appId: string) => { + const [modelConfig, setModelConfig] = useState(...) + const [completionParams, setCompletionParams] = useState({}) + + // Related state management logic here + + return { modelConfig, setModelConfig, completionParams, setCompletionParams } +} + +// Component becomes cleaner +const Configuration: FC = () => { + const { modelConfig, setModelConfig } = useModelConfig(appId) + return
...
+} +``` + +**Dify Examples**: +- `web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts` +- `web/app/components/app/configuration/debug/hooks.tsx` +- `web/app/components/workflow/hooks/use-workflow.ts` + +### Pattern 2: Extract Sub-Components + +**When**: Single component has multiple UI sections, conditional rendering blocks, or repeated patterns. + +**Dify Convention**: Place sub-components in subdirectories or as separate files in the same directory. + +```typescript +// ❌ Before: Monolithic JSX with multiple sections +const AppInfo = () => { + return ( +
+ {/* 100 lines of header UI */} + {/* 100 lines of operations UI */} + {/* 100 lines of modals */} +
+ ) +} + +// ✅ After: Split into focused components +// app-info/ +// ├── index.tsx (orchestration only) +// ├── app-header.tsx (header UI) +// ├── app-operations.tsx (operations UI) +// └── app-modals.tsx (modal management) + +const AppInfo = () => { + const { showModal, setShowModal } = useAppInfoModals() + + return ( +
+ + + setShowModal(null)} /> +
+ ) +} +``` + +**Dify Examples**: +- `web/app/components/app/configuration/` directory structure +- `web/app/components/workflow/nodes/` per-node organization + +### Pattern 3: Simplify Conditional Logic + +**When**: Deep nesting (> 3 levels), complex ternaries, or multiple `if/else` chains. + +```typescript +// ❌ Before: Deeply nested conditionals +const Template = useMemo(() => { + if (appDetail?.mode === AppModeEnum.CHAT) { + switch (locale) { + case LanguagesSupported[1]: + return + case LanguagesSupported[7]: + return + default: + return + } + } + if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) { + // Another 15 lines... + } + // More conditions... +}, [appDetail, locale]) + +// ✅ After: Use lookup tables + early returns +const TEMPLATE_MAP = { + [AppModeEnum.CHAT]: { + [LanguagesSupported[1]]: TemplateChatZh, + [LanguagesSupported[7]]: TemplateChatJa, + default: TemplateChatEn, + }, + [AppModeEnum.ADVANCED_CHAT]: { + [LanguagesSupported[1]]: TemplateAdvancedChatZh, + // ... + }, +} + +const Template = useMemo(() => { + const modeTemplates = TEMPLATE_MAP[appDetail?.mode] + if (!modeTemplates) return null + + const TemplateComponent = modeTemplates[locale] || modeTemplates.default + return +}, [appDetail, locale]) +``` + +### Pattern 4: Extract API/Data Logic + +**When**: Component directly handles API calls, data transformation, or complex async operations. + +**Dify Convention**: Use `@tanstack/react-query` hooks from `web/service/use-*.ts` or create custom data hooks. Project is migrating from SWR to React Query. + +```typescript +// ❌ Before: API logic in component +const MCPServiceCard = () => { + const [basicAppConfig, setBasicAppConfig] = useState({}) + + useEffect(() => { + if (isBasicApp && appId) { + (async () => { + const res = await fetchAppDetail({ url: '/apps', id: appId }) + setBasicAppConfig(res?.model_config || {}) + })() + } + }, [appId, isBasicApp]) + + // More API-related logic... +} + +// ✅ After: Extract to data hook using React Query +// use-app-config.ts +import { useQuery } from '@tanstack/react-query' +import { get } from '@/service/base' + +const NAME_SPACE = 'appConfig' + +export const useAppConfig = (appId: string, isBasicApp: boolean) => { + return useQuery({ + enabled: isBasicApp && !!appId, + queryKey: [NAME_SPACE, 'detail', appId], + queryFn: () => get(`/apps/${appId}`), + select: data => data?.model_config || {}, + }) +} + +// Component becomes cleaner +const MCPServiceCard = () => { + const { data: config, isLoading } = useAppConfig(appId, isBasicApp) + // UI only +} +``` + +**React Query Best Practices in Dify**: +- Define `NAME_SPACE` for query key organization +- Use `enabled` option for conditional fetching +- Use `select` for data transformation +- Export invalidation hooks: `useInvalidXxx` + +**Dify Examples**: +- `web/service/use-workflow.ts` +- `web/service/use-common.ts` +- `web/service/knowledge/use-dataset.ts` +- `web/service/knowledge/use-document.ts` + +### Pattern 5: Extract Modal/Dialog Management + +**When**: Component manages multiple modals with complex open/close states. + +**Dify Convention**: Modals should be extracted with their state management. + +```typescript +// ❌ Before: Multiple modal states in component +const AppInfo = () => { + const [showEditModal, setShowEditModal] = useState(false) + const [showDuplicateModal, setShowDuplicateModal] = useState(false) + const [showConfirmDelete, setShowConfirmDelete] = useState(false) + const [showSwitchModal, setShowSwitchModal] = useState(false) + const [showImportDSLModal, setShowImportDSLModal] = useState(false) + // 5+ more modal states... +} + +// ✅ After: Extract to modal management hook +type ModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | 'import' | null + +const useAppInfoModals = () => { + const [activeModal, setActiveModal] = useState(null) + + const openModal = useCallback((type: ModalType) => setActiveModal(type), []) + const closeModal = useCallback(() => setActiveModal(null), []) + + return { + activeModal, + openModal, + closeModal, + isOpen: (type: ModalType) => activeModal === type, + } +} +``` + +### Pattern 6: Extract Form Logic + +**When**: Complex form validation, submission handling, or field transformation. + +**Dify Convention**: Use `@tanstack/react-form` patterns from `web/app/components/base/form/`. + +```typescript +// ✅ Use existing form infrastructure +import { useAppForm } from '@/app/components/base/form' + +const ConfigForm = () => { + const form = useAppForm({ + defaultValues: { name: '', description: '' }, + onSubmit: handleSubmit, + }) + + return ... +} +``` + +## Dify-Specific Refactoring Guidelines + +### 1. Context Provider Extraction + +**When**: Component provides complex context values with multiple states. + +```typescript +// ❌ Before: Large context value object +const value = { + appId, isAPIKeySet, isTrailFinished, mode, modelModeType, + promptMode, isAdvancedMode, isAgent, isOpenAI, isFunctionCall, + // 50+ more properties... +} +return ... + +// ✅ After: Split into domain-specific contexts + + + + {children} + + + +``` + +**Dify Reference**: `web/context/` directory structure + +### 2. Workflow Node Components + +**When**: Refactoring workflow node components (`web/app/components/workflow/nodes/`). + +**Conventions**: +- Keep node logic in `use-interactions.ts` +- Extract panel UI to separate files +- Use `_base` components for common patterns + +``` +nodes// + ├── index.tsx # Node registration + ├── node.tsx # Node visual component + ├── panel.tsx # Configuration panel + ├── use-interactions.ts # Node-specific hooks + └── types.ts # Type definitions +``` + +### 3. Configuration Components + +**When**: Refactoring app configuration components. + +**Conventions**: +- Separate config sections into subdirectories +- Use existing patterns from `web/app/components/app/configuration/` +- Keep feature toggles in dedicated components + +### 4. Tool/Plugin Components + +**When**: Refactoring tool-related components (`web/app/components/tools/`). + +**Conventions**: +- Follow existing modal patterns +- Use service hooks from `web/service/use-tools.ts` +- Keep provider-specific logic isolated + +## Refactoring Workflow + +### Step 1: Generate Refactoring Prompt + +```bash +pnpm refactor-component +``` + +This command will: +- Analyze component complexity and features +- Identify specific refactoring actions needed +- Generate a prompt for AI assistant (auto-copied to clipboard on macOS) +- Provide detailed requirements based on detected patterns + +### Step 2: Analyze Details + +```bash +pnpm analyze-component --json +``` + +Identify: +- Total complexity score +- Max function complexity +- Line count +- Features detected (state, effects, API, etc.) + +### Step 3: Plan + +Create a refactoring plan based on detected features: + +| Detected Feature | Refactoring Action | +|------------------|-------------------| +| `hasState: true` + `hasEffects: true` | Extract custom hook | +| `hasAPI: true` | Extract data/service hook | +| `hasEvents: true` (many) | Extract event handlers | +| `lineCount > 300` | Split into sub-components | +| `maxComplexity > 50` | Simplify conditional logic | + +### Step 4: Execute Incrementally + +1. **Extract one piece at a time** +2. **Run lint, type-check, and tests after each extraction** +3. **Verify functionality before next step** + +``` +For each extraction: + ┌────────────────────────────────────────┐ + │ 1. Extract code │ + │ 2. Run: pnpm lint:fix │ + │ 3. Run: pnpm type-check:tsgo │ + │ 4. Run: pnpm test │ + │ 5. Test functionality manually │ + │ 6. PASS? → Next extraction │ + │ FAIL? → Fix before continuing │ + └────────────────────────────────────────┘ +``` + +### Step 5: Verify + +After refactoring: + +```bash +# Re-run refactor command to verify improvements +pnpm refactor-component + +# If complexity < 25 and lines < 200, you'll see: +# ✅ COMPONENT IS WELL-STRUCTURED + +# For detailed metrics: +pnpm analyze-component --json + +# Target metrics: +# - complexity < 50 +# - lineCount < 300 +# - maxComplexity < 30 +``` + +## Common Mistakes to Avoid + +### ❌ Over-Engineering + +```typescript +// ❌ Too many tiny hooks +const useButtonText = () => useState('Click') +const useButtonDisabled = () => useState(false) +const useButtonLoading = () => useState(false) + +// ✅ Cohesive hook with related state +const useButtonState = () => { + const [text, setText] = useState('Click') + const [disabled, setDisabled] = useState(false) + const [loading, setLoading] = useState(false) + return { text, setText, disabled, setDisabled, loading, setLoading } +} +``` + +### ❌ Breaking Existing Patterns + +- Follow existing directory structures +- Maintain naming conventions +- Preserve export patterns for compatibility + +### ❌ Premature Abstraction + +- Only extract when there's clear complexity benefit +- Don't create abstractions for single-use code +- Keep refactored code in the same domain area + +## References + +### Dify Codebase Examples + +- **Hook extraction**: `web/app/components/app/configuration/hooks/` +- **Component splitting**: `web/app/components/app/configuration/` +- **Service hooks**: `web/service/use-*.ts` +- **Workflow patterns**: `web/app/components/workflow/hooks/` +- **Form patterns**: `web/app/components/base/form/` + +### Related Skills + +- `frontend-testing` - For testing refactored components +- `web/testing/testing.md` - Testing specification diff --git a/.claude/skills/component-refactoring/references/complexity-patterns.md b/.claude/skills/component-refactoring/references/complexity-patterns.md new file mode 100644 index 0000000000..5a0a268f38 --- /dev/null +++ b/.claude/skills/component-refactoring/references/complexity-patterns.md @@ -0,0 +1,493 @@ +# Complexity Reduction Patterns + +This document provides patterns for reducing cognitive complexity in Dify React components. + +## Understanding Complexity + +### SonarJS Cognitive Complexity + +The `pnpm analyze-component` tool uses SonarJS cognitive complexity metrics: + +- **Total Complexity**: Sum of all functions' complexity in the file +- **Max Complexity**: Highest single function complexity + +### What Increases Complexity + +| Pattern | Complexity Impact | +|---------|-------------------| +| `if/else` | +1 per branch | +| Nested conditions | +1 per nesting level | +| `switch/case` | +1 per case | +| `for/while/do` | +1 per loop | +| `&&`/`||` chains | +1 per operator | +| Nested callbacks | +1 per nesting level | +| `try/catch` | +1 per catch | +| Ternary expressions | +1 per nesting | + +## Pattern 1: Replace Conditionals with Lookup Tables + +**Before** (complexity: ~15): + +```typescript +const Template = useMemo(() => { + if (appDetail?.mode === AppModeEnum.CHAT) { + switch (locale) { + case LanguagesSupported[1]: + return + case LanguagesSupported[7]: + return + default: + return + } + } + if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) { + switch (locale) { + case LanguagesSupported[1]: + return + case LanguagesSupported[7]: + return + default: + return + } + } + if (appDetail?.mode === AppModeEnum.WORKFLOW) { + // Similar pattern... + } + return null +}, [appDetail, locale]) +``` + +**After** (complexity: ~3): + +```typescript +// Define lookup table outside component +const TEMPLATE_MAP: Record>> = { + [AppModeEnum.CHAT]: { + [LanguagesSupported[1]]: TemplateChatZh, + [LanguagesSupported[7]]: TemplateChatJa, + default: TemplateChatEn, + }, + [AppModeEnum.ADVANCED_CHAT]: { + [LanguagesSupported[1]]: TemplateAdvancedChatZh, + [LanguagesSupported[7]]: TemplateAdvancedChatJa, + default: TemplateAdvancedChatEn, + }, + [AppModeEnum.WORKFLOW]: { + [LanguagesSupported[1]]: TemplateWorkflowZh, + [LanguagesSupported[7]]: TemplateWorkflowJa, + default: TemplateWorkflowEn, + }, + // ... +} + +// Clean component logic +const Template = useMemo(() => { + if (!appDetail?.mode) return null + + const templates = TEMPLATE_MAP[appDetail.mode] + if (!templates) return null + + const TemplateComponent = templates[locale] ?? templates.default + return +}, [appDetail, locale]) +``` + +## Pattern 2: Use Early Returns + +**Before** (complexity: ~10): + +```typescript +const handleSubmit = () => { + if (isValid) { + if (hasChanges) { + if (isConnected) { + submitData() + } else { + showConnectionError() + } + } else { + showNoChangesMessage() + } + } else { + showValidationError() + } +} +``` + +**After** (complexity: ~4): + +```typescript +const handleSubmit = () => { + if (!isValid) { + showValidationError() + return + } + + if (!hasChanges) { + showNoChangesMessage() + return + } + + if (!isConnected) { + showConnectionError() + return + } + + submitData() +} +``` + +## Pattern 3: Extract Complex Conditions + +**Before** (complexity: high): + +```typescript +const canPublish = (() => { + if (mode !== AppModeEnum.COMPLETION) { + if (!isAdvancedMode) + return true + + if (modelModeType === ModelModeType.completion) { + if (!hasSetBlockStatus.history || !hasSetBlockStatus.query) + return false + return true + } + return true + } + return !promptEmpty +})() +``` + +**After** (complexity: lower): + +```typescript +// Extract to named functions +const canPublishInCompletionMode = () => !promptEmpty + +const canPublishInChatMode = () => { + if (!isAdvancedMode) return true + if (modelModeType !== ModelModeType.completion) return true + return hasSetBlockStatus.history && hasSetBlockStatus.query +} + +// Clean main logic +const canPublish = mode === AppModeEnum.COMPLETION + ? canPublishInCompletionMode() + : canPublishInChatMode() +``` + +## Pattern 4: Replace Chained Ternaries + +**Before** (complexity: ~5): + +```typescript +const statusText = serverActivated + ? t('status.running') + : serverPublished + ? t('status.inactive') + : appUnpublished + ? t('status.unpublished') + : t('status.notConfigured') +``` + +**After** (complexity: ~2): + +```typescript +const getStatusText = () => { + if (serverActivated) return t('status.running') + if (serverPublished) return t('status.inactive') + if (appUnpublished) return t('status.unpublished') + return t('status.notConfigured') +} + +const statusText = getStatusText() +``` + +Or use lookup: + +```typescript +const STATUS_TEXT_MAP = { + running: 'status.running', + inactive: 'status.inactive', + unpublished: 'status.unpublished', + notConfigured: 'status.notConfigured', +} as const + +const getStatusKey = (): keyof typeof STATUS_TEXT_MAP => { + if (serverActivated) return 'running' + if (serverPublished) return 'inactive' + if (appUnpublished) return 'unpublished' + return 'notConfigured' +} + +const statusText = t(STATUS_TEXT_MAP[getStatusKey()]) +``` + +## Pattern 5: Flatten Nested Loops + +**Before** (complexity: high): + +```typescript +const processData = (items: Item[]) => { + const results: ProcessedItem[] = [] + + for (const item of items) { + if (item.isValid) { + for (const child of item.children) { + if (child.isActive) { + for (const prop of child.properties) { + if (prop.value !== null) { + results.push({ + itemId: item.id, + childId: child.id, + propValue: prop.value, + }) + } + } + } + } + } + } + + return results +} +``` + +**After** (complexity: lower): + +```typescript +// Use functional approach +const processData = (items: Item[]) => { + return items + .filter(item => item.isValid) + .flatMap(item => + item.children + .filter(child => child.isActive) + .flatMap(child => + child.properties + .filter(prop => prop.value !== null) + .map(prop => ({ + itemId: item.id, + childId: child.id, + propValue: prop.value, + })) + ) + ) +} +``` + +## Pattern 6: Extract Event Handler Logic + +**Before** (complexity: high in component): + +```typescript +const Component = () => { + const handleSelect = (data: DataSet[]) => { + if (isEqual(data.map(item => item.id), dataSets.map(item => item.id))) { + hideSelectDataSet() + return + } + + formattingChangedDispatcher() + let newDatasets = data + if (data.find(item => !item.name)) { + const newSelected = produce(data, (draft) => { + data.forEach((item, index) => { + if (!item.name) { + const newItem = dataSets.find(i => i.id === item.id) + if (newItem) + draft[index] = newItem + } + }) + }) + setDataSets(newSelected) + newDatasets = newSelected + } + else { + setDataSets(data) + } + hideSelectDataSet() + + // 40 more lines of logic... + } + + return
...
+} +``` + +**After** (complexity: lower): + +```typescript +// Extract to hook or utility +const useDatasetSelection = (dataSets: DataSet[], setDataSets: SetState) => { + const normalizeSelection = (data: DataSet[]) => { + const hasUnloadedItem = data.some(item => !item.name) + if (!hasUnloadedItem) return data + + return produce(data, (draft) => { + data.forEach((item, index) => { + if (!item.name) { + const existing = dataSets.find(i => i.id === item.id) + if (existing) draft[index] = existing + } + }) + }) + } + + const hasSelectionChanged = (newData: DataSet[]) => { + return !isEqual( + newData.map(item => item.id), + dataSets.map(item => item.id) + ) + } + + return { normalizeSelection, hasSelectionChanged } +} + +// Component becomes cleaner +const Component = () => { + const { normalizeSelection, hasSelectionChanged } = useDatasetSelection(dataSets, setDataSets) + + const handleSelect = (data: DataSet[]) => { + if (!hasSelectionChanged(data)) { + hideSelectDataSet() + return + } + + formattingChangedDispatcher() + const normalized = normalizeSelection(data) + setDataSets(normalized) + hideSelectDataSet() + } + + return
...
+} +``` + +## Pattern 7: Reduce Boolean Logic Complexity + +**Before** (complexity: ~8): + +```typescript +const toggleDisabled = hasInsufficientPermissions + || appUnpublished + || missingStartNode + || triggerModeDisabled + || (isAdvancedApp && !currentWorkflow?.graph) + || (isBasicApp && !basicAppConfig.updated_at) +``` + +**After** (complexity: ~3): + +```typescript +// Extract meaningful boolean functions +const isAppReady = () => { + if (isAdvancedApp) return !!currentWorkflow?.graph + return !!basicAppConfig.updated_at +} + +const hasRequiredPermissions = () => { + return isCurrentWorkspaceEditor && !hasInsufficientPermissions +} + +const canToggle = () => { + if (!hasRequiredPermissions()) return false + if (!isAppReady()) return false + if (missingStartNode) return false + if (triggerModeDisabled) return false + return true +} + +const toggleDisabled = !canToggle() +``` + +## Pattern 8: Simplify useMemo/useCallback Dependencies + +**Before** (complexity: multiple recalculations): + +```typescript +const payload = useMemo(() => { + let parameters: Parameter[] = [] + let outputParameters: OutputParameter[] = [] + + if (!published) { + parameters = (inputs || []).map((item) => ({ + name: item.variable, + description: '', + form: 'llm', + required: item.required, + type: item.type, + })) + outputParameters = (outputs || []).map((item) => ({ + name: item.variable, + description: '', + type: item.value_type, + })) + } + else if (detail && detail.tool) { + parameters = (inputs || []).map((item) => ({ + // Complex transformation... + })) + outputParameters = (outputs || []).map((item) => ({ + // Complex transformation... + })) + } + + return { + icon: detail?.icon || icon, + label: detail?.label || name, + // ...more fields + } +}, [detail, published, workflowAppId, icon, name, description, inputs, outputs]) +``` + +**After** (complexity: separated concerns): + +```typescript +// Separate transformations +const useParameterTransform = (inputs: InputVar[], detail?: ToolDetail, published?: boolean) => { + return useMemo(() => { + if (!published) { + return inputs.map(item => ({ + name: item.variable, + description: '', + form: 'llm', + required: item.required, + type: item.type, + })) + } + + if (!detail?.tool) return [] + + return inputs.map(item => ({ + name: item.variable, + required: item.required, + type: item.type === 'paragraph' ? 'string' : item.type, + description: detail.tool.parameters.find(p => p.name === item.variable)?.llm_description || '', + form: detail.tool.parameters.find(p => p.name === item.variable)?.form || 'llm', + })) + }, [inputs, detail, published]) +} + +// Component uses hook +const parameters = useParameterTransform(inputs, detail, published) +const outputParameters = useOutputTransform(outputs, detail, published) + +const payload = useMemo(() => ({ + icon: detail?.icon || icon, + label: detail?.label || name, + parameters, + outputParameters, + // ... +}), [detail, icon, name, parameters, outputParameters]) +``` + +## Target Metrics After Refactoring + +| Metric | Target | +|--------|--------| +| Total Complexity | < 50 | +| Max Function Complexity | < 30 | +| Function Length | < 30 lines | +| Nesting Depth | ≤ 3 levels | +| Conditional Chains | ≤ 3 conditions | diff --git a/.claude/skills/component-refactoring/references/component-splitting.md b/.claude/skills/component-refactoring/references/component-splitting.md new file mode 100644 index 0000000000..78a3389100 --- /dev/null +++ b/.claude/skills/component-refactoring/references/component-splitting.md @@ -0,0 +1,477 @@ +# Component Splitting Patterns + +This document provides detailed guidance on splitting large components into smaller, focused components in Dify. + +## When to Split Components + +Split a component when you identify: + +1. **Multiple UI sections** - Distinct visual areas with minimal coupling that can be composed independently +1. **Conditional rendering blocks** - Large `{condition && }` blocks +1. **Repeated patterns** - Similar UI structures used multiple times +1. **300+ lines** - Component exceeds manageable size +1. **Modal clusters** - Multiple modals rendered in one component + +## Splitting Strategies + +### Strategy 1: Section-Based Splitting + +Identify visual sections and extract each as a component. + +```typescript +// ❌ Before: Monolithic component (500+ lines) +const ConfigurationPage = () => { + return ( +
+ {/* Header Section - 50 lines */} +
+

{t('configuration.title')}

+
+ {isAdvancedMode && Advanced} + + +
+
+ + {/* Config Section - 200 lines */} +
+ +
+ + {/* Debug Section - 150 lines */} +
+ +
+ + {/* Modals Section - 100 lines */} + {showSelectDataSet && } + {showHistoryModal && } + {showUseGPT4Confirm && } +
+ ) +} + +// ✅ After: Split into focused components +// configuration/ +// ├── index.tsx (orchestration) +// ├── configuration-header.tsx +// ├── configuration-content.tsx +// ├── configuration-debug.tsx +// └── configuration-modals.tsx + +// configuration-header.tsx +interface ConfigurationHeaderProps { + isAdvancedMode: boolean + onPublish: () => void +} + +const ConfigurationHeader: FC = ({ + isAdvancedMode, + onPublish, +}) => { + const { t } = useTranslation() + + return ( +
+

{t('configuration.title')}

+
+ {isAdvancedMode && Advanced} + + +
+
+ ) +} + +// index.tsx (orchestration only) +const ConfigurationPage = () => { + const { modelConfig, setModelConfig } = useModelConfig() + const { activeModal, openModal, closeModal } = useModalState() + + return ( +
+ + + {!isMobile && ( + + )} + +
+ ) +} +``` + +### Strategy 2: Conditional Block Extraction + +Extract large conditional rendering blocks. + +```typescript +// ❌ Before: Large conditional blocks +const AppInfo = () => { + return ( +
+ {expand ? ( +
+ {/* 100 lines of expanded view */} +
+ ) : ( +
+ {/* 50 lines of collapsed view */} +
+ )} +
+ ) +} + +// ✅ After: Separate view components +const AppInfoExpanded: FC = ({ appDetail, onAction }) => { + return ( +
+ {/* Clean, focused expanded view */} +
+ ) +} + +const AppInfoCollapsed: FC = ({ appDetail, onAction }) => { + return ( +
+ {/* Clean, focused collapsed view */} +
+ ) +} + +const AppInfo = () => { + return ( +
+ {expand + ? + : + } +
+ ) +} +``` + +### Strategy 3: Modal Extraction + +Extract modals with their trigger logic. + +```typescript +// ❌ Before: Multiple modals in one component +const AppInfo = () => { + const [showEdit, setShowEdit] = useState(false) + const [showDuplicate, setShowDuplicate] = useState(false) + const [showDelete, setShowDelete] = useState(false) + const [showSwitch, setShowSwitch] = useState(false) + + const onEdit = async (data) => { /* 20 lines */ } + const onDuplicate = async (data) => { /* 20 lines */ } + const onDelete = async () => { /* 15 lines */ } + + return ( +
+ {/* Main content */} + + {showEdit && setShowEdit(false)} />} + {showDuplicate && setShowDuplicate(false)} />} + {showDelete && setShowDelete(false)} />} + {showSwitch && } +
+ ) +} + +// ✅ After: Modal manager component +// app-info-modals.tsx +type ModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | null + +interface AppInfoModalsProps { + appDetail: AppDetail + activeModal: ModalType + onClose: () => void + onSuccess: () => void +} + +const AppInfoModals: FC = ({ + appDetail, + activeModal, + onClose, + onSuccess, +}) => { + const handleEdit = async (data) => { /* logic */ } + const handleDuplicate = async (data) => { /* logic */ } + const handleDelete = async () => { /* logic */ } + + return ( + <> + {activeModal === 'edit' && ( + + )} + {activeModal === 'duplicate' && ( + + )} + {activeModal === 'delete' && ( + + )} + {activeModal === 'switch' && ( + + )} + + ) +} + +// Parent component +const AppInfo = () => { + const { activeModal, openModal, closeModal } = useModalState() + + return ( +
+ {/* Main content with openModal triggers */} + + + +
+ ) +} +``` + +### Strategy 4: List Item Extraction + +Extract repeated item rendering. + +```typescript +// ❌ Before: Inline item rendering +const OperationsList = () => { + return ( +
+ {operations.map(op => ( +
+ {op.icon} + {op.title} + {op.description} + + {op.badge && {op.badge}} + {/* More complex rendering... */} +
+ ))} +
+ ) +} + +// ✅ After: Extracted item component +interface OperationItemProps { + operation: Operation + onAction: (id: string) => void +} + +const OperationItem: FC = ({ operation, onAction }) => { + return ( +
+ {operation.icon} + {operation.title} + {operation.description} + + {operation.badge && {operation.badge}} +
+ ) +} + +const OperationsList = () => { + const handleAction = useCallback((id: string) => { + const op = operations.find(o => o.id === id) + op?.onClick() + }, [operations]) + + return ( +
+ {operations.map(op => ( + + ))} +
+ ) +} +``` + +## Directory Structure Patterns + +### Pattern A: Flat Structure (Simple Components) + +For components with 2-3 sub-components: + +``` +component-name/ + ├── index.tsx # Main component + ├── sub-component-a.tsx + ├── sub-component-b.tsx + └── types.ts # Shared types +``` + +### Pattern B: Nested Structure (Complex Components) + +For components with many sub-components: + +``` +component-name/ + ├── index.tsx # Main orchestration + ├── types.ts # Shared types + ├── hooks/ + │ ├── use-feature-a.ts + │ └── use-feature-b.ts + ├── components/ + │ ├── header/ + │ │ └── index.tsx + │ ├── content/ + │ │ └── index.tsx + │ └── modals/ + │ └── index.tsx + └── utils/ + └── helpers.ts +``` + +### Pattern C: Feature-Based Structure (Dify Standard) + +Following Dify's existing patterns: + +``` +configuration/ + ├── index.tsx # Main page component + ├── base/ # Base/shared components + │ ├── feature-panel/ + │ ├── group-name/ + │ └── operation-btn/ + ├── config/ # Config section + │ ├── index.tsx + │ ├── agent/ + │ └── automatic/ + ├── dataset-config/ # Dataset section + │ ├── index.tsx + │ ├── card-item/ + │ └── params-config/ + ├── debug/ # Debug section + │ ├── index.tsx + │ └── hooks.tsx + └── hooks/ # Shared hooks + └── use-advanced-prompt-config.ts +``` + +## Props Design + +### Minimal Props Principle + +Pass only what's needed: + +```typescript +// ❌ Bad: Passing entire objects when only some fields needed + + +// ✅ Good: Destructure to minimum required + +``` + +### Callback Props Pattern + +Use callbacks for child-to-parent communication: + +```typescript +// Parent +const Parent = () => { + const [value, setValue] = useState('') + + return ( + + ) +} + +// Child +interface ChildProps { + value: string + onChange: (value: string) => void + onSubmit: () => void +} + +const Child: FC = ({ value, onChange, onSubmit }) => { + return ( +
+ onChange(e.target.value)} /> + +
+ ) +} +``` + +### Render Props for Flexibility + +When sub-components need parent context: + +```typescript +interface ListProps { + items: T[] + renderItem: (item: T, index: number) => React.ReactNode + renderEmpty?: () => React.ReactNode +} + +function List({ items, renderItem, renderEmpty }: ListProps) { + if (items.length === 0 && renderEmpty) { + return <>{renderEmpty()} + } + + return ( +
+ {items.map((item, index) => renderItem(item, index))} +
+ ) +} + +// Usage + } + renderEmpty={() => } +/> +``` diff --git a/.claude/skills/component-refactoring/references/hook-extraction.md b/.claude/skills/component-refactoring/references/hook-extraction.md new file mode 100644 index 0000000000..a8d75deffd --- /dev/null +++ b/.claude/skills/component-refactoring/references/hook-extraction.md @@ -0,0 +1,317 @@ +# Hook Extraction Patterns + +This document provides detailed guidance on extracting custom hooks from complex components in Dify. + +## When to Extract Hooks + +Extract a custom hook when you identify: + +1. **Coupled state groups** - Multiple `useState` hooks that are always used together +1. **Complex effects** - `useEffect` with multiple dependencies or cleanup logic +1. **Business logic** - Data transformations, validations, or calculations +1. **Reusable patterns** - Logic that appears in multiple components + +## Extraction Process + +### Step 1: Identify State Groups + +Look for state variables that are logically related: + +```typescript +// ❌ These belong together - extract to hook +const [modelConfig, setModelConfig] = useState(...) +const [completionParams, setCompletionParams] = useState({}) +const [modelModeType, setModelModeType] = useState(...) + +// These are model-related state that should be in useModelConfig() +``` + +### Step 2: Identify Related Effects + +Find effects that modify the grouped state: + +```typescript +// ❌ These effects belong with the state above +useEffect(() => { + if (hasFetchedDetail && !modelModeType) { + const mode = currModel?.model_properties.mode + if (mode) { + const newModelConfig = produce(modelConfig, (draft) => { + draft.mode = mode + }) + setModelConfig(newModelConfig) + } + } +}, [textGenerationModelList, hasFetchedDetail, modelModeType, currModel]) +``` + +### Step 3: Create the Hook + +```typescript +// hooks/use-model-config.ts +import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { ModelConfig } from '@/models/debug' +import { produce } from 'immer' +import { useEffect, useState } from 'react' +import { ModelModeType } from '@/types/app' + +interface UseModelConfigParams { + initialConfig?: Partial + currModel?: { model_properties?: { mode?: ModelModeType } } + hasFetchedDetail: boolean +} + +interface UseModelConfigReturn { + modelConfig: ModelConfig + setModelConfig: (config: ModelConfig) => void + completionParams: FormValue + setCompletionParams: (params: FormValue) => void + modelModeType: ModelModeType +} + +export const useModelConfig = ({ + initialConfig, + currModel, + hasFetchedDetail, +}: UseModelConfigParams): UseModelConfigReturn => { + const [modelConfig, setModelConfig] = useState({ + provider: 'langgenius/openai/openai', + model_id: 'gpt-3.5-turbo', + mode: ModelModeType.unset, + // ... default values + ...initialConfig, + }) + + const [completionParams, setCompletionParams] = useState({}) + + const modelModeType = modelConfig.mode + + // Fill old app data missing model mode + useEffect(() => { + if (hasFetchedDetail && !modelModeType) { + const mode = currModel?.model_properties?.mode + if (mode) { + setModelConfig(produce(modelConfig, (draft) => { + draft.mode = mode + })) + } + } + }, [hasFetchedDetail, modelModeType, currModel]) + + return { + modelConfig, + setModelConfig, + completionParams, + setCompletionParams, + modelModeType, + } +} +``` + +### Step 4: Update Component + +```typescript +// Before: 50+ lines of state management +const Configuration: FC = () => { + const [modelConfig, setModelConfig] = useState(...) + // ... lots of related state and effects +} + +// After: Clean component +const Configuration: FC = () => { + const { + modelConfig, + setModelConfig, + completionParams, + setCompletionParams, + modelModeType, + } = useModelConfig({ + currModel, + hasFetchedDetail, + }) + + // Component now focuses on UI +} +``` + +## Naming Conventions + +### Hook Names + +- Use `use` prefix: `useModelConfig`, `useDatasetConfig` +- Be specific: `useAdvancedPromptConfig` not `usePrompt` +- Include domain: `useWorkflowVariables`, `useMCPServer` + +### File Names + +- Kebab-case: `use-model-config.ts` +- Place in `hooks/` subdirectory when multiple hooks exist +- Place alongside component for single-use hooks + +### Return Type Names + +- Suffix with `Return`: `UseModelConfigReturn` +- Suffix params with `Params`: `UseModelConfigParams` + +## Common Hook Patterns in Dify + +### 1. Data Fetching Hook (React Query) + +```typescript +// Pattern: Use @tanstack/react-query for data fetching +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { get } from '@/service/base' +import { useInvalid } from '@/service/use-base' + +const NAME_SPACE = 'appConfig' + +// Query keys for cache management +export const appConfigQueryKeys = { + detail: (appId: string) => [NAME_SPACE, 'detail', appId] as const, +} + +// Main data hook +export const useAppConfig = (appId: string) => { + return useQuery({ + enabled: !!appId, + queryKey: appConfigQueryKeys.detail(appId), + queryFn: () => get(`/apps/${appId}`), + select: data => data?.model_config || null, + }) +} + +// Invalidation hook for refreshing data +export const useInvalidAppConfig = () => { + return useInvalid([NAME_SPACE]) +} + +// Usage in component +const Component = () => { + const { data: config, isLoading, error, refetch } = useAppConfig(appId) + const invalidAppConfig = useInvalidAppConfig() + + const handleRefresh = () => { + invalidAppConfig() // Invalidates cache and triggers refetch + } + + return
...
+} +``` + +### 2. Form State Hook + +```typescript +// Pattern: Form state + validation + submission +export const useConfigForm = (initialValues: ConfigFormValues) => { + const [values, setValues] = useState(initialValues) + const [errors, setErrors] = useState>({}) + const [isSubmitting, setIsSubmitting] = useState(false) + + const validate = useCallback(() => { + const newErrors: Record = {} + if (!values.name) newErrors.name = 'Name is required' + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + }, [values]) + + const handleChange = useCallback((field: string, value: any) => { + setValues(prev => ({ ...prev, [field]: value })) + }, []) + + const handleSubmit = useCallback(async (onSubmit: (values: ConfigFormValues) => Promise) => { + if (!validate()) return + setIsSubmitting(true) + try { + await onSubmit(values) + } finally { + setIsSubmitting(false) + } + }, [values, validate]) + + return { values, errors, isSubmitting, handleChange, handleSubmit } +} +``` + +### 3. Modal State Hook + +```typescript +// Pattern: Multiple modal management +type ModalType = 'edit' | 'delete' | 'duplicate' | null + +export const useModalState = () => { + const [activeModal, setActiveModal] = useState(null) + const [modalData, setModalData] = useState(null) + + const openModal = useCallback((type: ModalType, data?: any) => { + setActiveModal(type) + setModalData(data) + }, []) + + const closeModal = useCallback(() => { + setActiveModal(null) + setModalData(null) + }, []) + + return { + activeModal, + modalData, + openModal, + closeModal, + isOpen: useCallback((type: ModalType) => activeModal === type, [activeModal]), + } +} +``` + +### 4. Toggle/Boolean Hook + +```typescript +// Pattern: Boolean state with convenience methods +export const useToggle = (initialValue = false) => { + const [value, setValue] = useState(initialValue) + + const toggle = useCallback(() => setValue(v => !v), []) + const setTrue = useCallback(() => setValue(true), []) + const setFalse = useCallback(() => setValue(false), []) + + return [value, { toggle, setTrue, setFalse, set: setValue }] as const +} + +// Usage +const [isExpanded, { toggle, setTrue: expand, setFalse: collapse }] = useToggle() +``` + +## Testing Extracted Hooks + +After extraction, test hooks in isolation: + +```typescript +// use-model-config.spec.ts +import { renderHook, act } from '@testing-library/react' +import { useModelConfig } from './use-model-config' + +describe('useModelConfig', () => { + it('should initialize with default values', () => { + const { result } = renderHook(() => useModelConfig({ + hasFetchedDetail: false, + })) + + expect(result.current.modelConfig.provider).toBe('langgenius/openai/openai') + expect(result.current.modelModeType).toBe(ModelModeType.unset) + }) + + it('should update model config', () => { + const { result } = renderHook(() => useModelConfig({ + hasFetchedDetail: true, + })) + + act(() => { + result.current.setModelConfig({ + ...result.current.modelConfig, + model_id: 'gpt-4', + }) + }) + + expect(result.current.modelConfig.model_id).toBe('gpt-4') + }) +}) +``` diff --git a/.claude/skills/frontend-testing/SKILL.md b/.claude/skills/frontend-testing/SKILL.md index 65602c92eb..dd9677a78e 100644 --- a/.claude/skills/frontend-testing/SKILL.md +++ b/.claude/skills/frontend-testing/SKILL.md @@ -318,5 +318,5 @@ For more detailed information, refer to: - `web/vitest.config.ts` - Vitest configuration - `web/vitest.setup.ts` - Test environment setup -- `web/testing/analyze-component.js` - Component analysis tool +- `web/scripts/analyze-component.js` - Component analysis tool - Modules are not mocked automatically. Global mocks live in `web/vitest.setup.ts` (for example `react-i18next`, `next/image`); mock other modules like `ky` or `mime` locally in test files. diff --git a/web/package.json b/web/package.json index 0d52fc63dd..fe6b8ec9f7 100644 --- a/web/package.json +++ b/web/package.json @@ -38,7 +38,8 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest --watch", - "analyze-component": "node testing/analyze-component.js", + "analyze-component": "node ./scripts/analyze-component.js", + "refactor-component": "node ./scripts/refactor-component.js", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "preinstall": "npx only-allow pnpm", diff --git a/web/testing/analyze-component.js b/web/scripts/analyze-component.js similarity index 64% rename from web/testing/analyze-component.js rename to web/scripts/analyze-component.js index 3f70f3d1ec..8b01744b3d 100755 --- a/web/testing/analyze-component.js +++ b/web/scripts/analyze-component.js @@ -3,376 +3,13 @@ import { spawnSync } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' -import tsParser from '@typescript-eslint/parser' -import { Linter } from 'eslint' -import sonarPlugin from 'eslint-plugin-sonarjs' - -// ============================================================================ -// Simple Analyzer -// ============================================================================ - -class ComponentAnalyzer { - analyze(code, filePath, absolutePath) { - const resolvedPath = absolutePath ?? path.resolve(process.cwd(), filePath) - const fileName = path.basename(filePath, path.extname(filePath)) - const lineCount = code.split('\n').length - - // Calculate complexity metrics - const { total: rawComplexity, max: rawMaxComplexity } = this.calculateCognitiveComplexity(code) - const complexity = this.normalizeComplexity(rawComplexity) - const maxComplexity = this.normalizeComplexity(rawMaxComplexity) - - // Count usage references (may take a few seconds) - const usageCount = this.countUsageReferences(filePath, resolvedPath) - - // Calculate test priority - const priority = this.calculateTestPriority(complexity, usageCount) - - return { - name: fileName.charAt(0).toUpperCase() + fileName.slice(1), - path: filePath, - type: this.detectType(filePath, code), - hasProps: code.includes('Props') || code.includes('interface'), - hasState: code.includes('useState') || code.includes('useReducer'), - hasEffects: code.includes('useEffect'), - hasCallbacks: code.includes('useCallback'), - hasMemo: code.includes('useMemo'), - hasEvents: /on[A-Z]\w+/.test(code), - hasRouter: code.includes('useRouter') || code.includes('usePathname'), - hasAPI: code.includes('service/') || code.includes('fetch(') || code.includes('useSWR'), - hasForwardRef: code.includes('forwardRef'), - hasComponentMemo: /React\.memo|memo\(/.test(code), - hasSuspense: code.includes('Suspense') || /\blazy\(/.test(code), - hasPortal: code.includes('createPortal'), - hasImperativeHandle: code.includes('useImperativeHandle'), - hasSWR: code.includes('useSWR'), - hasReactQuery: code.includes('useQuery') || code.includes('useMutation'), - hasAhooks: code.includes('from \'ahooks\''), - complexity, - maxComplexity, - rawComplexity, - rawMaxComplexity, - lineCount, - usageCount, - priority, - } - } - - detectType(filePath, code) { - const normalizedPath = filePath.replace(/\\/g, '/') - if (normalizedPath.includes('/hooks/')) - return 'hook' - if (normalizedPath.includes('/utils/')) - return 'util' - if (/\/page\.(t|j)sx?$/.test(normalizedPath)) - return 'page' - if (/\/layout\.(t|j)sx?$/.test(normalizedPath)) - return 'layout' - if (/\/providers?\//.test(normalizedPath)) - return 'provider' - // Dify-specific types - if (normalizedPath.includes('/components/base/')) - return 'base-component' - if (normalizedPath.includes('/context/')) - return 'context' - if (normalizedPath.includes('/store/')) - return 'store' - if (normalizedPath.includes('/service/')) - return 'service' - if (/use[A-Z]\w+/.test(code)) - return 'component' - return 'component' - } - - /** - * Calculate Cognitive Complexity using SonarJS ESLint plugin - * Reference: https://www.sonarsource.com/blog/5-clean-code-tips-for-reducing-cognitive-complexity/ - * - * Returns raw (unnormalized) complexity values: - * - total: sum of all functions' complexity in the file - * - max: highest single function complexity in the file - * - * Raw Score Thresholds (per function): - * 0-15: Simple | 16-30: Medium | 31-50: Complex | 51+: Very Complex - * - * @returns {{ total: number, max: number }} raw total and max complexity - */ - calculateCognitiveComplexity(code) { - const linter = new Linter() - const baseConfig = { - languageOptions: { - parser: tsParser, - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - ecmaFeatures: { jsx: true }, - }, - }, - plugins: { sonarjs: sonarPlugin }, - } - - try { - // Get total complexity using 'metric' option (more stable) - const totalConfig = { - ...baseConfig, - rules: { 'sonarjs/cognitive-complexity': ['error', 0, 'metric'] }, - } - const totalMessages = linter.verify(code, totalConfig) - const totalMsg = totalMessages.find( - msg => msg.ruleId === 'sonarjs/cognitive-complexity' - && msg.messageId === 'fileComplexity', - ) - const total = totalMsg ? Number.parseInt(totalMsg.message, 10) : 0 - - // Get max function complexity by analyzing each function - const maxConfig = { - ...baseConfig, - rules: { 'sonarjs/cognitive-complexity': ['error', 0] }, - } - const maxMessages = linter.verify(code, maxConfig) - let max = 0 - const complexityPattern = /reduce its Cognitive Complexity from (\d+)/ - - maxMessages.forEach((msg) => { - if (msg.ruleId === 'sonarjs/cognitive-complexity') { - const match = msg.message.match(complexityPattern) - if (match && match[1]) - max = Math.max(max, Number.parseInt(match[1], 10)) - } - }) - - return { total, max } - } - catch { - return { total: 0, max: 0 } - } - } - - /** - * Normalize cognitive complexity to 0-100 scale - * - * Mapping (aligned with SonarJS thresholds): - * Raw 0-15 (Simple) -> Normalized 0-25 - * Raw 16-30 (Medium) -> Normalized 25-50 - * Raw 31-50 (Complex) -> Normalized 50-75 - * Raw 51+ (Very Complex) -> Normalized 75-100 (asymptotic) - */ - normalizeComplexity(rawComplexity) { - if (rawComplexity <= 15) { - // Linear: 0-15 -> 0-25 - return Math.round((rawComplexity / 15) * 25) - } - else if (rawComplexity <= 30) { - // Linear: 16-30 -> 25-50 - return Math.round(25 + ((rawComplexity - 15) / 15) * 25) - } - else if (rawComplexity <= 50) { - // Linear: 31-50 -> 50-75 - return Math.round(50 + ((rawComplexity - 30) / 20) * 25) - } - else { - // Asymptotic: 51+ -> 75-100 - // Formula ensures score approaches but never exceeds 100 - return Math.round(75 + 25 * (1 - 1 / (1 + (rawComplexity - 50) / 100))) - } - } - - /** - * Count how many times a component is referenced in the codebase - * Scans TypeScript sources for import statements referencing the component - */ - countUsageReferences(filePath, absolutePath) { - try { - const resolvedComponentPath = absolutePath ?? path.resolve(process.cwd(), filePath) - const fileName = path.basename(resolvedComponentPath, path.extname(resolvedComponentPath)) - - let searchName = fileName - if (fileName === 'index') { - const parentDir = path.dirname(resolvedComponentPath) - searchName = path.basename(parentDir) - } - - if (!searchName) - return 0 - - const searchRoots = this.collectSearchRoots(resolvedComponentPath) - if (searchRoots.length === 0) - return 0 - - const escapedName = ComponentAnalyzer.escapeRegExp(searchName) - const patterns = [ - new RegExp(`from\\s+['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`), - new RegExp(`import\\s*\\(\\s*['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`), - new RegExp(`export\\s+(?:\\*|{[^}]*})\\s*from\\s+['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`), - new RegExp(`require\\(\\s*['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`), - ] - - const visited = new Set() - let usageCount = 0 - - const stack = [...searchRoots] - while (stack.length > 0) { - const currentDir = stack.pop() - if (!currentDir || visited.has(currentDir)) - continue - visited.add(currentDir) - - const entries = fs.readdirSync(currentDir, { withFileTypes: true }) - - entries.forEach((entry) => { - const entryPath = path.join(currentDir, entry.name) - - if (entry.isDirectory()) { - if (this.shouldSkipDir(entry.name)) - return - stack.push(entryPath) - return - } - - if (!this.shouldInspectFile(entry.name)) - return - - const normalizedEntryPath = path.resolve(entryPath) - if (normalizedEntryPath === path.resolve(resolvedComponentPath)) - return - - const source = fs.readFileSync(entryPath, 'utf-8') - if (!source.includes(searchName)) - return - - if (patterns.some((pattern) => { - pattern.lastIndex = 0 - return pattern.test(source) - })) { - usageCount += 1 - } - }) - } - - return usageCount - } - catch { - // If command fails, return 0 - return 0 - } - } - - collectSearchRoots(resolvedComponentPath) { - const roots = new Set() - - let currentDir = path.dirname(resolvedComponentPath) - const workspaceRoot = process.cwd() - - while (currentDir && currentDir !== path.dirname(currentDir)) { - if (path.basename(currentDir) === 'app') { - roots.add(currentDir) - break - } - - if (currentDir === workspaceRoot) - break - currentDir = path.dirname(currentDir) - } - - const fallbackRoots = [ - path.join(workspaceRoot, 'app'), - path.join(workspaceRoot, 'web', 'app'), - path.join(workspaceRoot, 'src'), - ] - - fallbackRoots.forEach((root) => { - if (fs.existsSync(root) && fs.statSync(root).isDirectory()) - roots.add(root) - }) - - return Array.from(roots) - } - - shouldSkipDir(dirName) { - const normalized = dirName.toLowerCase() - return [ - 'node_modules', - '.git', - '.next', - 'dist', - 'out', - 'coverage', - 'build', - '__tests__', - '__mocks__', - ].includes(normalized) - } - - shouldInspectFile(fileName) { - const normalized = fileName.toLowerCase() - if (!(/\.(ts|tsx)$/i.test(fileName))) - return false - if (normalized.endsWith('.d.ts')) - return false - if (/\.(spec|test)\.(ts|tsx)$/.test(normalized)) - return false - if (normalized.endsWith('.stories.tsx')) - return false - return true - } - - static escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - } - - /** - * Calculate test priority based on cognitive complexity and usage - * - * Priority Score = 0.7 * Complexity + 0.3 * Usage Score (all normalized to 0-100) - * - Complexity Score: 0-100 (normalized from SonarJS) - * - Usage Score: 0-100 (based on reference count) - * - * Priority Levels (0-100): - * - 0-25: 🟢 LOW - * - 26-50: 🟡 MEDIUM - * - 51-75: 🟠 HIGH - * - 76-100: 🔴 CRITICAL - */ - calculateTestPriority(complexity, usageCount) { - const complexityScore = complexity - - // Normalize usage score to 0-100 - let usageScore - if (usageCount === 0) - usageScore = 0 - else if (usageCount <= 5) - usageScore = 20 - else if (usageCount <= 20) - usageScore = 40 - else if (usageCount <= 50) - usageScore = 70 - else - usageScore = 100 - - // Weighted average: complexity (70%) + usage (30%) - const totalScore = Math.round(0.7 * complexityScore + 0.3 * usageScore) - - return { - score: totalScore, - level: this.getPriorityLevel(totalScore), - usageScore, - complexityScore, - } - } - - /** - * Get priority level based on score (0-100 scale) - */ - getPriorityLevel(score) { - if (score > 75) - return '🔴 CRITICAL' - if (score > 50) - return '🟠 HIGH' - if (score > 25) - return '🟡 MEDIUM' - return '🟢 LOW' - } -} +import { + ComponentAnalyzer, + extractCopyContent, + getComplexityLevel, + listAnalyzableFiles, + resolveDirectoryEntry, +} from './component-analyzer.js' // ============================================================================ // Prompt Builder for AI Assistants @@ -394,8 +31,8 @@ class TestPromptBuilder { 📊 Component Analysis: ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Type: ${analysis.type} -Total Complexity: ${analysis.complexity}/100 ${this.getComplexityLevel(analysis.complexity)} -Max Func Complexity: ${analysis.maxComplexity}/100 ${this.getComplexityLevel(analysis.maxComplexity)} +Total Complexity: ${analysis.complexity}/100 ${getComplexityLevel(analysis.complexity)} +Max Func Complexity: ${analysis.maxComplexity}/100 ${getComplexityLevel(analysis.maxComplexity)} Lines: ${analysis.lineCount} Usage: ${analysis.usageCount} reference${analysis.usageCount !== 1 ? 's' : ''} Test Priority: ${analysis.priority.score} ${analysis.priority.level} @@ -444,17 +81,6 @@ Create the test file at: ${testPath} ` } - getComplexityLevel(score) { - // Normalized complexity thresholds (0-100 scale) - if (score <= 25) - return '🟢 Simple' - if (score <= 50) - return '🟡 Medium' - if (score <= 75) - return '🟠 Complex' - return '🔴 Very Complex' - } - buildFocusPoints(analysis) { const points = [] @@ -730,94 +356,10 @@ Output format: } } -function extractCopyContent(prompt) { - const marker = '📋 PROMPT FOR AI ASSISTANT' - const markerIndex = prompt.indexOf(marker) - if (markerIndex === -1) - return '' - - const section = prompt.slice(markerIndex) - const lines = section.split('\n') - const firstDivider = lines.findIndex(line => line.includes('━━━━━━━━')) - if (firstDivider === -1) - return '' - - const startIdx = firstDivider + 1 - let endIdx = lines.length - - for (let i = startIdx; i < lines.length; i++) { - if (lines[i].includes('━━━━━━━━')) { - endIdx = i - break - } - } - - if (startIdx >= endIdx) - return '' - - return lines.slice(startIdx, endIdx).join('\n').trim() -} - // ============================================================================ // Main Function // ============================================================================ -/** - * Resolve directory to entry file - * Priority: index files > common entry files (node.tsx, panel.tsx, etc.) - */ -function resolveDirectoryEntry(absolutePath, componentPath) { - // Entry files in priority order: index files first, then common entry files - const entryFiles = [ - 'index.tsx', - 'index.ts', // Priority 1: index files - 'node.tsx', - 'panel.tsx', - 'component.tsx', - 'main.tsx', - 'container.tsx', // Priority 2: common entry files - ] - for (const entryFile of entryFiles) { - const entryPath = path.join(absolutePath, entryFile) - if (fs.existsSync(entryPath)) { - return { - absolutePath: entryPath, - componentPath: path.join(componentPath, entryFile), - } - } - } - - return null -} - -/** - * List analyzable files in directory (for user guidance) - */ -function listAnalyzableFiles(dirPath) { - try { - const entries = fs.readdirSync(dirPath, { withFileTypes: true }) - return entries - .filter(entry => !entry.isDirectory() && /\.(tsx?|jsx?)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) - .map(entry => entry.name) - .sort((a, b) => { - // Prioritize common entry files - const priority = ['index.tsx', 'index.ts', 'node.tsx', 'panel.tsx', 'component.tsx', 'main.tsx', 'container.tsx'] - const aIdx = priority.indexOf(a) - const bIdx = priority.indexOf(b) - if (aIdx !== -1 && bIdx !== -1) - return aIdx - bIdx - if (aIdx !== -1) - return -1 - if (bIdx !== -1) - return 1 - return a.localeCompare(b) - }) - } - catch { - return [] - } -} - function showHelp() { console.log(` 📋 Component Analyzer - Generate test prompts for AI assistants diff --git a/web/scripts/component-analyzer.js b/web/scripts/component-analyzer.js new file mode 100644 index 0000000000..c53b652bc2 --- /dev/null +++ b/web/scripts/component-analyzer.js @@ -0,0 +1,484 @@ +/** + * Component Analyzer - Shared module for analyzing React component complexity + * + * This module is used by: + * - analyze-component.js (for test generation) + * - refactor-component.js (for refactoring suggestions) + */ + +import fs from 'node:fs' +import path from 'node:path' +import tsParser from '@typescript-eslint/parser' +import { Linter } from 'eslint' +import sonarPlugin from 'eslint-plugin-sonarjs' + +// ============================================================================ +// Component Analyzer +// ============================================================================ + +export class ComponentAnalyzer { + analyze(code, filePath, absolutePath) { + const resolvedPath = absolutePath ?? path.resolve(process.cwd(), filePath) + const fileName = path.basename(filePath, path.extname(filePath)) + const lineCount = code.split('\n').length + + // Calculate complexity metrics + const { total: rawComplexity, max: rawMaxComplexity } = this.calculateCognitiveComplexity(code) + const complexity = this.normalizeComplexity(rawComplexity) + const maxComplexity = this.normalizeComplexity(rawMaxComplexity) + + // Count usage references (may take a few seconds) + const usageCount = this.countUsageReferences(filePath, resolvedPath) + + // Calculate test priority + const priority = this.calculateTestPriority(complexity, usageCount) + + return { + name: fileName.charAt(0).toUpperCase() + fileName.slice(1), + path: filePath, + type: this.detectType(filePath, code), + hasProps: code.includes('Props') || code.includes('interface'), + hasState: code.includes('useState') || code.includes('useReducer'), + hasEffects: code.includes('useEffect'), + hasCallbacks: code.includes('useCallback'), + hasMemo: code.includes('useMemo'), + hasEvents: /on[A-Z]\w+/.test(code), + hasRouter: code.includes('useRouter') || code.includes('usePathname'), + hasAPI: code.includes('service/') || code.includes('fetch(') || code.includes('useSWR'), + hasForwardRef: code.includes('forwardRef'), + hasComponentMemo: /React\.memo|memo\(/.test(code), + hasSuspense: code.includes('Suspense') || /\blazy\(/.test(code), + hasPortal: code.includes('createPortal'), + hasImperativeHandle: code.includes('useImperativeHandle'), + hasSWR: code.includes('useSWR'), + hasReactQuery: code.includes('useQuery') || code.includes('useMutation'), + hasAhooks: code.includes('from \'ahooks\''), + complexity, + maxComplexity, + rawComplexity, + rawMaxComplexity, + lineCount, + usageCount, + priority, + } + } + + detectType(filePath, code) { + const normalizedPath = filePath.replace(/\\/g, '/') + if (normalizedPath.includes('/hooks/')) + return 'hook' + if (normalizedPath.includes('/utils/')) + return 'util' + if (/\/page\.(t|j)sx?$/.test(normalizedPath)) + return 'page' + if (/\/layout\.(t|j)sx?$/.test(normalizedPath)) + return 'layout' + if (/\/providers?\//.test(normalizedPath)) + return 'provider' + // Dify-specific types + if (normalizedPath.includes('/components/base/')) + return 'base-component' + if (normalizedPath.includes('/context/')) + return 'context' + if (normalizedPath.includes('/store/')) + return 'store' + if (normalizedPath.includes('/service/')) + return 'service' + if (/use[A-Z]\w+/.test(code)) + return 'component' + return 'component' + } + + /** + * Calculate Cognitive Complexity using SonarJS ESLint plugin + * Reference: https://www.sonarsource.com/blog/5-clean-code-tips-for-reducing-cognitive-complexity/ + * + * Returns raw (unnormalized) complexity values: + * - total: sum of all functions' complexity in the file + * - max: highest single function complexity in the file + * + * Raw Score Thresholds (per function): + * 0-15: Simple | 16-30: Medium | 31-50: Complex | 51+: Very Complex + * + * @returns {{ total: number, max: number }} raw total and max complexity + */ + calculateCognitiveComplexity(code) { + const linter = new Linter() + const baseConfig = { + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { jsx: true }, + }, + }, + plugins: { sonarjs: sonarPlugin }, + } + + try { + // Get total complexity using 'metric' option (more stable) + const totalConfig = { + ...baseConfig, + rules: { 'sonarjs/cognitive-complexity': ['error', 0, 'metric'] }, + } + const totalMessages = linter.verify(code, totalConfig) + const totalMsg = totalMessages.find( + msg => msg.ruleId === 'sonarjs/cognitive-complexity' + && msg.messageId === 'fileComplexity', + ) + const total = totalMsg ? Number.parseInt(totalMsg.message, 10) : 0 + + // Get max function complexity by analyzing each function + const maxConfig = { + ...baseConfig, + rules: { 'sonarjs/cognitive-complexity': ['error', 0] }, + } + const maxMessages = linter.verify(code, maxConfig) + let max = 0 + const complexityPattern = /reduce its Cognitive Complexity from (\d+)/ + + maxMessages.forEach((msg) => { + if (msg.ruleId === 'sonarjs/cognitive-complexity') { + const match = msg.message.match(complexityPattern) + if (match && match[1]) + max = Math.max(max, Number.parseInt(match[1], 10)) + } + }) + + return { total, max } + } + catch { + return { total: 0, max: 0 } + } + } + + /** + * Normalize cognitive complexity to 0-100 scale + * + * Mapping (aligned with SonarJS thresholds): + * Raw 0-15 (Simple) -> Normalized 0-25 + * Raw 16-30 (Medium) -> Normalized 25-50 + * Raw 31-50 (Complex) -> Normalized 50-75 + * Raw 51+ (Very Complex) -> Normalized 75-100 (asymptotic) + */ + normalizeComplexity(rawComplexity) { + if (rawComplexity <= 15) { + // Linear: 0-15 -> 0-25 + return Math.round((rawComplexity / 15) * 25) + } + else if (rawComplexity <= 30) { + // Linear: 16-30 -> 25-50 + return Math.round(25 + ((rawComplexity - 15) / 15) * 25) + } + else if (rawComplexity <= 50) { + // Linear: 31-50 -> 50-75 + return Math.round(50 + ((rawComplexity - 30) / 20) * 25) + } + else { + // Asymptotic: 51+ -> 75-100 + // Formula ensures score approaches but never exceeds 100 + return Math.round(75 + 25 * (1 - 1 / (1 + (rawComplexity - 50) / 100))) + } + } + + /** + * Count how many times a component is referenced in the codebase + * Scans TypeScript sources for import statements referencing the component + */ + countUsageReferences(filePath, absolutePath) { + try { + const resolvedComponentPath = absolutePath ?? path.resolve(process.cwd(), filePath) + const fileName = path.basename(resolvedComponentPath, path.extname(resolvedComponentPath)) + + let searchName = fileName + if (fileName === 'index') { + const parentDir = path.dirname(resolvedComponentPath) + searchName = path.basename(parentDir) + } + + if (!searchName) + return 0 + + const searchRoots = this.collectSearchRoots(resolvedComponentPath) + if (searchRoots.length === 0) + return 0 + + const escapedName = ComponentAnalyzer.escapeRegExp(searchName) + const patterns = [ + new RegExp(`from\\s+['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`), + new RegExp(`import\\s*\\(\\s*['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`), + new RegExp(`export\\s+(?:\\*|{[^}]*})\\s*from\\s+['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`), + new RegExp(`require\\(\\s*['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`), + ] + + const visited = new Set() + let usageCount = 0 + + const stack = [...searchRoots] + while (stack.length > 0) { + const currentDir = stack.pop() + if (!currentDir || visited.has(currentDir)) + continue + visited.add(currentDir) + + const entries = fs.readdirSync(currentDir, { withFileTypes: true }) + + entries.forEach((entry) => { + const entryPath = path.join(currentDir, entry.name) + + if (entry.isDirectory()) { + if (this.shouldSkipDir(entry.name)) + return + stack.push(entryPath) + return + } + + if (!this.shouldInspectFile(entry.name)) + return + + const normalizedEntryPath = path.resolve(entryPath) + if (normalizedEntryPath === path.resolve(resolvedComponentPath)) + return + + const source = fs.readFileSync(entryPath, 'utf-8') + if (!source.includes(searchName)) + return + + if (patterns.some((pattern) => { + pattern.lastIndex = 0 + return pattern.test(source) + })) { + usageCount += 1 + } + }) + } + + return usageCount + } + catch { + // If command fails, return 0 + return 0 + } + } + + collectSearchRoots(resolvedComponentPath) { + const roots = new Set() + + let currentDir = path.dirname(resolvedComponentPath) + const workspaceRoot = process.cwd() + + while (currentDir && currentDir !== path.dirname(currentDir)) { + if (path.basename(currentDir) === 'app') { + roots.add(currentDir) + break + } + + if (currentDir === workspaceRoot) + break + currentDir = path.dirname(currentDir) + } + + const fallbackRoots = [ + path.join(workspaceRoot, 'app'), + path.join(workspaceRoot, 'web', 'app'), + path.join(workspaceRoot, 'src'), + ] + + fallbackRoots.forEach((root) => { + if (fs.existsSync(root) && fs.statSync(root).isDirectory()) + roots.add(root) + }) + + return Array.from(roots) + } + + shouldSkipDir(dirName) { + const normalized = dirName.toLowerCase() + return [ + 'node_modules', + '.git', + '.next', + 'dist', + 'out', + 'coverage', + 'build', + '__tests__', + '__mocks__', + ].includes(normalized) + } + + shouldInspectFile(fileName) { + const normalized = fileName.toLowerCase() + if (!(/\.(ts|tsx)$/i.test(fileName))) + return false + if (normalized.endsWith('.d.ts')) + return false + if (/\.(spec|test)\.(ts|tsx)$/.test(normalized)) + return false + if (normalized.endsWith('.stories.tsx')) + return false + return true + } + + static escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + } + + /** + * Calculate test priority based on cognitive complexity and usage + * + * Priority Score = 0.7 * Complexity + 0.3 * Usage Score (all normalized to 0-100) + * - Complexity Score: 0-100 (normalized from SonarJS) + * - Usage Score: 0-100 (based on reference count) + * + * Priority Levels (0-100): + * - 0-25: 🟢 LOW + * - 26-50: 🟡 MEDIUM + * - 51-75: 🟠 HIGH + * - 76-100: 🔴 CRITICAL + */ + calculateTestPriority(complexity, usageCount) { + const complexityScore = complexity + + // Normalize usage score to 0-100 + let usageScore + if (usageCount === 0) + usageScore = 0 + else if (usageCount <= 5) + usageScore = 20 + else if (usageCount <= 20) + usageScore = 40 + else if (usageCount <= 50) + usageScore = 70 + else + usageScore = 100 + + // Weighted average: complexity (70%) + usage (30%) + const totalScore = Math.round(0.7 * complexityScore + 0.3 * usageScore) + + return { + score: totalScore, + level: this.getPriorityLevel(totalScore), + usageScore, + complexityScore, + } + } + + /** + * Get priority level based on score (0-100 scale) + */ + getPriorityLevel(score) { + if (score > 75) + return '🔴 CRITICAL' + if (score > 50) + return '🟠 HIGH' + if (score > 25) + return '🟡 MEDIUM' + return '🟢 LOW' + } +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Resolve directory to entry file + * Priority: index files > common entry files (node.tsx, panel.tsx, etc.) + */ +export function resolveDirectoryEntry(absolutePath, componentPath) { + // Entry files in priority order: index files first, then common entry files + const entryFiles = [ + 'index.tsx', + 'index.ts', // Priority 1: index files + 'node.tsx', + 'panel.tsx', + 'component.tsx', + 'main.tsx', + 'container.tsx', // Priority 2: common entry files + ] + for (const entryFile of entryFiles) { + const entryPath = path.join(absolutePath, entryFile) + if (fs.existsSync(entryPath)) { + return { + absolutePath: entryPath, + componentPath: path.join(componentPath, entryFile), + } + } + } + + return null +} + +/** + * List analyzable files in directory (for user guidance) + */ +export function listAnalyzableFiles(dirPath) { + try { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }) + return entries + .filter(entry => !entry.isDirectory() && /\.(tsx?|jsx?)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) + .map(entry => entry.name) + .sort((a, b) => { + // Prioritize common entry files + const priority = ['index.tsx', 'index.ts', 'node.tsx', 'panel.tsx', 'component.tsx', 'main.tsx', 'container.tsx'] + const aIdx = priority.indexOf(a) + const bIdx = priority.indexOf(b) + if (aIdx !== -1 && bIdx !== -1) + return aIdx - bIdx + if (aIdx !== -1) + return -1 + if (bIdx !== -1) + return 1 + return a.localeCompare(b) + }) + } + catch { + return [] + } +} + +/** + * Extract copy content from prompt (for clipboard) + */ +export function extractCopyContent(prompt) { + const marker = '📋 PROMPT FOR AI ASSISTANT (COPY THIS TO YOUR AI ASSISTANT):' + const markerIndex = prompt.indexOf(marker) + if (markerIndex === -1) + return '' + + const section = prompt.slice(markerIndex) + const lines = section.split('\n') + const firstDivider = lines.findIndex(line => line.includes('━━━━━━━━')) + if (firstDivider === -1) + return '' + + const startIdx = firstDivider + 1 + let endIdx = lines.length + + for (let i = startIdx; i < lines.length; i++) { + if (lines[i].includes('━━━━━━━━')) { + endIdx = i + break + } + } + + if (startIdx >= endIdx) + return '' + + return lines.slice(startIdx, endIdx).join('\n').trim() +} + +/** + * Get complexity level label + */ +export function getComplexityLevel(score) { + if (score <= 25) + return '🟢 Simple' + if (score <= 50) + return '🟡 Medium' + if (score <= 75) + return '🟠 Complex' + return '🔴 Very Complex' +} diff --git a/web/scripts/refactor-component.js b/web/scripts/refactor-component.js new file mode 100644 index 0000000000..f890540515 --- /dev/null +++ b/web/scripts/refactor-component.js @@ -0,0 +1,420 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { + ComponentAnalyzer, + extractCopyContent, + getComplexityLevel, + listAnalyzableFiles, + resolveDirectoryEntry, +} from './component-analyzer.js' + +// ============================================================================ +// Extended Analyzer for Refactoring +// ============================================================================ + +class RefactorAnalyzer extends ComponentAnalyzer { + analyze(code, filePath, absolutePath) { + // Get base analysis from parent class + const baseAnalysis = super.analyze(code, filePath, absolutePath) + + // Add refactoring-specific metrics + // Note: These counts use regex matching which may include import statements. + // For most components this results in +1 over actual usage, which is acceptable + // for heuristic analysis. For precise AST-based counting, consider using + // @typescript-eslint/parser to traverse the AST. + const stateCount = (code.match(/useState\s*[(<]/g) || []).length + const effectCount = (code.match(/useEffect\s*\(/g) || []).length + const callbackCount = (code.match(/useCallback\s*\(/g) || []).length + const memoCount = (code.match(/useMemo\s*\(/g) || []).length + const conditionalBlocks = this.countConditionalBlocks(code) + const nestedTernaries = this.countNestedTernaries(code) + const hasContext = code.includes('useContext') || code.includes('createContext') + const hasReducer = code.includes('useReducer') + const hasModals = this.countModals(code) + + return { + ...baseAnalysis, + stateCount, + effectCount, + callbackCount, + memoCount, + conditionalBlocks, + nestedTernaries, + hasContext, + hasReducer, + hasModals, + } + } + + countModals(code) { + const modalPatterns = [ + /Modal/g, + /Dialog/g, + /Drawer/g, + /Confirm/g, + /showModal|setShowModal|isShown|isShowing/g, + ] + let count = 0 + modalPatterns.forEach((pattern) => { + const matches = code.match(pattern) + if (matches) + count += matches.length + }) + return Math.floor(count / 3) // Rough estimate of actual modals + } + + countConditionalBlocks(code) { + const ifBlocks = (code.match(/\bif\s*\(/g) || []).length + const ternaries = (code.match(/\?.*:/g) || []).length + const switchCases = (code.match(/\bswitch\s*\(/g) || []).length + return ifBlocks + ternaries + switchCases + } + + countNestedTernaries(code) { + const nestedInTrueBranch = (code.match(/\?[^:?]*\?[^:]*:/g) || []).length + const nestedInFalseBranch = (code.match(/\?[^:?]*:[^?]*\?[^:]*:/g) || []).length + + return nestedInTrueBranch + nestedInFalseBranch + } +} + +// ============================================================================ +// Refactor Prompt Builder +// ============================================================================ + +class RefactorPromptBuilder { + build(analysis) { + const refactorActions = this.identifyRefactorActions(analysis) + + return ` +╔════════════════════════════════════════════════════════════════════════════╗ +║ 🔧 REFACTOR DIFY COMPONENT ║ +╚════════════════════════════════════════════════════════════════════════════╝ + +📍 Component: ${analysis.name} +📂 Path: ${analysis.path} + +📊 Complexity Analysis: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Total Complexity: ${analysis.complexity}/100 ${getComplexityLevel(analysis.complexity)} +Max Func Complexity: ${analysis.maxComplexity}/100 ${getComplexityLevel(analysis.maxComplexity)} +Lines: ${analysis.lineCount} ${analysis.lineCount > 300 ? '⚠️ TOO LARGE' : ''} +Usage: ${analysis.usageCount} reference${analysis.usageCount !== 1 ? 's' : ''} + +📈 Code Metrics: + useState calls: ${analysis.stateCount} + useEffect calls: ${analysis.effectCount} + useCallback calls: ${analysis.callbackCount} + useMemo calls: ${analysis.memoCount} + Conditional blocks: ${analysis.conditionalBlocks} + Nested ternaries: ${analysis.nestedTernaries} + Modal components: ${analysis.hasModals} + +🔍 Features Detected: + ${analysis.hasState ? '✓' : '✗'} Local state (useState/useReducer) + ${analysis.hasEffects ? '✓' : '✗'} Side effects (useEffect) + ${analysis.hasCallbacks ? '✓' : '✗'} Callbacks (useCallback) + ${analysis.hasMemo ? '✓' : '✗'} Memoization (useMemo) + ${analysis.hasContext ? '✓' : '✗'} Context (useContext/createContext) + ${analysis.hasEvents ? '✓' : '✗'} Event handlers + ${analysis.hasRouter ? '✓' : '✗'} Next.js routing + ${analysis.hasAPI ? '✓' : '✗'} API calls + ${analysis.hasReactQuery ? '✓' : '✗'} React Query + ${analysis.hasSWR ? '✓' : '✗'} SWR (should migrate to React Query) + ${analysis.hasAhooks ? '✓' : '✗'} ahooks +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +🎯 RECOMMENDED REFACTORING ACTIONS: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +${refactorActions.map((action, i) => `${i + 1}. ${action}`).join('\n')} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +📋 PROMPT FOR AI ASSISTANT (COPY THIS TO YOUR AI ASSISTANT): +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Please refactor the component at @${analysis.path} + +Component metrics: +- Complexity: ${analysis.complexity}/100 (target: < 50) +- Lines: ${analysis.lineCount} (target: < 300) +- useState: ${analysis.stateCount}, useEffect: ${analysis.effectCount} + +Refactoring tasks: +${refactorActions.map(action => `- ${action}`).join('\n')} + +Requirements: +${this.buildRequirements(analysis)} + +Follow Dify project conventions: +- Place extracted hooks in \`hooks/\` subdirectory or as \`use-.ts\` +- Use React Query (\`@tanstack/react-query\`) for data fetching, not SWR +- Follow existing patterns in \`web/service/use-*.ts\` for API hooks +- Keep each new file under 300 lines +- Maintain TypeScript strict typing + +After refactoring, verify: +- \`pnpm lint:fix\` passes +- \`pnpm type-check:tsgo\` passes +- Re-run \`pnpm refactor-component ${analysis.path}\` to confirm complexity < 50 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +` + } + + identifyRefactorActions(analysis) { + const actions = [] + + // Priority 1: Extract hooks for complex state management + if (analysis.stateCount >= 3 || (analysis.stateCount >= 2 && analysis.effectCount >= 2)) { + actions.push(`🪝 EXTRACT CUSTOM HOOK: ${analysis.stateCount} useState + ${analysis.effectCount} useEffect detected. Extract related state and effects into a custom hook (e.g., \`use${analysis.name}State.ts\`)`) + } + + // Priority 2: Extract API/data logic + if (analysis.hasAPI && (analysis.hasEffects || analysis.hasSWR)) { + if (analysis.hasSWR) { + actions.push('🔄 MIGRATE SWR TO REACT QUERY: Replace useSWR with useQuery from @tanstack/react-query') + } + actions.push('🌐 EXTRACT DATA HOOK: Move API calls and data fetching logic into a dedicated hook using React Query') + } + + // Priority 3: Split large components + if (analysis.lineCount > 300) { + actions.push(`📦 SPLIT COMPONENT: ${analysis.lineCount} lines exceeds limit. Extract UI sections into sub-components`) + } + + // Priority 4: Extract modal management + if (analysis.hasModals >= 2) { + actions.push(`🔲 EXTRACT MODAL MANAGEMENT: ${analysis.hasModals} modal-related patterns detected. Create a useModalState hook or separate modal components`) + } + + // Priority 5: Simplify conditionals + if (analysis.conditionalBlocks > 10 || analysis.nestedTernaries >= 2) { + actions.push('🔀 SIMPLIFY CONDITIONALS: Use lookup tables, early returns, or extract complex conditions into named functions') + } + + // Priority 6: Extract callbacks + if (analysis.callbackCount >= 4) { + actions.push(`⚡ CONSOLIDATE CALLBACKS: ${analysis.callbackCount} useCallback calls. Consider extracting related callbacks into a custom hook`) + } + + // Priority 7: Context provider extraction + if (analysis.hasContext && analysis.complexity > 50) { + actions.push('🎯 EXTRACT CONTEXT LOGIC: Move context provider logic into separate files or split into domain-specific contexts') + } + + // Priority 8: Memoization review + if (analysis.memoCount >= 3 && analysis.complexity > 50) { + actions.push(`📝 REVIEW MEMOIZATION: ${analysis.memoCount} useMemo calls. Extract complex computations into utility functions or hooks`) + } + + // If no specific issues, provide general guidance + if (actions.length === 0) { + if (analysis.complexity > 50) { + actions.push('🔍 ANALYZE FUNCTIONS: Review individual functions for complexity and extract helper functions') + } + else { + actions.push('✅ Component complexity is acceptable. Consider minor improvements for maintainability') + } + } + + return actions + } + + buildRequirements(analysis) { + const requirements = [] + + if (analysis.stateCount >= 3) { + requirements.push('- Group related useState calls into a single custom hook') + requirements.push('- Move associated useEffect calls with the state they depend on') + } + + if (analysis.hasAPI) { + requirements.push('- Create data fetching hook following web/service/use-*.ts patterns') + requirements.push('- Use useQuery with proper queryKey and enabled options') + requirements.push('- Export invalidation hook (useInvalidXxx) for cache management') + } + + if (analysis.lineCount > 300) { + requirements.push('- Extract logical UI sections into separate components') + requirements.push('- Keep parent component focused on orchestration') + requirements.push('- Pass minimal props to child components') + } + + if (analysis.hasModals >= 2) { + requirements.push('- Create unified modal state management') + requirements.push('- Consider extracting modals to separate file') + } + + if (analysis.conditionalBlocks > 10) { + requirements.push('- Replace switch statements with lookup tables') + requirements.push('- Use early returns to reduce nesting') + requirements.push('- Extract complex boolean logic to named functions') + } + + if (requirements.length === 0) { + requirements.push('- Maintain existing code structure') + requirements.push('- Focus on readability improvements') + } + + return requirements.join('\n') + } +} + +// ============================================================================ +// Main Function +// ============================================================================ + +function showHelp() { + console.log(` +🔧 Component Refactor Tool - Generate refactoring prompts for AI assistants + +Usage: + node refactor-component.js [options] + pnpm refactor-component [options] + +Options: + --help Show this help message + --json Output analysis result as JSON (for programmatic use) + +Examples: + # Analyze and generate refactoring prompt + pnpm refactor-component app/components/app/configuration/index.tsx + + # Output as JSON + pnpm refactor-component app/components/tools/mcp/modal.tsx --json + +Complexity Thresholds: + 🟢 0-25: Simple (no refactoring needed) + 🟡 26-50: Medium (consider minor refactoring) + 🟠 51-75: Complex (should refactor) + 🔴 76-100: Very Complex (must refactor) + +For complete refactoring guidelines, see: + .claude/skills/component-refactoring/SKILL.md +`) +} + +function main() { + const rawArgs = process.argv.slice(2) + + let isJsonMode = false + const args = [] + + rawArgs.forEach((arg) => { + if (arg === '--json') { + isJsonMode = true + return + } + if (arg === '--help' || arg === '-h') { + showHelp() + process.exit(0) + } + args.push(arg) + }) + + if (args.length === 0) { + showHelp() + process.exit(1) + } + + let componentPath = args[0] + let absolutePath = path.resolve(process.cwd(), componentPath) + + if (!fs.existsSync(absolutePath)) { + console.error(`❌ Error: Path not found: ${componentPath}`) + process.exit(1) + } + + if (fs.statSync(absolutePath).isDirectory()) { + const resolvedFile = resolveDirectoryEntry(absolutePath, componentPath) + if (resolvedFile) { + absolutePath = resolvedFile.absolutePath + componentPath = resolvedFile.componentPath + } + else { + const availableFiles = listAnalyzableFiles(absolutePath) + console.error(`❌ Error: Directory does not contain a recognizable entry file: ${componentPath}`) + if (availableFiles.length > 0) { + console.error(`\n Available files to analyze:`) + availableFiles.forEach(f => console.error(` - ${path.join(componentPath, f)}`)) + console.error(`\n Please specify the exact file path, e.g.:`) + console.error(` pnpm refactor-component ${path.join(componentPath, availableFiles[0])}`) + } + process.exit(1) + } + } + + const sourceCode = fs.readFileSync(absolutePath, 'utf-8') + + const analyzer = new RefactorAnalyzer() + const analysis = analyzer.analyze(sourceCode, componentPath, absolutePath) + + // JSON output mode + if (isJsonMode) { + console.log(JSON.stringify(analysis, null, 2)) + return + } + + // Check if refactoring is needed + if (analysis.complexity <= 25 && analysis.lineCount <= 200) { + console.log(` +╔════════════════════════════════════════════════════════════════════════════╗ +║ ✅ COMPONENT IS WELL-STRUCTURED ║ +╚════════════════════════════════════════════════════════════════════════════╝ + +📍 Component: ${analysis.name} +📂 Path: ${analysis.path} + +📊 Metrics: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Complexity: ${analysis.complexity}/100 🟢 Simple +Lines: ${analysis.lineCount} ✓ Within limits +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +This component has good structure. No immediate refactoring needed. +You can proceed with testing using: pnpm analyze-component ${componentPath} +`) + return + } + + // Build refactoring prompt + const builder = new RefactorPromptBuilder() + const prompt = builder.build(analysis) + + console.log(prompt) + + // Copy to clipboard (macOS) + try { + const checkPbcopy = spawnSync('which', ['pbcopy'], { stdio: 'pipe' }) + if (checkPbcopy.status !== 0) + return + const copyContent = extractCopyContent(prompt) + if (!copyContent) + return + + const result = spawnSync('pbcopy', [], { + input: copyContent, + encoding: 'utf-8', + }) + + if (result.status === 0) { + console.log('\n📋 Refactoring prompt copied to clipboard!') + console.log(' Paste it in your AI assistant:') + console.log(' - Cursor: Cmd+L (Chat) or Cmd+I (Composer)') + console.log(' - GitHub Copilot Chat: Cmd+I') + console.log(' - Or any other AI coding tool\n') + } + } + catch { + // pbcopy failed, but don't break the script + } +} + +// ============================================================================ +// Run +// ============================================================================ + +main() diff --git a/web/testing/testing.md b/web/testing/testing.md index 08fc716cf3..1d578ae634 100644 --- a/web/testing/testing.md +++ b/web/testing/testing.md @@ -33,7 +33,7 @@ pnpm test path/to/file.spec.tsx - **Global setup**: `vitest.setup.ts` already imports `@testing-library/jest-dom`, runs `cleanup()` after every test, and defines shared mocks (for example `react-i18next`, `next/image`). Add any environment-level mocks (for example `ResizeObserver`, `matchMedia`, `IntersectionObserver`, `TextEncoder`, `crypto`) here so they are shared consistently. - **Reusable mocks**: Place shared mock factories inside `web/__mocks__/` and use `vi.mock('module-name')` to point to them rather than redefining mocks in every spec. - **Mocking behavior**: Modules are not mocked automatically. Use `vi.mock(...)` in tests, or place global mocks in `vitest.setup.ts`. -- **Script utilities**: `web/testing/analyze-component.js` analyzes component complexity and generates test prompts for AI assistants. Commands: +- **Script utilities**: `web/scripts/analyze-component.js` analyzes component complexity and generates test prompts for AI assistants. Commands: - `pnpm analyze-component ` - Analyze and generate test prompt - `pnpm analyze-component --json` - Output analysis as JSON - `pnpm analyze-component --review` - Generate test review prompt From 9885e92854382043058c156be08aea88d9dae331 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Thu, 25 Dec 2025 18:36:52 +0800 Subject: [PATCH 10/22] fix: validate first then save to db (#30107) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/core/plugin/entities/parameters.py | 2 +- api/core/tools/workflow_as_tool/provider.py | 22 +- .../tools/workflow_tools_manage_service.py | 31 +-- .../test_workflow_tools_manage_service.py | 204 ++++++++++++++++++ 4 files changed, 231 insertions(+), 28 deletions(-) diff --git a/api/core/plugin/entities/parameters.py b/api/core/plugin/entities/parameters.py index 88a3a7bd43..bfa662b9f6 100644 --- a/api/core/plugin/entities/parameters.py +++ b/api/core/plugin/entities/parameters.py @@ -76,7 +76,7 @@ class PluginParameter(BaseModel): auto_generate: PluginParameterAutoGenerate | None = None template: PluginParameterTemplate | None = None required: bool = False - default: Union[float, int, str, bool] | None = None + default: Union[float, int, str, bool, list, dict] | None = None min: Union[float, int] | None = None max: Union[float, int] | None = None precision: int | None = None diff --git a/api/core/tools/workflow_as_tool/provider.py b/api/core/tools/workflow_as_tool/provider.py index 0439fb1d60..2bd973f831 100644 --- a/api/core/tools/workflow_as_tool/provider.py +++ b/api/core/tools/workflow_as_tool/provider.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import Session from core.app.app_config.entities import VariableEntity, VariableEntityType from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager +from core.db.session_factory import session_factory from core.plugin.entities.parameters import PluginParameterOption from core.tools.__base.tool_provider import ToolProviderController from core.tools.__base.tool_runtime import ToolRuntime @@ -47,33 +48,30 @@ class WorkflowToolProviderController(ToolProviderController): @classmethod def from_db(cls, db_provider: WorkflowToolProvider) -> "WorkflowToolProviderController": - with Session(db.engine, expire_on_commit=False) as session, session.begin(): - provider = session.get(WorkflowToolProvider, db_provider.id) if db_provider.id else None - if not provider: - raise ValueError("workflow provider not found") - app = session.get(App, provider.app_id) + with session_factory.create_session() as session, session.begin(): + app = session.get(App, db_provider.app_id) if not app: raise ValueError("app not found") - user = session.get(Account, provider.user_id) if provider.user_id else None + user = session.get(Account, db_provider.user_id) if db_provider.user_id else None controller = WorkflowToolProviderController( entity=ToolProviderEntity( identity=ToolProviderIdentity( author=user.name if user else "", - name=provider.label, - label=I18nObject(en_US=provider.label, zh_Hans=provider.label), - description=I18nObject(en_US=provider.description, zh_Hans=provider.description), - icon=provider.icon, + name=db_provider.label, + label=I18nObject(en_US=db_provider.label, zh_Hans=db_provider.label), + description=I18nObject(en_US=db_provider.description, zh_Hans=db_provider.description), + icon=db_provider.icon, ), credentials_schema=[], plugin_id=None, ), - provider_id=provider.id or "", + provider_id="", ) controller.tools = [ - controller._get_db_provider_tool(provider, app, session=session, user=user), + controller._get_db_provider_tool(db_provider, app, session=session, user=user), ] return controller diff --git a/api/services/tools/workflow_tools_manage_service.py b/api/services/tools/workflow_tools_manage_service.py index fe77ff2dc5..714a651839 100644 --- a/api/services/tools/workflow_tools_manage_service.py +++ b/api/services/tools/workflow_tools_manage_service.py @@ -5,8 +5,8 @@ from datetime import datetime from typing import Any from sqlalchemy import or_, select -from sqlalchemy.orm import Session +from core.db.session_factory import session_factory from core.helper.tool_provider_cache import ToolProviderListCache from core.model_runtime.utils.encoders import jsonable_encoder from core.tools.__base.tool_provider import ToolProviderController @@ -68,26 +68,27 @@ class WorkflowToolManageService: if workflow is None: raise ValueError(f"Workflow not found for app {workflow_app_id}") - with Session(db.engine, expire_on_commit=False) as session, session.begin(): - workflow_tool_provider = WorkflowToolProvider( - tenant_id=tenant_id, - user_id=user_id, - app_id=workflow_app_id, - name=name, - label=label, - icon=json.dumps(icon), - description=description, - parameter_configuration=json.dumps(parameters), - privacy_policy=privacy_policy, - version=workflow.version, - ) - session.add(workflow_tool_provider) + workflow_tool_provider = WorkflowToolProvider( + tenant_id=tenant_id, + user_id=user_id, + app_id=workflow_app_id, + name=name, + label=label, + icon=json.dumps(icon), + description=description, + parameter_configuration=json.dumps(parameters), + privacy_policy=privacy_policy, + version=workflow.version, + ) try: WorkflowToolProviderController.from_db(workflow_tool_provider) except Exception as e: raise ValueError(str(e)) + with session_factory.create_session() as session, session.begin(): + session.add(workflow_tool_provider) + if labels is not None: ToolLabelManager.update_tool_labels( ToolTransformService.workflow_provider_to_controller(workflow_tool_provider), labels diff --git a/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py b/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py index 71cedd26c4..3d46735a1a 100644 --- a/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py +++ b/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py @@ -705,3 +705,207 @@ class TestWorkflowToolManageService: db.session.refresh(created_tool) assert created_tool.name == first_tool_name assert created_tool.updated_at is not None + + def test_create_workflow_tool_with_file_parameter_default( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test workflow tool creation with FILE parameter having a file object as default. + + This test verifies: + - FILE parameters can have file object defaults + - The default value (dict with id/base64Url) is properly handled + - Tool creation succeeds without Pydantic validation errors + + Related issue: Array[File] default value causes Pydantic validation errors. + """ + fake = Faker() + + # Create test data + app, account, workflow = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies + ) + + # Create workflow graph with a FILE variable that has a default value + workflow_graph = { + "nodes": [ + { + "id": "start_node", + "data": { + "type": "start", + "variables": [ + { + "variable": "document", + "label": "Document", + "type": "file", + "required": False, + "default": {"id": fake.uuid4(), "base64Url": ""}, + } + ], + }, + } + ] + } + workflow.graph = json.dumps(workflow_graph) + + # Setup workflow tool parameters with FILE type + file_parameters = [ + { + "name": "document", + "description": "Upload a document", + "form": "form", + "type": "file", + "required": False, + } + ] + + # Execute the method under test + # Note: from_db is mocked, so this test primarily validates the parameter configuration + result = WorkflowToolManageService.create_workflow_tool( + user_id=account.id, + tenant_id=account.current_tenant.id, + workflow_app_id=app.id, + name=fake.word(), + label=fake.word(), + icon={"type": "emoji", "emoji": "📄"}, + description=fake.text(max_nb_chars=200), + parameters=file_parameters, + ) + + # Verify the result + assert result == {"result": "success"} + + def test_create_workflow_tool_with_files_parameter_default( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test workflow tool creation with FILES (Array[File]) parameter having file objects as default. + + This test verifies: + - FILES parameters can have a list of file objects as default + - The default value (list of dicts with id/base64Url) is properly handled + - Tool creation succeeds without Pydantic validation errors + + Related issue: Array[File] default value causes 4 Pydantic validation errors + because PluginParameter.default only accepts Union[float, int, str, bool] | None. + """ + fake = Faker() + + # Create test data + app, account, workflow = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies + ) + + # Create workflow graph with a FILE_LIST variable that has a default value + workflow_graph = { + "nodes": [ + { + "id": "start_node", + "data": { + "type": "start", + "variables": [ + { + "variable": "documents", + "label": "Documents", + "type": "file-list", + "required": False, + "default": [ + {"id": fake.uuid4(), "base64Url": ""}, + {"id": fake.uuid4(), "base64Url": ""}, + ], + } + ], + }, + } + ] + } + workflow.graph = json.dumps(workflow_graph) + + # Setup workflow tool parameters with FILES type + files_parameters = [ + { + "name": "documents", + "description": "Upload multiple documents", + "form": "form", + "type": "files", + "required": False, + } + ] + + # Execute the method under test + # Note: from_db is mocked, so this test primarily validates the parameter configuration + result = WorkflowToolManageService.create_workflow_tool( + user_id=account.id, + tenant_id=account.current_tenant.id, + workflow_app_id=app.id, + name=fake.word(), + label=fake.word(), + icon={"type": "emoji", "emoji": "📁"}, + description=fake.text(max_nb_chars=200), + parameters=files_parameters, + ) + + # Verify the result + assert result == {"result": "success"} + + def test_create_workflow_tool_db_commit_before_validation( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test that database commit happens before validation, causing DB pollution on validation failure. + + This test verifies the second bug: + - WorkflowToolProvider is committed to database BEFORE from_db validation + - If validation fails, the record remains in the database + - Subsequent attempts fail with "Tool already exists" error + + This demonstrates why we need to validate BEFORE database commit. + """ + + fake = Faker() + + # Create test data + app, account, workflow = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies + ) + + tool_name = fake.word() + + # Mock from_db to raise validation error + mock_external_service_dependencies["workflow_tool_provider_controller"].from_db.side_effect = ValueError( + "Validation failed: default parameter type mismatch" + ) + + # Attempt to create workflow tool (will fail at validation stage) + with pytest.raises(ValueError) as exc_info: + WorkflowToolManageService.create_workflow_tool( + user_id=account.id, + tenant_id=account.current_tenant.id, + workflow_app_id=app.id, + name=tool_name, + label=fake.word(), + icon={"type": "emoji", "emoji": "🔧"}, + description=fake.text(max_nb_chars=200), + parameters=self._create_test_workflow_tool_parameters(), + ) + + assert "Validation failed" in str(exc_info.value) + + # Verify the tool was NOT created in database + # This is the expected behavior (no pollution) + from extensions.ext_database import db + + tool_count = ( + db.session.query(WorkflowToolProvider) + .where( + WorkflowToolProvider.tenant_id == account.current_tenant.id, + WorkflowToolProvider.name == tool_name, + ) + .count() + ) + + # The record should NOT exist because the transaction should be rolled back + # Currently, due to the bug, the record might exist (this test documents the bug) + # After the fix, this should always be 0 + # For now, we document that the record may exist, demonstrating the bug + # assert tool_count == 0 # Expected after fix From 08d5eee993da6ef9a23b96cc21e56691b4bf30f6 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 25 Dec 2025 19:13:59 +0800 Subject: [PATCH 11/22] fix: load i18n on server (#30171) --- web/i18n-config/server.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/i18n-config/server.ts b/web/i18n-config/server.ts index c92a5a6025..736d76e2c7 100644 --- a/web/i18n-config/server.ts +++ b/web/i18n-config/server.ts @@ -1,7 +1,6 @@ import type { Locale } from '.' -import type { KeyPrefix, Namespace } from './i18next-config' +import type { Namespace } from './i18next-config' import { match } from '@formatjs/intl-localematcher' -import { camelCase } from 'es-toolkit/compat' import { createInstance } from 'i18next' import resourcesToBackend from 'i18next-resources-to-backend' import Negotiator from 'negotiator' @@ -23,10 +22,11 @@ const initI18next = async (lng: Locale, ns: Namespace) => { return i18nInstance } -export async function getTranslation(lng: Locale, ns: Namespace) { +export async function getTranslation(lng: Locale, ns: Namespace, options: Record = {}) { const i18nextInstance = await initI18next(lng, ns) return { - t: i18nextInstance.getFixedT(lng, 'translation', camelCase(ns) as KeyPrefix), + // @ts-expect-error types mismatch + t: i18nextInstance.getFixedT(lng, ns, options.keyPrefix), i18n: i18nextInstance, } } From 0f3ffbee2c065a4196f2f736ccf7a47cb471854e Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 25 Dec 2025 19:45:27 +0800 Subject: [PATCH 12/22] chore: some test (#30148) Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- .../create-app-dialog/app-list/index.spec.tsx | 136 +++++++++++ .../app-list/sidebar.spec.tsx | 38 +++ .../app/overview/settings/index.spec.tsx | 217 ++++++++++++++++++ .../config-credentials.spec.tsx | 60 +++++ .../get-schema.spec.tsx | 55 +++++ .../index.spec.tsx | 154 +++++++++++++ .../test-api.spec.tsx | 87 +++++++ 7 files changed, 747 insertions(+) create mode 100644 web/app/components/app/create-app-dialog/app-list/index.spec.tsx create mode 100644 web/app/components/app/create-app-dialog/app-list/sidebar.spec.tsx create mode 100644 web/app/components/app/overview/settings/index.spec.tsx create mode 100644 web/app/components/tools/edit-custom-collection-modal/config-credentials.spec.tsx create mode 100644 web/app/components/tools/edit-custom-collection-modal/get-schema.spec.tsx create mode 100644 web/app/components/tools/edit-custom-collection-modal/index.spec.tsx create mode 100644 web/app/components/tools/edit-custom-collection-modal/test-api.spec.tsx diff --git a/web/app/components/app/create-app-dialog/app-list/index.spec.tsx b/web/app/components/app/create-app-dialog/app-list/index.spec.tsx new file mode 100644 index 0000000000..42f510b468 --- /dev/null +++ b/web/app/components/app/create-app-dialog/app-list/index.spec.tsx @@ -0,0 +1,136 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { AppModeEnum } from '@/types/app' +import Apps from './index' + +const mockUseExploreAppList = vi.fn() + +vi.mock('ahooks', () => ({ + useDebounceFn: (fn: () => void) => ({ + run: () => setTimeout(fn, 0), + cancel: vi.fn(), + flush: () => fn(), + }), +})) +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ isCurrentWorkspaceEditor: true }), +})) +vi.mock('use-context-selector', async () => { + const actual = await vi.importActual('use-context-selector') + return { + ...actual, + useContext: () => ({ hasEditPermission: true }), + } +}) +vi.mock('@/hooks/use-tab-searchparams', () => ({ + useTabSearchParams: () => ['Recommended', vi.fn()], +})) +vi.mock('@/service/use-explore', () => ({ + useExploreAppList: () => mockUseExploreAppList(), +})) +vi.mock('@/app/components/app/type-selector', () => ({ + __esModule: true, + default: ({ value, onChange }: { value: AppModeEnum[], onChange: (value: AppModeEnum[]) => void }) => ( + + ), +})) +vi.mock('../app-card', () => ({ + __esModule: true, + default: ({ app, onCreate }: { app: any, onCreate: () => void }) => ( +
+ {app.app.name} +
+ ), +})) +vi.mock('@/app/components/explore/create-app-modal', () => ({ + __esModule: true, + default: () =>
, +})) +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: vi.fn() }, +})) +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: vi.fn(), +})) +vi.mock('@/service/apps', () => ({ + importDSL: vi.fn().mockResolvedValue({ app_id: '1' }), +})) +vi.mock('@/service/explore', () => ({ + fetchAppDetail: vi.fn().mockResolvedValue({ + export_data: 'dsl', + mode: 'chat', + }), +})) +vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ + usePluginDependencies: () => ({ + handleCheckPluginDependencies: vi.fn(), + }), +})) +vi.mock('@/utils/app-redirection', () => ({ + getRedirection: vi.fn(), +})) +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), +})) + +const createAppEntry = (name: string, category: string) => ({ + app_id: name, + category, + app: { + id: name, + name, + icon_type: 'emoji', + icon: '🙂', + icon_background: '#000', + icon_url: null, + description: 'desc', + mode: AppModeEnum.CHAT, + }, +}) + +describe('Apps', () => { + const defaultData = { + allList: [ + createAppEntry('Alpha', 'Cat A'), + createAppEntry('Bravo', 'Cat B'), + ], + categories: ['Cat A', 'Cat B'], + } + + beforeEach(() => { + vi.clearAllMocks() + mockUseExploreAppList.mockReturnValue({ + data: defaultData, + isLoading: false, + }) + }) + + it('renders template cards when data is available', () => { + render() + + expect(screen.getAllByTestId('app-card')).toHaveLength(2) + expect(screen.getByText('Alpha')).toBeInTheDocument() + expect(screen.getByText('Bravo')).toBeInTheDocument() + }) + + it('opens create modal when a template card is clicked', () => { + render() + + fireEvent.click(screen.getAllByTestId('app-card')[0]) + expect(screen.getByTestId('create-from-template-modal')).toBeInTheDocument() + }) + it('shows no template message when list is empty', () => { + mockUseExploreAppList.mockReturnValueOnce({ + data: { allList: [], categories: [] }, + isLoading: false, + }) + + render() + + expect(screen.getByText('app.newApp.noTemplateFound')).toBeInTheDocument() + expect(screen.getByText('app.newApp.noTemplateFoundTip')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/create-app-dialog/app-list/sidebar.spec.tsx b/web/app/components/app/create-app-dialog/app-list/sidebar.spec.tsx new file mode 100644 index 0000000000..724177a6ce --- /dev/null +++ b/web/app/components/app/create-app-dialog/app-list/sidebar.spec.tsx @@ -0,0 +1,38 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import Sidebar, { AppCategories } from './sidebar' + +vi.mock('@remixicon/react', () => ({ + RiStickyNoteAddLine: () => sticky, + RiThumbUpLine: () => thumb, +})) +describe('Sidebar', () => { + it('renders recommended and custom categories', () => { + render() + + expect(screen.getByText('app.newAppFromTemplate.sidebar.Recommended')).toBeInTheDocument() + expect(screen.getByText('Cat A')).toBeInTheDocument() + expect(screen.getByText('Cat B')).toBeInTheDocument() + }) + + it('notifies callbacks when items are clicked', () => { + const onClick = vi.fn() + const onCreate = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByText('app.newAppFromTemplate.sidebar.Recommended')) + expect(onClick).toHaveBeenCalledWith(AppCategories.RECOMMENDED) + + fireEvent.click(screen.getByText('Cat A')) + expect(onClick).toHaveBeenCalledWith('Cat A') + + fireEvent.click(screen.getByText('app.newApp.startFromBlank')) + expect(onCreate).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/app/overview/settings/index.spec.tsx b/web/app/components/app/overview/settings/index.spec.tsx new file mode 100644 index 0000000000..8deae7f952 --- /dev/null +++ b/web/app/components/app/overview/settings/index.spec.tsx @@ -0,0 +1,217 @@ +import type { ReactNode } from 'react' +import type { ModalContextState } from '@/context/modal-context' +import type { ProviderContextState } from '@/context/provider-context' +import type { AppDetailResponse } from '@/models/app' +import type { AppSSO } from '@/types/app' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { Plan } from '@/app/components/billing/type' +import { baseProviderContextValue } from '@/context/provider-context' +import { AppModeEnum } from '@/types/app' +import SettingsModal from './index' + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + return { + ...actual, + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (options?.returnObjects) + return [`${key}-feature-1`, `${key}-feature-2`] + if (options) + return `${key}:${JSON.stringify(options)}` + return key + }, + i18n: { + language: 'en', + changeLanguage: vi.fn(), + }, + }), + Trans: ({ children }: { children?: ReactNode }) => <>{children}, + } +}) + +const mockNotify = vi.fn() +const mockOnClose = vi.fn() +const mockOnSave = vi.fn() +const mockSetShowPricingModal = vi.fn() +const mockSetShowAccountSettingModal = vi.fn() +const mockUseProviderContext = vi.fn<() => ProviderContextState>() + +const buildModalContext = (): ModalContextState => ({ + setShowAccountSettingModal: mockSetShowAccountSettingModal, + setShowApiBasedExtensionModal: vi.fn(), + setShowModerationSettingModal: vi.fn(), + setShowExternalDataToolModal: vi.fn(), + setShowPricingModal: mockSetShowPricingModal, + setShowAnnotationFullModal: vi.fn(), + setShowModelModal: vi.fn(), + setShowExternalKnowledgeAPIModal: vi.fn(), + setShowModelLoadBalancingModal: vi.fn(), + setShowOpeningModal: vi.fn(), + setShowUpdatePluginModal: vi.fn(), + setShowEducationExpireNoticeModal: vi.fn(), + setShowTriggerEventsLimitModal: vi.fn(), +}) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => buildModalContext(), +})) + +vi.mock('@/app/components/base/toast', async () => { + const actual = await vi.importActual('@/app/components/base/toast') + return { + ...actual, + useToastContext: () => ({ + notify: mockNotify, + close: vi.fn(), + }), + } +}) + +vi.mock('@/context/i18n', async () => { + const actual = await vi.importActual('@/context/i18n') + return { + ...actual, + useDocLink: () => (path?: string) => `https://docs.example.com${path ?? ''}`, + } +}) + +vi.mock('@/context/provider-context', async () => { + const actual = await vi.importActual('@/context/provider-context') + return { + ...actual, + useProviderContext: () => mockUseProviderContext(), + } +}) + +const mockAppInfo = { + site: { + title: 'Test App', + icon_type: 'emoji', + icon: '😀', + icon_background: '#ABCDEF', + icon_url: 'https://example.com/icon.png', + description: 'A description', + chat_color_theme: '#123456', + chat_color_theme_inverted: true, + copyright: '© Dify', + privacy_policy: '', + custom_disclaimer: 'Disclaimer', + default_language: 'en-US', + show_workflow_steps: true, + use_icon_as_answer_icon: true, + }, + mode: AppModeEnum.ADVANCED_CHAT, + enable_sso: false, +} as unknown as AppDetailResponse & Partial + +const renderSettingsModal = () => render( + , +) + +describe('SettingsModal', () => { + beforeEach(() => { + mockNotify.mockClear() + mockOnClose.mockClear() + mockOnSave.mockClear() + mockSetShowPricingModal.mockClear() + mockSetShowAccountSettingModal.mockClear() + mockUseProviderContext.mockReturnValue({ + ...baseProviderContextValue, + enableBilling: true, + plan: { + ...baseProviderContextValue.plan, + type: Plan.sandbox, + }, + webappCopyrightEnabled: true, + }) + }) + + it('should render the modal and expose the expanded settings section', async () => { + renderSettingsModal() + expect(screen.getByText('appOverview.overview.appInfo.settings.title')).toBeInTheDocument() + + const showMoreEntry = screen.getByText('appOverview.overview.appInfo.settings.more.entry') + fireEvent.click(showMoreEntry) + + await waitFor(() => { + expect(screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.copyRightPlaceholder')).toBeInTheDocument() + expect(screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder')).toBeInTheDocument() + }) + }) + + it('should notify the user when the name is empty', async () => { + renderSettingsModal() + const nameInput = screen.getByPlaceholderText('app.appNamePlaceholder') + fireEvent.change(nameInput, { target: { value: '' } }) + fireEvent.click(screen.getByText('common.operation.save')) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ message: 'app.newApp.nameNotEmpty' })) + }) + expect(mockOnSave).not.toHaveBeenCalled() + }) + + it('should validate the theme color and show an error when the hex is invalid', async () => { + renderSettingsModal() + const colorInput = screen.getByPlaceholderText('E.g #A020F0') + fireEvent.change(colorInput, { target: { value: 'not-a-hex' } }) + + fireEvent.click(screen.getByText('common.operation.save')) + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + message: 'appOverview.overview.appInfo.settings.invalidHexMessage', + })) + }) + expect(mockOnSave).not.toHaveBeenCalled() + }) + + it('should validate the privacy policy URL when advanced settings are open', async () => { + renderSettingsModal() + fireEvent.click(screen.getByText('appOverview.overview.appInfo.settings.more.entry')) + const privacyInput = screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder') + // eslint-disable-next-line sonarjs/no-clear-text-protocols + fireEvent.change(privacyInput, { target: { value: 'ftp://invalid-url' } }) + + fireEvent.click(screen.getByText('common.operation.save')) + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + message: 'appOverview.overview.appInfo.settings.invalidPrivacyPolicy', + })) + }) + expect(mockOnSave).not.toHaveBeenCalled() + }) + + it('should save valid settings and close the modal', async () => { + mockOnSave.mockResolvedValueOnce(undefined) + renderSettingsModal() + + fireEvent.click(screen.getByText('common.operation.save')) + + await waitFor(() => expect(mockOnSave).toHaveBeenCalled()) + expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ + title: mockAppInfo.site.title, + description: mockAppInfo.site.description, + default_language: mockAppInfo.site.default_language, + chat_color_theme: mockAppInfo.site.chat_color_theme, + chat_color_theme_inverted: mockAppInfo.site.chat_color_theme_inverted, + prompt_public: false, + copyright: mockAppInfo.site.copyright, + privacy_policy: mockAppInfo.site.privacy_policy, + custom_disclaimer: mockAppInfo.site.custom_disclaimer, + icon_type: 'emoji', + icon: mockAppInfo.site.icon, + icon_background: mockAppInfo.site.icon_background, + show_workflow_steps: mockAppInfo.site.show_workflow_steps, + use_icon_as_answer_icon: mockAppInfo.site.use_icon_as_answer_icon, + enable_sso: mockAppInfo.enable_sso, + })) + expect(mockOnClose).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/tools/edit-custom-collection-modal/config-credentials.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/config-credentials.spec.tsx new file mode 100644 index 0000000000..870263d83c --- /dev/null +++ b/web/app/components/tools/edit-custom-collection-modal/config-credentials.spec.tsx @@ -0,0 +1,60 @@ +import type { Credential } from '@/app/components/tools/types' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types' +import ConfigCredential from './config-credentials' + +describe('ConfigCredential', () => { + const baseCredential: Credential = { + auth_type: AuthType.none, + } + const mockOnChange = vi.fn() + const mockOnHide = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders and calls onHide when cancel is pressed', async () => { + await act(async () => { + render( + , + ) + }) + + fireEvent.click(screen.getByText('common.operation.cancel')) + + expect(mockOnHide).toHaveBeenCalledTimes(1) + expect(mockOnChange).not.toHaveBeenCalled() + }) + + it('allows selecting apiKeyHeader and submits the new credential', async () => { + await act(async () => { + render( + , + ) + }) + + fireEvent.click(screen.getByText('tools.createTool.authMethod.types.api_key_header')) + const headerInput = screen.getByPlaceholderText('tools.createTool.authMethod.types.apiKeyPlaceholder') + const valueInput = screen.getByPlaceholderText('tools.createTool.authMethod.types.apiValuePlaceholder') + fireEvent.change(headerInput, { target: { value: 'X-Auth' } }) + fireEvent.change(valueInput, { target: { value: 'sEcReT' } }) + fireEvent.click(screen.getByText('common.operation.save')) + + expect(mockOnChange).toHaveBeenCalledWith({ + auth_type: AuthType.apiKeyHeader, + api_key_header: 'X-Auth', + api_key_header_prefix: AuthHeaderPrefix.custom, + api_key_value: 'sEcReT', + }) + expect(mockOnHide).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/tools/edit-custom-collection-modal/get-schema.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/get-schema.spec.tsx new file mode 100644 index 0000000000..de156ce68a --- /dev/null +++ b/web/app/components/tools/edit-custom-collection-modal/get-schema.spec.tsx @@ -0,0 +1,55 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { importSchemaFromURL } from '@/service/tools' +import Toast from '../../base/toast' +import examples from './examples' +import GetSchema from './get-schema' + +vi.mock('@/service/tools', () => ({ + importSchemaFromURL: vi.fn(), +})) +const importSchemaFromURLMock = vi.mocked(importSchemaFromURL) + +describe('GetSchema', () => { + const notifySpy = vi.spyOn(Toast, 'notify') + const mockOnChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + notifySpy.mockClear() + importSchemaFromURLMock.mockReset() + render() + }) + + it('shows an error when the URL is not http', () => { + fireEvent.click(screen.getByText('tools.createTool.importFromUrl')) + const input = screen.getByPlaceholderText('tools.createTool.importFromUrlPlaceHolder') + // eslint-disable-next-line sonarjs/no-clear-text-protocols + fireEvent.change(input, { target: { value: 'ftp://invalid' } }) + fireEvent.click(screen.getByText('common.operation.ok')) + + expect(notifySpy).toHaveBeenCalledWith({ + type: 'error', + message: 'tools.createTool.urlError', + }) + }) + + it('imports schema from url when valid', async () => { + fireEvent.click(screen.getByText('tools.createTool.importFromUrl')) + const input = screen.getByPlaceholderText('tools.createTool.importFromUrlPlaceHolder') + fireEvent.change(input, { target: { value: 'https://example.com' } }) + importSchemaFromURLMock.mockResolvedValueOnce({ schema: 'result-schema' }) + + fireEvent.click(screen.getByText('common.operation.ok')) + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith('result-schema') + }) + }) + + it('selects example schema when example option clicked', () => { + fireEvent.click(screen.getByText('tools.createTool.examples')) + fireEvent.click(screen.getByText(`tools.createTool.exampleOptions.${examples[0].key}`)) + + expect(mockOnChange).toHaveBeenCalledWith(examples[0].content) + }) +}) diff --git a/web/app/components/tools/edit-custom-collection-modal/index.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/index.spec.tsx new file mode 100644 index 0000000000..92c9cc3df2 --- /dev/null +++ b/web/app/components/tools/edit-custom-collection-modal/index.spec.tsx @@ -0,0 +1,154 @@ +import type { ModalContextState } from '@/context/modal-context' +import type { ProviderContextState } from '@/context/provider-context' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import Toast from '@/app/components/base/toast' +import { Plan } from '@/app/components/billing/type' +import { parseParamsSchema } from '@/service/tools' +import EditCustomCollectionModal from './index' + +vi.mock('ahooks', async () => { + const actual = await vi.importActual('ahooks') + return { + ...actual, + useDebounce: (value: unknown) => value, + } +}) + +vi.mock('@/service/tools', () => ({ + parseParamsSchema: vi.fn(), +})) +const parseParamsSchemaMock = vi.mocked(parseParamsSchema) + +const mockSetShowPricingModal = vi.fn() +const mockSetShowAccountSettingModal = vi.fn() +vi.mock('@/context/modal-context', () => ({ + useModalContext: (): ModalContextState => ({ + setShowAccountSettingModal: mockSetShowAccountSettingModal, + setShowApiBasedExtensionModal: vi.fn(), + setShowModerationSettingModal: vi.fn(), + setShowExternalDataToolModal: vi.fn(), + setShowPricingModal: mockSetShowPricingModal, + setShowAnnotationFullModal: vi.fn(), + setShowModelModal: vi.fn(), + setShowExternalKnowledgeAPIModal: vi.fn(), + setShowModelLoadBalancingModal: vi.fn(), + setShowOpeningModal: vi.fn(), + setShowUpdatePluginModal: vi.fn(), + setShowEducationExpireNoticeModal: vi.fn(), + setShowTriggerEventsLimitModal: vi.fn(), + }), +})) + +const mockUseProviderContext = vi.fn() +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => mockUseProviderContext(), +})) + +vi.mock('@/context/i18n', async () => { + const actual = await vi.importActual('@/context/i18n') + return { + ...actual, + useDocLink: () => (path?: string) => `https://docs.example.com${path ?? ''}`, + } +}) + +describe('EditCustomCollectionModal', () => { + const mockOnHide = vi.fn() + const mockOnAdd = vi.fn() + const mockOnEdit = vi.fn() + const mockOnRemove = vi.fn() + const toastNotifySpy = vi.spyOn(Toast, 'notify') + + beforeEach(() => { + vi.clearAllMocks() + toastNotifySpy.mockClear() + parseParamsSchemaMock.mockResolvedValue({ + parameters_schema: [], + schema_type: 'openapi', + }) + mockUseProviderContext.mockReturnValue({ + plan: { + type: Plan.sandbox, + }, + enableBilling: false, + webappCopyrightEnabled: true, + } as ProviderContextState) + }) + + const renderModal = () => render( + , + ) + + it('shows an error when the provider name is missing', async () => { + renderModal() + + const schemaInput = screen.getByPlaceholderText('tools.createTool.schemaPlaceHolder') + fireEvent.change(schemaInput, { target: { value: '{}' } }) + await waitFor(() => { + expect(parseParamsSchemaMock).toHaveBeenCalledWith('{}') + }) + + fireEvent.click(screen.getByText('common.operation.save')) + + await waitFor(() => { + expect(toastNotifySpy).toHaveBeenCalledWith(expect.objectContaining({ + message: 'common.errorMsg.fieldRequired:{"field":"tools.createTool.name"}', + type: 'error', + })) + }) + expect(mockOnAdd).not.toHaveBeenCalled() + }) + + it('shows an error when the schema is missing', async () => { + renderModal() + + const providerInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder') + fireEvent.change(providerInput, { target: { value: 'provider' } }) + + fireEvent.click(screen.getByText('common.operation.save')) + + await waitFor(() => { + expect(toastNotifySpy).toHaveBeenCalledWith(expect.objectContaining({ + message: 'common.errorMsg.fieldRequired:{"field":"tools.createTool.schema"}', + type: 'error', + })) + }) + expect(mockOnAdd).not.toHaveBeenCalled() + }) + + it('saves a valid custom collection', async () => { + renderModal() + const providerInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder') + fireEvent.change(providerInput, { target: { value: 'provider' } }) + + const schemaInput = screen.getByPlaceholderText('tools.createTool.schemaPlaceHolder') + fireEvent.change(schemaInput, { target: { value: '{}' } }) + + await waitFor(() => { + expect(parseParamsSchemaMock).toHaveBeenCalledWith('{}') + }) + + await act(async () => { + fireEvent.click(screen.getByText('common.operation.save')) + }) + + await waitFor(() => { + expect(mockOnAdd).toHaveBeenCalledWith(expect.objectContaining({ + provider: 'provider', + schema: '{}', + schema_type: 'openapi', + credentials: { + auth_type: 'none', + }, + labels: [], + })) + expect(toastNotifySpy).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/tools/edit-custom-collection-modal/test-api.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/test-api.spec.tsx new file mode 100644 index 0000000000..2df967684a --- /dev/null +++ b/web/app/components/tools/edit-custom-collection-modal/test-api.spec.tsx @@ -0,0 +1,87 @@ +import type { CustomCollectionBackend, CustomParamSchema } from '@/app/components/tools/types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { AuthType } from '@/app/components/tools/types' +import I18n from '@/context/i18n' +import { testAPIAvailable } from '@/service/tools' +import TestApi from './test-api' + +vi.mock('@/service/tools', () => ({ + testAPIAvailable: vi.fn(), +})) +const testAPIAvailableMock = vi.mocked(testAPIAvailable) + +describe('TestApi', () => { + const customCollection: CustomCollectionBackend = { + provider: 'custom', + credentials: { + auth_type: AuthType.none, + }, + schema_type: 'openapi', + schema: '{ }', + icon: { background: '', content: '' }, + privacy_policy: '', + custom_disclaimer: '', + id: 'test-id', + labels: [], + } + const tool: CustomParamSchema = { + operation_id: 'testOp', + summary: 'summary', + method: 'GET', + server_url: 'https://api.example.com', + parameters: [{ + name: 'limit', + label: { + en_US: 'Limit', + zh_Hans: '限制', + }, + // eslint-disable-next-line ts/no-explicit-any + } as any], + } + + const renderTestApi = () => { + const providerValue = { + locale: 'en-US', + i18n: {}, + setLocaleOnClient: vi.fn(), + } + return render( + + + , + ) + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders parameters and runs the API test', async () => { + testAPIAvailableMock.mockResolvedValueOnce({ result: 'ok' }) + renderTestApi() + + const parameterInput = screen.getAllByRole('textbox')[0] + fireEvent.change(parameterInput, { target: { value: '5' } }) + fireEvent.click(screen.getByRole('button', { name: 'tools.test.title' })) + + await waitFor(() => { + expect(testAPIAvailableMock).toHaveBeenCalledWith({ + provider_name: customCollection.provider, + tool_name: tool.operation_id, + credentials: { + auth_type: AuthType.none, + }, + schema_type: customCollection.schema_type, + schema: customCollection.schema, + parameters: { + limit: '5', + }, + }) + expect(screen.getByText('ok')).toBeInTheDocument() + }) + }) +}) From 44fc0c614c7c9addaea41068980266d7898813d8 Mon Sep 17 00:00:00 2001 From: lif <1835304752@qq.com> Date: Thu, 25 Dec 2025 19:49:26 +0800 Subject: [PATCH 13/22] fix(web): correct deleted tools matching to use provider_id instead of id (#30138) Signed-off-by: majiayu000 <1835304752@qq.com> --- web/app/components/app/configuration/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/components/app/configuration/index.tsx b/web/app/components/app/configuration/index.tsx index 4b5bcafc9b..9cc9377508 100644 --- a/web/app/components/app/configuration/index.tsx +++ b/web/app/components/app/configuration/index.tsx @@ -679,7 +679,7 @@ const Configuration: FC = () => { const toolInCollectionList = collectionList.find(c => tool.provider_id === c.id) return { ...tool, - isDeleted: res.deleted_tools?.some((deletedTool: any) => deletedTool.id === tool.id && deletedTool.tool_name === tool.tool_name) ?? false, + isDeleted: res.deleted_tools?.some((deletedTool: any) => deletedTool.provider_id === tool.provider_id && deletedTool.tool_name === tool.tool_name) ?? false, notAuthor: toolInCollectionList?.is_team_authorization === false, ...(tool.provider_type === 'builtin' ? { From f08d847c20766960ef836b18a0db4c9bc41fe463 Mon Sep 17 00:00:00 2001 From: Pleasure1234 Date: Thu, 25 Dec 2025 11:50:21 +0000 Subject: [PATCH 14/22] fix: add transparent border to prevent button size flickering (#30128) --- .../components/workflow-header/features-trigger.tsx | 4 ++-- .../components/workflow/header/chat-variable-button.tsx | 4 ++-- web/app/components/workflow/header/env-button.tsx | 4 ++-- .../components/workflow/header/global-variable-button.tsx | 4 ++-- web/app/components/workflow/header/header-in-restoring.tsx | 7 ++++--- .../components/workflow/header/version-history-button.tsx | 4 ++-- 6 files changed, 14 insertions(+), 13 deletions(-) diff --git a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx index 1df6f10195..13fc6a5ce0 100644 --- a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx +++ b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx @@ -188,8 +188,8 @@ const FeaturesTrigger = () => { {isChatMode && (