From d3319153408e170824043f30ef5ae04bcf335808 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Mon, 30 Mar 2026 10:45:28 +0800 Subject: [PATCH] refactor(app-publisher): streamline app publisher component and introduce custom hook for improved state management --- .../app-publisher/__tests__/index.spec.tsx | 208 ++++++++ .../menu-content-actions-section.spec.tsx | 110 ++++ .../menu-content-publish-section.spec.tsx | 70 +++ .../publish-with-multiple-model.spec.tsx | 114 ++++ .../__tests__/use-app-publisher.spec.tsx | 486 ++++++++++++++++++ .../components/app/app-publisher/index.tsx | 359 +++---------- .../app/app-publisher/use-app-publisher.ts | 368 +++++++++++++ .../external-data-tool-modal.spec.tsx | 237 +++++++++ .../tools/__tests__/helpers.spec.ts | 190 +++++++ .../tools/__tests__/index.spec.tsx | 173 +++++++ .../item/__tests__/action-bar.spec.tsx | 181 +++++++ .../item/__tests__/index.spec.tsx | 162 ++++++ .../item/__tests__/result-tab.spec.tsx | 51 ++ .../__tests__/use-generation-item.spec.tsx | 339 ++++++++++++ .../item/__tests__/workflow-content.spec.tsx | 142 +++++ .../app/text-generate/item/index.tsx | 212 ++------ .../text-generate/item/use-generation-item.ts | 308 +++++++++++ .../apps/__tests__/app-card-skeleton.spec.tsx | 28 + ...t-from-marketplace-template-modal.spec.tsx | 329 ++++++++++++ .../plugin-page/__tests__/debug-info.spec.tsx | 116 +++++ .../__tests__/plugins-panel.spec.tsx | 369 +++++++++++++ 21 files changed, 4100 insertions(+), 452 deletions(-) create mode 100644 web/app/components/app/app-publisher/__tests__/index.spec.tsx create mode 100644 web/app/components/app/app-publisher/__tests__/menu-content-actions-section.spec.tsx create mode 100644 web/app/components/app/app-publisher/__tests__/menu-content-publish-section.spec.tsx create mode 100644 web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx create mode 100644 web/app/components/app/app-publisher/__tests__/use-app-publisher.spec.tsx create mode 100644 web/app/components/app/app-publisher/use-app-publisher.ts create mode 100644 web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx create mode 100644 web/app/components/app/configuration/tools/__tests__/helpers.spec.ts create mode 100644 web/app/components/app/configuration/tools/__tests__/index.spec.tsx create mode 100644 web/app/components/app/text-generate/item/__tests__/action-bar.spec.tsx create mode 100644 web/app/components/app/text-generate/item/__tests__/index.spec.tsx create mode 100644 web/app/components/app/text-generate/item/__tests__/result-tab.spec.tsx create mode 100644 web/app/components/app/text-generate/item/__tests__/use-generation-item.spec.tsx create mode 100644 web/app/components/app/text-generate/item/__tests__/workflow-content.spec.tsx create mode 100644 web/app/components/app/text-generate/item/use-generation-item.ts create mode 100644 web/app/components/apps/__tests__/app-card-skeleton.spec.tsx create mode 100644 web/app/components/apps/__tests__/import-from-marketplace-template-modal.spec.tsx create mode 100644 web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx create mode 100644 web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx new file mode 100644 index 0000000000..283cbb4b2b --- /dev/null +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -0,0 +1,208 @@ +import type { AppPublisherMenuContentProps } from '../menu-content.types' +import type { AppDetailResponse } from '@/models/app' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defaultSystemFeatures } from '@/types/feature' +import AppPublisher from '../index' + +const mockMenuContent = vi.fn() +const mockUseAppPublisher = vi.fn() + +vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ + children, + open, + }: { + children: React.ReactNode + open: boolean + }) =>
{children}
, + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: () => void + }) =>
{children}
, + PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('@/app/components/app/overview/embedded', () => ({ + default: ({ + isShow, + onClose, + appBaseUrl, + accessToken, + }: { + isShow: boolean + onClose: () => void + appBaseUrl?: string + accessToken?: string + }) => ( +
+ +
+ ), +})) + +vi.mock('../menu-content', () => ({ + default: (props: AppPublisherMenuContentProps) => { + mockMenuContent(props) + return ( +
+ +
+ ) + }, +})) + +vi.mock('../use-app-publisher', () => ({ + useAppPublisher: (...args: unknown[]) => mockUseAppPublisher(...args), +})) + +vi.mock('../../app-access-control', () => ({ + default: ({ + onClose, + onConfirm, + }: { + onClose: () => void + onConfirm: () => void + }) => ( +
+ + +
+ ), +})) + +const createHookState = () => ({ + accessToken: 'app-token', + appBaseURL: 'https://apps.example.com', + appDetail: { + id: 'app-1', + site: { + access_token: 'app-token', + app_base_url: 'https://apps.example.com', + }, + } as AppDetailResponse | undefined, + appURL: '/apps/app-1', + closeAppAccessControl: vi.fn(), + closeEmbeddingModal: vi.fn(), + crossAxisOffset: 8, + debugWithMultipleModel: false, + disabled: false, + disabledFunctionButton: false, + disabledFunctionTooltip: undefined, + draftUpdatedAt: 5678, + embeddingModalOpen: false, + formatTimeFromNow: (time: number) => `from-now:${time}`, + handleAccessControlUpdate: vi.fn(), + handleOpenEmbedding: vi.fn(), + handleOpenInExplore: vi.fn(), + handlePublish: vi.fn(), + handlePublishToMarketplace: vi.fn(), + handleRestore: vi.fn(), + handleTrigger: vi.fn(), + hasHumanInputNode: false, + hasTriggerNode: false, + inputs: [], + isAppAccessSet: true, + isChatApp: false, + isGettingAppWhiteListSubjects: false, + isGettingUserCanAccessApp: false, + missingStartNode: false, + multipleModelConfigs: [], + onRefreshData: vi.fn(), + open: false, + outputs: [], + publishDisabled: false, + publishLoading: false, + published: false, + publishedAt: 1234, + publishingToMarketplace: false, + setOpen: vi.fn(), + showAppAccessControl: false, + showAppAccessControlModal: vi.fn(), + startNodeLimitExceeded: false, + systemFeatures: defaultSystemFeatures, + toolPublished: false, + upgradeHighlightStyle: { color: 'red' }, + workflowToolAvailable: true, + workflowToolDisabled: false, + workflowToolMessage: undefined, +}) + +describe('AppPublisher', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseAppPublisher.mockReturnValue(createHookState()) + }) + + it('should render the publish trigger and forward state into the menu content', () => { + render() + + const menuContentProps = mockMenuContent.mock.calls[0][0] as AppPublisherMenuContentProps + + expect(screen.getByRole('button', { name: 'workflow.common.publish' })).toBeInTheDocument() + expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false') + expect(menuContentProps).toEqual(expect.objectContaining({ + appURL: '/apps/app-1', + publishedAt: 1234, + workflowToolDisabled: false, + })) + expect(screen.getByTestId('embedded-modal')).toHaveAttribute('data-app-base-url', 'https://apps.example.com') + expect(screen.getByTestId('embedded-modal')).toHaveAttribute('data-access-token', 'app-token') + }) + + it('should invoke the trigger handler when the publish button is clicked', () => { + const state = createHookState() + mockUseAppPublisher.mockReturnValue(state) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.publish' })) + + expect(state.handleTrigger).toHaveBeenCalledTimes(1) + }) + + it('should pass loading-disabled state to the publish button', () => { + const state = createHookState() + state.disabled = true + state.publishLoading = true + mockUseAppPublisher.mockReturnValue(state) + + render() + + expect(screen.getByRole('button', { name: /workflow\.common\.publish/i })).toBeDisabled() + }) + + it('should render access control when requested and wire the overlay callbacks', () => { + const state = createHookState() + state.embeddingModalOpen = true + state.showAppAccessControl = true + mockUseAppPublisher.mockReturnValue(state) + + render() + + expect(screen.getByTestId('embedded-modal')).toHaveAttribute('data-open', 'true') + expect(screen.getByTestId('access-control')).toBeInTheDocument() + + fireEvent.click(screen.getByText('close-embedded')) + fireEvent.click(screen.getByText('confirm-access')) + fireEvent.click(screen.getByText('close-access')) + + expect(state.closeEmbeddingModal).toHaveBeenCalledTimes(1) + expect(state.handleAccessControlUpdate).toHaveBeenCalledTimes(1) + expect(state.closeAppAccessControl).toHaveBeenCalledTimes(1) + }) + + it('should skip rendering access control when the app detail is absent', () => { + const state = createHookState() + state.appDetail = undefined + state.showAppAccessControl = true + mockUseAppPublisher.mockReturnValue(state) + + render() + + expect(screen.queryByTestId('access-control')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/app-publisher/__tests__/menu-content-actions-section.spec.tsx b/web/app/components/app/app-publisher/__tests__/menu-content-actions-section.spec.tsx new file mode 100644 index 0000000000..d1dfaee05c --- /dev/null +++ b/web/app/components/app/app-publisher/__tests__/menu-content-actions-section.spec.tsx @@ -0,0 +1,110 @@ +import type { AppPublisherMenuContentProps } from '../menu-content.types' +import type { AppDetailResponse } from '@/models/app' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { toast } from '@/app/components/base/ui/toast' +import { AppModeEnum } from '@/types/app' +import MenuContentActionsSection from '../menu-content-actions-section' + +const mockWorkflowToolConfigureButton = vi.fn() + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: vi.fn(), + }, +})) + +vi.mock('@/app/components/tools/workflow-tool/configure-button', () => ({ + default: (props: { + detailNeedUpdate: boolean + disabled?: boolean + disabledReason?: string + handlePublish: (params?: unknown) => void + }) => { + mockWorkflowToolConfigureButton(props) + return ( + + ) + }, +})) + +const createAppDetail = (overrides: Partial = {}): AppDetailResponse => ({ + description: 'Workflow description', + icon: '๐Ÿค–', + icon_background: '#ffffff', + icon_type: 'emoji', + id: 'app-1', + mode: AppModeEnum.WORKFLOW, + name: 'Workflow app', + ...overrides, +} as AppDetailResponse) + +const createProps = (overrides: Partial = {}): React.ComponentProps => ({ + appDetail: createAppDetail(), + appURL: '/apps/app-1', + disabledFunctionButton: false, + disabledFunctionTooltip: undefined, + hasHumanInputNode: false, + hasTriggerNode: false, + inputs: [], + missingStartNode: false, + onOpenEmbedding: vi.fn(), + onOpenInExplore: vi.fn(), + onPublish: vi.fn(), + onRefreshData: vi.fn(), + outputs: [], + published: false, + publishedAt: 1234, + toolPublished: false, + workflowToolDisabled: false, + workflowToolMessage: undefined, + ...overrides, +}) + +describe('MenuContentActionsSection', () => { + it('should show a toast when explore is requested before publish and render the embed action for chat apps', async () => { + const user = userEvent.setup() + const onOpenEmbedding = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('workflow.common.openInExplore')) + await user.click(screen.getByText('workflow.common.embedIntoSite')) + + expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('notPublishedYet')) + expect(onOpenEmbedding).not.toHaveBeenCalled() + }) + + it('should forward workflow tool publish requests to onPublish', async () => { + const user = userEvent.setup() + const onPublish = vi.fn().mockResolvedValue(undefined) + + render( + , + ) + + await user.click(screen.getByText('publish-workflow-tool')) + + expect(mockWorkflowToolConfigureButton.mock.calls[0][0]).toEqual(expect.objectContaining({ + detailNeedUpdate: true, + disabled: false, + disabledReason: undefined, + })) + expect(onPublish).toHaveBeenCalledWith({ tool: true }) + }) +}) diff --git a/web/app/components/app/app-publisher/__tests__/menu-content-publish-section.spec.tsx b/web/app/components/app/app-publisher/__tests__/menu-content-publish-section.spec.tsx new file mode 100644 index 0000000000..4a328c0d39 --- /dev/null +++ b/web/app/components/app/app-publisher/__tests__/menu-content-publish-section.spec.tsx @@ -0,0 +1,70 @@ +import type { ModelAndParameter } from '../../configuration/debug/types' +import type { AppPublisherMenuContentProps } from '../menu-content.types' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import MenuContentPublishSection from '../menu-content-publish-section' + +vi.mock('@/app/components/billing/upgrade-btn', () => ({ + default: () =>
upgrade-btn
, +})) + +vi.mock('../../workflow/shortcuts-name', () => ({ + default: () =>
shortcuts-name
, +})) + +vi.mock('../publish-with-multiple-model', () => ({ + default: ({ + multipleModelConfigs, + onSelect, + }: { + multipleModelConfigs: Array<{ id: string }> + onSelect: (item: { id: string }) => void + }) => ( + + ), +})) + +const createProps = (overrides: Partial = {}): React.ComponentProps => ({ + debugWithMultipleModel: false, + draftUpdatedAt: 5678, + formatTimeFromNow: time => `from-now:${time}`, + isChatApp: false, + multipleModelConfigs: [{ + id: 'model-1', + model: 'gpt-4o', + parameters: {}, + provider: 'openai', + }] satisfies ModelAndParameter[], + onPublish: vi.fn(), + onRestore: vi.fn(), + publishDisabled: false, + published: false, + publishedAt: undefined, + publishLoading: false, + startNodeLimitExceeded: false, + upgradeHighlightStyle: { color: 'red' }, + ...overrides, +}) + +describe('MenuContentPublishSection', () => { + it('should forward selected models when multiple-model publishing is enabled', async () => { + const user = userEvent.setup() + const onPublish = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('publish-with-multiple-model')) + + expect(onPublish).toHaveBeenCalledWith({ id: 'model-1' }) + }) +}) diff --git a/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx b/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx new file mode 100644 index 0000000000..a2f89ed513 --- /dev/null +++ b/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx @@ -0,0 +1,114 @@ +import type { ModelAndParameter } from '../../configuration/debug/types' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import PublishWithMultipleModel from '../publish-with-multiple-model' + +let mockTextGenerationModelList: Array<{ + provider: string + models: Array<{ + model: string + label: Record + }> +}> + +vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ + children, + open, + }: { + children: React.ReactNode + open: boolean + }) =>
{children}
, + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: () => void + }) =>
{children}
, + PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useLanguage: () => 'en_US', +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + textGenerationModelList: mockTextGenerationModelList, + }), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-icon', () => ({ + default: ({ modelName }: { modelName: string }) =>
{modelName}
, +})) + +const validConfig: ModelAndParameter = { + id: 'config-1', + model: 'gpt-4o', + provider: 'openai', + parameters: {}, +} + +describe('PublishWithMultipleModel', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTextGenerationModelList = [{ + provider: 'openai', + models: [{ + model: 'gpt-4o', + label: { + en_US: 'GPT-4o', + }, + }], + }] + }) + + it('should disable the button when no valid model configuration matches the provider context', async () => { + const user = userEvent.setup() + + render( + , + ) + + const trigger = screen.getByRole('button', { name: 'appDebug.operation.applyConfig' }) + + expect(trigger).toBeDisabled() + + await user.click(trigger) + + expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false') + }) + + it('should open the model list and forward the selected configuration', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + + render( + , + ) + + const trigger = screen.getByRole('button', { name: 'appDebug.operation.applyConfig' }) + + expect(trigger).not.toBeDisabled() + + await user.click(trigger) + + expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true') + expect(screen.getByText('appDebug.publishAs')).toBeInTheDocument() + expect(screen.getByText('GPT-4o')).toBeInTheDocument() + expect(screen.getByTestId('model-icon-gpt-4o')).toBeInTheDocument() + + await user.click(screen.getByText('GPT-4o')) + + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining(validConfig)) + expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false') + }) +}) diff --git a/web/app/components/app/app-publisher/__tests__/use-app-publisher.spec.tsx b/web/app/components/app/app-publisher/__tests__/use-app-publisher.spec.tsx new file mode 100644 index 0000000000..7ca2e72444 --- /dev/null +++ b/web/app/components/app/app-publisher/__tests__/use-app-publisher.spec.tsx @@ -0,0 +1,486 @@ +import type { AppPublisherProps } from '../index' +import type { AppDetailResponse } from '@/models/app' +import type { SystemFeatures } from '@/types/feature' +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from '@/app/components/base/ui/toast' +import { WorkflowContext } from '@/app/components/workflow/context' +import { AccessMode } from '@/models/access-control' +import { AppModeEnum } from '@/types/app' +import { defaultSystemFeatures } from '@/types/feature' +import { basePath } from '@/utils/var' +import { useAppPublisher } from '../use-app-publisher' + +const mockTrackEvent = vi.fn() +const mockGetSocket = vi.fn() +const mockRefetch = vi.fn() +const mockFetchAppDetailDirect = vi.fn() +const mockFetchInstalledAppList = vi.fn() +const mockFetchPublishedWorkflow = vi.fn() +const mockOpenAsyncWindow = vi.fn() +const mockPublishToCreatorsPlatform = vi.fn() +const mockInvalidateAppWorkflow = vi.fn() +const mockSetAppDetail = vi.fn() +const mockSetPublishedAt = vi.fn() +const mockUnsubscribe = vi.fn() +const mockWindowOpen = vi.fn() + +let capturedShortcut: ((event: { preventDefault: () => void }) => void) | undefined +let capturedPublishUpdate: ((update: { data: { action?: string } }) => void) | undefined +let mockAppDetail: AppDetailResponse | undefined +let mockSystemFeatures: SystemFeatures +let mockAccessSubjects: { groups?: Array<{ id: string }>, members?: Array<{ id: string }> } | undefined +let mockUserCanAccessApp: { result?: boolean } | undefined +let mockGetUserCanAccessAppLoading = false +let mockAppWhiteListSubjectsLoading = false + +vi.mock('ahooks', () => ({ + useKeyPress: (_key: string, callback: (event: { preventDefault: () => void }) => void) => { + capturedShortcut = callback + }, +})) + +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: (...args: unknown[]) => mockTrackEvent(...args), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: vi.fn(), + }, +})) + +vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({ + collaborationManager: { + onAppPublishUpdate: (callback: (update: { data: { action?: string } }) => void) => { + capturedPublishUpdate = callback + return mockUnsubscribe + }, + }, +})) + +vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({ + webSocketClient: { + getSocket: (...args: unknown[]) => mockGetSocket(...args), + }, +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { appDetail?: AppDetailResponse, setAppDetail: typeof mockSetAppDetail }) => unknown) => selector({ + appDetail: mockAppDetail, + setAppDetail: mockSetAppDetail, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: { systemFeatures: SystemFeatures }) => unknown) => selector({ + systemFeatures: mockSystemFeatures, + }), +})) + +vi.mock('@/hooks/use-async-window-open', () => ({ + useAsyncWindowOpen: () => mockOpenAsyncWindow, +})) + +vi.mock('@/hooks/use-format-time-from-now', () => ({ + useFormatTimeFromNow: () => ({ + formatTimeFromNow: (time: number) => `from-now:${time}`, + }), +})) + +vi.mock('@/service/access-control', () => ({ + useAppWhiteListSubjects: () => ({ + data: mockAccessSubjects, + isLoading: mockAppWhiteListSubjectsLoading, + }), + useGetUserCanAccessApp: () => ({ + data: mockUserCanAccessApp, + isLoading: mockGetUserCanAccessAppLoading, + refetch: mockRefetch, + }), +})) + +vi.mock('@/service/apps', () => ({ + fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args), +})) + +vi.mock('@/service/client', () => ({ + consoleClient: { + apps: { + publishToCreatorsPlatform: (...args: unknown[]) => mockPublishToCreatorsPlatform(...args), + }, + }, +})) + +vi.mock('@/service/explore', () => ({ + fetchInstalledAppList: (...args: unknown[]) => mockFetchInstalledAppList(...args), +})) + +vi.mock('@/service/use-workflow', () => ({ + useInvalidateAppWorkflow: () => mockInvalidateAppWorkflow, +})) + +vi.mock('@/service/workflow', () => ({ + fetchPublishedWorkflow: (...args: unknown[]) => mockFetchPublishedWorkflow(...args), +})) + +const createSystemFeatures = (overrides: Partial = {}): SystemFeatures => ({ + ...defaultSystemFeatures, + ...overrides, + branding: { + ...defaultSystemFeatures.branding, + ...overrides.branding, + }, + license: { + ...defaultSystemFeatures.license, + ...overrides.license, + }, + plugin_installation_permission: { + ...defaultSystemFeatures.plugin_installation_permission, + ...overrides.plugin_installation_permission, + }, + webapp_auth: { + ...defaultSystemFeatures.webapp_auth, + ...overrides.webapp_auth, + sso_config: { + ...defaultSystemFeatures.webapp_auth.sso_config, + ...overrides.webapp_auth?.sso_config, + }, + }, +}) + +const createAppDetail = (overrides: Partial = {}): AppDetailResponse => ({ + access_mode: AccessMode.PUBLIC, + description: 'Workflow description', + icon: '๐Ÿค–', + icon_background: '#ffffff', + icon_type: 'emoji', + id: 'app-1', + mode: AppModeEnum.WORKFLOW, + name: 'Workflow app', + site: { + app_base_url: 'https://apps.example.com', + access_token: 'app-token', + }, + ...overrides, +} as AppDetailResponse) + +const createProps = (overrides: Partial = {}): AppPublisherProps => ({ + crossAxisOffset: 12, + debugWithMultipleModel: false, + draftUpdatedAt: 5678, + hasHumanInputNode: false, + hasTriggerNode: false, + inputs: [], + missingStartNode: false, + multipleModelConfigs: [], + onPublish: vi.fn(), + onRefreshData: vi.fn(), + onRestore: vi.fn(), + onToggle: vi.fn(), + outputs: [], + publishDisabled: false, + publishedAt: 1234, + publishLoading: false, + startNodeLimitExceeded: false, + toolPublished: false, + workflowToolAvailable: true, + ...overrides, +}) + +const createWrapper = () => { + const store = { + getState: () => ({ + setPublishedAt: mockSetPublishedAt, + }), + } as unknown as NonNullable> + + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ) +} + +describe('useAppPublisher', () => { + beforeEach(() => { + vi.clearAllMocks() + capturedShortcut = undefined + capturedPublishUpdate = undefined + mockAppDetail = createAppDetail() + mockSystemFeatures = createSystemFeatures() + mockAccessSubjects = { + groups: [{ id: 'group-1' }], + members: [], + } + mockUserCanAccessApp = { + result: true, + } + mockGetUserCanAccessAppLoading = false + mockAppWhiteListSubjectsLoading = false + mockGetSocket.mockReturnValue({ + emit: vi.fn(), + }) + mockOpenAsyncWindow.mockImplementation(async (getUrl: () => Promise, options?: { onError?: (error: Error) => void }) => { + try { + return await getUrl() + } + catch (error) { + options?.onError?.(error as Error) + } + }) + mockPublishToCreatorsPlatform.mockResolvedValue({ + redirect_url: 'https://marketplace.example.com/app-1', + }) + mockFetchInstalledAppList.mockResolvedValue({ + installed_apps: [{ id: 'installed-1' }], + }) + mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ name: 'Updated app' })) + mockFetchPublishedWorkflow.mockResolvedValue({ + created_at: 4321, + }) + window.open = mockWindowOpen as typeof window.open + }) + + it('should expose derived app metadata and default action state', () => { + const { result } = renderHook(() => useAppPublisher(createProps()), { + wrapper: createWrapper(), + }) + + expect(result.current.appURL).toBe(`https://apps.example.com${basePath}/workflow/app-token`) + expect(result.current.isChatApp).toBe(false) + expect(result.current.disabledFunctionButton).toBe(false) + expect(result.current.disabledFunctionTooltip).toBeUndefined() + expect(result.current.workflowToolDisabled).toBe(false) + expect(result.current.workflowToolMessage).toBeUndefined() + expect(result.current.formatTimeFromNow(1234)).toBe('from-now:1234') + }) + + it('should derive access warnings and refetch when the popover opens', async () => { + mockSystemFeatures = createSystemFeatures({ + webapp_auth: { + ...defaultSystemFeatures.webapp_auth, + enabled: true, + }, + }) + mockAppDetail = createAppDetail({ + access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, + }) + mockAccessSubjects = { + groups: [], + members: [], + } + mockUserCanAccessApp = { + result: false, + } + const onToggle = vi.fn() + + const { result } = renderHook(() => useAppPublisher(createProps({ onToggle })), { + wrapper: createWrapper(), + }) + + expect(result.current.isAppAccessSet).toBe(false) + expect(result.current.disabledFunctionButton).toBe(true) + expect(result.current.disabledFunctionTooltip).toBe('app.noAccessPermission') + + act(() => { + result.current.handleTrigger() + }) + + expect(result.current.open).toBe(true) + expect(onToggle).toHaveBeenCalledWith(true) + expect(mockRefetch).toHaveBeenCalledTimes(1) + }) + + it('should publish through the keyboard shortcut and emit collaboration updates', async () => { + const onPublish = vi.fn().mockResolvedValue(undefined) + const emit = vi.fn() + mockGetSocket.mockReturnValue({ emit }) + + const { result } = renderHook(() => useAppPublisher(createProps({ onPublish })), { + wrapper: createWrapper(), + }) + + await act(async () => { + await capturedShortcut?.({ preventDefault: vi.fn() }) + }) + + expect(onPublish).toHaveBeenCalledTimes(1) + expect(result.current.published).toBe(true) + expect(mockInvalidateAppWorkflow).toHaveBeenCalledWith('app-1') + expect(emit).toHaveBeenCalledWith('collaboration_event', expect.objectContaining({ + type: 'app_publish_update', + data: expect.objectContaining({ action: 'published' }), + })) + expect(mockTrackEvent).toHaveBeenCalledWith('app_published_time', expect.objectContaining({ + app_id: 'app-1', + app_name: 'Workflow app', + })) + }) + + it('should keep the menu closed when restore finishes and swallow restore failures', async () => { + const onRestore = vi.fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('restore failed')) + const { result } = renderHook(() => useAppPublisher(createProps({ onRestore })), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.handleTrigger() + }) + + await act(async () => { + await result.current.handleRestore() + }) + + expect(result.current.open).toBe(false) + + act(() => { + result.current.handleTrigger() + }) + + await act(async () => { + await result.current.handleRestore() + }) + + expect(result.current.open).toBe(true) + }) + + it('should open the embedding modal, refresh app access, and close both overlays', async () => { + const { result } = renderHook(() => useAppPublisher(createProps()), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.handleTrigger() + }) + + act(() => { + result.current.handleOpenEmbedding() + }) + + expect(result.current.embeddingModalOpen).toBe(true) + expect(result.current.open).toBe(false) + + act(() => { + result.current.showAppAccessControlModal() + }) + + expect(result.current.showAppAccessControl).toBe(true) + + await act(async () => { + await result.current.handleAccessControlUpdate() + }) + + expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) + expect(mockSetAppDetail).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated app' })) + expect(result.current.showAppAccessControl).toBe(false) + + act(() => { + result.current.closeEmbeddingModal() + result.current.closeAppAccessControl() + }) + + expect(result.current.embeddingModalOpen).toBe(false) + expect(result.current.showAppAccessControl).toBe(false) + }) + + it('should resolve the explore URL and surface window-open errors via toast', async () => { + const { result } = renderHook(() => useAppPublisher(createProps()), { + wrapper: createWrapper(), + }) + + await act(async () => { + await result.current.handleOpenInExplore() + }) + + expect(mockFetchInstalledAppList).toHaveBeenCalledWith('app-1') + + mockFetchInstalledAppList.mockResolvedValueOnce({ installed_apps: [] }) + + await act(async () => { + await result.current.handleOpenInExplore() + }) + + expect(toast.error).toHaveBeenCalledWith('No app found in Explore') + }) + + it('should publish to marketplace and reset loading after failures', async () => { + const { result } = renderHook(() => useAppPublisher(createProps()), { + wrapper: createWrapper(), + }) + + await act(async () => { + await result.current.handlePublishToMarketplace() + }) + + expect(mockPublishToCreatorsPlatform).toHaveBeenCalledWith({ + params: { appId: 'app-1' }, + }) + expect(mockWindowOpen).toHaveBeenCalledWith('https://marketplace.example.com/app-1', '_blank') + expect(result.current.publishingToMarketplace).toBe(false) + + mockPublishToCreatorsPlatform.mockRejectedValueOnce(new Error('publish failed')) + + await act(async () => { + await result.current.handlePublishToMarketplace() + }) + + expect(toast.error).toHaveBeenCalledWith('publish failed') + expect(result.current.publishingToMarketplace).toBe(false) + }) + + it('should ignore marketplace publishing when the app id is missing', async () => { + mockAppDetail = createAppDetail({ id: '' }) + + const { result } = renderHook(() => useAppPublisher(createProps()), { + wrapper: createWrapper(), + }) + + await act(async () => { + await result.current.handlePublishToMarketplace() + }) + + expect(mockPublishToCreatorsPlatform).not.toHaveBeenCalled() + }) + + it('should refresh published workflow timestamps from collaboration events and unsubscribe on unmount', async () => { + const { unmount } = renderHook(() => useAppPublisher(createProps()), { + wrapper: createWrapper(), + }) + + await act(async () => { + capturedPublishUpdate?.({ data: { action: 'published' } }) + }) + + await waitFor(() => { + expect(mockFetchPublishedWorkflow).toHaveBeenCalledWith('/apps/app-1/workflows/publish') + }) + + expect(mockSetPublishedAt).toHaveBeenCalledWith(4321) + + unmount() + + expect(mockUnsubscribe).toHaveBeenCalledTimes(1) + }) + + it('should warn when publish succeeds without an app id or socket', async () => { + mockAppDetail = createAppDetail({ id: '' }) + mockGetSocket.mockReturnValue(null) + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { result } = renderHook(() => useAppPublisher(createProps()), { + wrapper: createWrapper(), + }) + + await act(async () => { + await result.current.handlePublish() + }) + + expect(mockInvalidateAppWorkflow).not.toHaveBeenCalled() + expect(consoleWarnSpy).toHaveBeenCalledWith('[app-publisher] missing appId, skip workflow invalidate and socket emit') + + consoleWarnSpy.mockRestore() + }) +}) diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index fc23fbe080..baa17ac659 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -1,51 +1,18 @@ import type { ModelAndParameter } from '../configuration/debug/types' -import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration' import type { InputVar, Variable } from '@/app/components/workflow/types' -import type { InstalledApp } from '@/models/explore' import type { PublishWorkflowParams } from '@/types/workflow' - -import { useKeyPress } from 'ahooks' -import { - memo, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from 'react' +import { memo } from 'react' import { useTranslation } from 'react-i18next' import EmbeddedModal from '@/app/components/app/overview/embedded' -import { useStore as useAppStore } from '@/app/components/app/store' -import { trackEvent } from '@/app/components/base/amplitude' import Button from '@/app/components/base/button' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { toast } from '@/app/components/base/ui/toast' -import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' -import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' -import { WorkflowContext } from '@/app/components/workflow/context' -import { useGlobalPublicStore } from '@/context/global-public-context' -import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' -import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' -import { AccessMode } from '@/models/access-control' -import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control' -import { fetchAppDetailDirect } from '@/service/apps' -import { consoleClient } from '@/service/client' -import { fetchInstalledAppList } from '@/service/explore' -import { useInvalidateAppWorkflow } from '@/service/use-workflow' -import { fetchPublishedWorkflow } from '@/service/workflow' -import { AppModeEnum } from '@/types/app' -import { basePath } from '@/utils/var' -import { getKeyboardKeyCodeBySystem } from '../../workflow/utils' import AccessControl from '../app-access-control' import AppPublisherMenuContent from './menu-content' - -type InstalledAppsResponse = { - installed_apps?: InstalledApp[] -} +import { useAppPublisher } from './use-app-publisher' export type AppPublisherProps = { disabled?: boolean @@ -95,219 +62,46 @@ const AppPublisher = ({ hasHumanInputNode = false, }: AppPublisherProps) => { const { t } = useTranslation() - - const [published, setPublished] = useState(false) - const [open, setOpen] = useState(false) - const [showAppAccessControl, setShowAppAccessControl] = useState(false) - - const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) - const [publishingToMarketplace, setPublishingToMarketplace] = useState(false) - - const workflowStore = useContext(WorkflowContext) - const appDetail = useAppStore(state => state.appDetail) - const setAppDetail = useAppStore(s => s.setAppDetail) - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) - const { formatTimeFromNow } = useFormatTimeFromNow() - const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {} - - const appMode = (appDetail?.mode !== AppModeEnum.COMPLETION && appDetail?.mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : appDetail.mode - const appURL = `${appBaseURL}${basePath}/${appMode}/${accessToken}` - const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT) - - const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false }) - const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS) - const invalidateAppWorkflow = useInvalidateAppWorkflow() - const openAsyncWindow = useAsyncWindowOpen() - - const isAppAccessSet = useMemo(() => { - if (appDetail && appAccessSubjects) { - return !(appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0) - } - return true - }, [appAccessSubjects, appDetail]) - - const noAccessPermission = useMemo(() => Boolean( - systemFeatures.webapp_auth.enabled - && appDetail - && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS - && !userCanAccessApp?.result, - ), [systemFeatures, appDetail, userCanAccessApp]) - const disabledFunctionButton = useMemo(() => (!publishedAt || missingStartNode || noAccessPermission), [publishedAt, missingStartNode, noAccessPermission]) - - const disabledFunctionTooltip = useMemo(() => { - if (!publishedAt) - return t('notPublishedYet', { ns: 'app' }) - if (missingStartNode) - return t('noUserInputNode', { ns: 'app' }) - if (noAccessPermission) - return t('noAccessPermission', { ns: 'app' }) - }, [missingStartNode, noAccessPermission, publishedAt, t]) - - useEffect(() => { - if (systemFeatures.webapp_auth.enabled && open && appDetail) - refetch() - }, [open, appDetail, refetch, systemFeatures]) - - const handlePublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => { - try { - await onPublish?.(params) - setPublished(true) - - const appId = appDetail?.id - const socket = appId ? webSocketClient.getSocket(appId) : null - if (appId) - invalidateAppWorkflow(appId) - else - console.warn('[app-publisher] missing appId, skip workflow invalidate and socket emit') - if (socket) { - const timestamp = Date.now() - socket.emit('collaboration_event', { - type: 'app_publish_update', - data: { - action: 'published', - timestamp, - }, - timestamp, - }) - } - else if (appId) { - console.warn('[app-publisher] socket not ready, skip collaboration_event emit', { appId }) - } - - trackEvent('app_published_time', { action_mode: 'app', app_id: appDetail?.id, app_name: appDetail?.name }) - } - catch (error) { - console.warn('[app-publisher] publish failed', error) - setPublished(false) - } - }, [appDetail, onPublish, invalidateAppWorkflow]) - - const handleRestore = useCallback(async () => { - try { - await onRestore?.() - setOpen(false) - } - catch { } - }, [onRestore]) - - const handleTrigger = useCallback(() => { - const state = !open - - if (disabled) { - setOpen(false) - return - } - - onToggle?.(state) - setOpen(state) - - if (state) - setPublished(false) - }, [disabled, onToggle, open]) - - const handleOpenInExplore = useCallback(async () => { - await openAsyncWindow(async () => { - if (!appDetail?.id) - throw new Error('App not found') - const response = (await fetchInstalledAppList(appDetail?.id)) as InstalledAppsResponse - const installedApps = response?.installed_apps - if (installedApps?.length) - return `${basePath}/explore/installed/${installedApps[0].id}` - throw new Error('No app found in Explore') - }, { - onError: (err) => { - toast.error(`${err.message || err}`) - }, - }) - }, [appDetail?.id, openAsyncWindow]) - - const handleAccessControlUpdate = useCallback(async () => { - if (!appDetail) - return - try { - const res = await fetchAppDetailDirect({ url: '/apps', id: appDetail.id }) - setAppDetail(res) - } - finally { - setShowAppAccessControl(false) - } - }, [appDetail, setAppDetail]) - - const handlePublishToMarketplace = useCallback(async () => { - if (!appDetail?.id || publishingToMarketplace) - return - setPublishingToMarketplace(true) - try { - const result = await consoleClient.apps.publishToCreatorsPlatform({ - params: { appId: appDetail.id }, - }) - window.open(result.redirect_url, '_blank') - } - catch (error: any) { - toast.error(error.message || t('common.publishToMarketplaceFailed', { ns: 'workflow' })) - } - finally { - setPublishingToMarketplace(false) - } - }, [appDetail?.id, publishingToMarketplace, t]) - - useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => { - e.preventDefault() - if (publishDisabled || published || publishLoading) - return - handlePublish() - }, { exactMatch: true, useCapture: true }) - - useEffect(() => { - const appId = appDetail?.id - if (!appId) - return - - const unsubscribe = collaborationManager.onAppPublishUpdate((update: CollaborationUpdate) => { - const action = typeof update.data.action === 'string' ? update.data.action : undefined - if (action === 'published') { - invalidateAppWorkflow(appId) - fetchPublishedWorkflow(`/apps/${appId}/workflows/publish`) - .then((publishedWorkflow) => { - if (publishedWorkflow?.created_at) - workflowStore?.getState().setPublishedAt(publishedWorkflow.created_at) - }) - .catch((error) => { - console.warn('[app-publisher] refresh published workflow failed', error) - }) - } - }) - - return unsubscribe - }, [appDetail?.id, invalidateAppWorkflow, workflowStore]) - - const hasPublishedVersion = !!publishedAt - const workflowToolDisabled = !hasPublishedVersion || !workflowToolAvailable - const workflowToolMessage = workflowToolDisabled ? t('common.workflowAsToolDisabledHint', { ns: 'workflow' }) : undefined - const upgradeHighlightStyle = useMemo(() => ({ - background: 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)', - WebkitBackgroundClip: 'text', - backgroundClip: 'text', - WebkitTextFillColor: 'transparent', - }), []) + const state = useAppPublisher({ + disabled, + publishDisabled, + publishedAt, + draftUpdatedAt, + debugWithMultipleModel, + multipleModelConfigs, + onPublish, + onRestore, + onToggle, + crossAxisOffset, + toolPublished, + inputs, + outputs, + onRefreshData, + workflowToolAvailable, + missingStartNode, + hasTriggerNode, + startNodeLimitExceeded, + publishLoading, + hasHumanInputNode, + }) return ( <> - + + ), +})) + +vi.mock('@/app/components/base/emoji-picker', () => ({ + default: ({ + onClose, + onSelect, + }: { + onClose: () => void + onSelect: (icon: string, iconBackground: string) => void + }) => ( +
+ + +
+ ), +})) + +vi.mock('@/app/components/base/features/new-feature-panel/moderation/form-generation', () => ({ + default: ({ + onChange, + }: { + onChange: (value: Record) => void + }) => , +})) + +vi.mock('@/app/components/base/ui/dialog', () => ({ + Dialog: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('@/app/components/base/ui/select', () => ({ + Select: ({ + children, + onValueChange, + }: { + children: React.ReactNode + onValueChange?: (value: string) => void + }) => ( +
+ {children} + + +
+ ), + SelectContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectItem: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectTrigger: ({ + children, + ...props + }: React.ButtonHTMLAttributes) => , + SelectValue: () => select-value, +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: vi.fn(), + }, +})) + +vi.mock('@/app/components/header/account-setting/api-based-extension-page/selector', () => ({ + default: ({ onChange }: { onChange: (value: string) => void }) => ( + + ), +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.example.com${path}`, + useLocale: () => mockLocale, +})) + +vi.mock('@/service/use-common', () => ({ + useCodeBasedExtensions: () => ({ + data: { + data: [{ + form_schema: [{ + default: 'default-region', + label: { + 'en-US': 'Region', + 'zh-Hans': 'ๅœฐๅŒบ', + }, + required: true, + variable: 'region', + }], + label: { + 'en-US': 'Custom Tool', + 'zh-Hans': '่‡ชๅฎšไน‰ๅทฅๅ…ท', + }, + name: 'custom-tool', + }], + }, + }), +})) + +describe('ExternalDataToolModal', () => { + beforeEach(() => { + vi.clearAllMocks() + mockLocale = 'en-US' + }) + + it('should save api-based tools after validating the selected extension', async () => { + const user = userEvent.setup() + const onSave = vi.fn() + const onValidateBeforeSave = vi.fn().mockReturnValue(true) + + render( + , + ) + + expect(screen.getByRole('link', { name: 'common.apiBasedExtension.link' })).toHaveAttribute( + 'href', + 'https://docs.example.com/use-dify/workspace/api-extension/api-extension', + ) + + await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.name.placeholder'), 'Search tool') + await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.variableName.placeholder'), 'search_tool') + await user.click(screen.getByText('select-api-extension')) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(onValidateBeforeSave).toHaveBeenCalledWith(expect.objectContaining({ + config: { + api_based_extension_id: 'extension-1', + }, + enabled: true, + label: 'Search tool', + type: 'api', + variable: 'search_tool', + })) + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + config: { + api_based_extension_id: 'extension-1', + }, + enabled: true, + label: 'Search tool', + type: 'api', + variable: 'search_tool', + })) + }) + + it('should reject invalid variable names before save', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.name.placeholder'), 'Search tool') + await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.variableName.placeholder'), 'invalid-key!') + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('notValid')) + }) + + it('should allow selecting emojis and saving custom providers with generated form data', async () => { + const user = userEvent.setup() + const onSave = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('select-custom-tool')) + await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.name.placeholder'), 'Custom tool') + await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.variableName.placeholder'), 'custom_tool') + await user.click(screen.getByTestId('app-icon')) + await user.click(screen.getByText('select-emoji')) + await user.click(screen.getByText('fill-form')) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + config: { + region: 'us', + }, + enabled: true, + icon: '๐Ÿ˜Ž', + icon_background: '#000000', + type: 'custom-tool', + variable: 'custom_tool', + })) + }) + + it('should stop before saving when the caller rejects the formatted payload', async () => { + const user = userEvent.setup() + const onSave = vi.fn() + const onValidateBeforeSave = vi.fn().mockReturnValue(false) + + render( + , + ) + + await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.name.placeholder'), 'Search tool') + await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.variableName.placeholder'), 'search_tool') + await user.click(screen.getByText('select-api-extension')) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(onValidateBeforeSave).toHaveBeenCalledTimes(1) + expect(onSave).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/app/configuration/tools/__tests__/helpers.spec.ts b/web/app/components/app/configuration/tools/__tests__/helpers.spec.ts new file mode 100644 index 0000000000..84f35b9f86 --- /dev/null +++ b/web/app/components/app/configuration/tools/__tests__/helpers.spec.ts @@ -0,0 +1,190 @@ +import type { ExternalDataToolProvider } from '../helpers' +import type { CodeBasedExtensionForm, ExternalDataTool } from '@/models/common' +import { describe, expect, it } from 'vitest' +import { LanguagesSupported } from '@/i18n-config/language' +import { + + findExternalDataToolVariableConflict, + formatExternalDataToolForSave, + getExternalDataToolDefaultConfig, + getExternalDataToolValidationError, + getInitialExternalDataTool, + removeExternalDataTool, + upsertExternalDataTool, +} from '../helpers' + +const createProviderField = (overrides: Partial = {}): CodeBasedExtensionForm => ({ + default: 'default-region', + label: { + 'en-US': 'Region', + 'zh-Hans': 'ๅœฐๅŒบ', + } as CodeBasedExtensionForm['label'], + options: [], + placeholder: '', + required: true, + type: 'text-input', + variable: 'region', + ...overrides, +}) + +const provider: ExternalDataToolProvider = { + key: 'custom-tool', + name: 'Custom tool', + form_schema: [createProviderField()], +} + +const createTool = (overrides: Partial = {}): ExternalDataTool => ({ + config: {}, + icon: '๐Ÿค–', + icon_background: '#fff', + label: 'External tool', + type: 'api', + variable: 'tool_var', + ...overrides, +}) + +describe('configuration/tools/helpers', () => { + it('should initialize new tools with the api type', () => { + expect(getInitialExternalDataTool({} as ExternalDataTool)).toEqual({ + type: 'api', + }) + expect(getInitialExternalDataTool(createTool({ type: 'custom-tool' }))).toEqual(createTool({ type: 'custom-tool' })) + }) + + it('should derive default configs from non-system providers only', () => { + expect(getExternalDataToolDefaultConfig('api', [provider])).toBeUndefined() + expect(getExternalDataToolDefaultConfig('custom-tool', [provider])).toEqual({ + region: 'default-region', + }) + expect(getExternalDataToolDefaultConfig('missing-provider', [provider])).toBeUndefined() + }) + + it('should upsert and remove external data tools by index', () => { + const first = createTool({ label: 'First' }) + const second = createTool({ label: 'Second', variable: 'second_var' }) + + expect(upsertExternalDataTool([first], second, -1)).toEqual([first, second]) + expect(upsertExternalDataTool([first, second], createTool({ label: 'Updated second', variable: 'second_var' }), 1)).toEqual([ + first, + createTool({ label: 'Updated second', variable: 'second_var' }), + ]) + expect(removeExternalDataTool([first, second], 0)).toEqual([second]) + }) + + it('should detect conflicts with prompt variables and other external tools', () => { + const existing = [ + createTool(), + createTool({ label: 'Second', variable: 'second_var' }), + ] + + expect(findExternalDataToolVariableConflict(undefined, existing, [], -1)).toBeUndefined() + expect(findExternalDataToolVariableConflict('prompt_var', existing, [{ key: 'prompt_var' }], -1)).toBe('prompt_var') + expect(findExternalDataToolVariableConflict('second_var', existing, [], -1)).toBe('second_var') + expect(findExternalDataToolVariableConflict('second_var', existing, [], 1)).toBeUndefined() + }) + + it('should format api and custom tools for save', () => { + const apiTool = createTool({ + config: { + api_based_extension_id: 'extension-1', + region: 'ignored', + }, + }) + + const customTool = createTool({ + config: { + region: 'us', + ignored: 'value', + }, + type: 'custom-tool', + }) + + expect(formatExternalDataToolForSave(apiTool, undefined, true)).toEqual(expect.objectContaining({ + enabled: true, + config: { + api_based_extension_id: 'extension-1', + }, + })) + + expect(formatExternalDataToolForSave(customTool, provider, false)).toEqual(expect.objectContaining({ + enabled: false, + config: { + region: 'us', + }, + })) + }) + + it('should return required and invalid validation errors', () => { + expect(getExternalDataToolValidationError({ + localeData: createTool({ type: '' }), + currentProvider: undefined, + locale: 'en-US', + })).toEqual({ + kind: 'required', + label: 'feature.tools.modal.toolType.title', + }) + + expect(getExternalDataToolValidationError({ + localeData: createTool({ label: '' }), + currentProvider: undefined, + locale: 'en-US', + })).toEqual({ + kind: 'required', + label: 'feature.tools.modal.name.title', + }) + + expect(getExternalDataToolValidationError({ + localeData: createTool({ variable: '' }), + currentProvider: undefined, + locale: 'en-US', + })).toEqual({ + kind: 'required', + label: 'feature.tools.modal.variableName.title', + }) + + expect(getExternalDataToolValidationError({ + localeData: createTool({ variable: 'invalid-key!' }), + currentProvider: undefined, + locale: 'en-US', + })).toEqual({ + kind: 'invalid', + label: 'feature.tools.modal.variableName.title', + }) + }) + + it('should validate required provider fields using the current locale', () => { + expect(getExternalDataToolValidationError({ + localeData: createTool({ + config: {}, + }), + currentProvider: undefined, + locale: LanguagesSupported[1], + })).toEqual({ + kind: 'required', + label: 'API ๆ‰ฉๅฑ•', + }) + + expect(getExternalDataToolValidationError({ + localeData: createTool({ + config: {}, + type: 'custom-tool', + }), + currentProvider: provider, + locale: LanguagesSupported[1], + })).toEqual({ + kind: 'required', + label: 'ๅœฐๅŒบ', + }) + + expect(getExternalDataToolValidationError({ + localeData: createTool({ + config: { + region: 'us', + }, + type: 'custom-tool', + }), + currentProvider: provider, + locale: 'en-US', + })).toBeNull() + }) +}) diff --git a/web/app/components/app/configuration/tools/__tests__/index.spec.tsx b/web/app/components/app/configuration/tools/__tests__/index.spec.tsx new file mode 100644 index 0000000000..144c891fa1 --- /dev/null +++ b/web/app/components/app/configuration/tools/__tests__/index.spec.tsx @@ -0,0 +1,173 @@ +import type { ExternalDataTool } from '@/models/common' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from '@/app/components/base/ui/toast' +import Tools from '../index' + +const mockCopy = vi.fn() +const mockSetExternalDataToolsConfig = vi.fn() +const mockSetShowExternalDataToolModal = vi.fn() + +let mockConfigContext: { + externalDataToolsConfig: ExternalDataTool[] + modelConfig: { + configs?: { + prompt_variables?: Array<{ key: string }> + } + } + setExternalDataToolsConfig: typeof mockSetExternalDataToolsConfig +} + +vi.mock('copy-to-clipboard', () => ({ + default: (...args: unknown[]) => mockCopy(...args), +})) + +vi.mock('use-context-selector', async () => { + const actual = await vi.importActual('use-context-selector') + return { + ...actual, + useContext: () => mockConfigContext, + } +}) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowExternalDataToolModal: mockSetShowExternalDataToolModal, + }), +})) + +vi.mock('@/app/components/base/app-icon', () => ({ + default: ({ icon }: { icon?: string }) =>
{icon || 'icon'}
, +})) + +vi.mock('@/app/components/base/switch', () => ({ + default: ({ + value, + onChange, + }: { + value: boolean + onChange: (value: boolean) => void + }) => ( + + ), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: vi.fn(), + }, +})) + +vi.mock('@/app/components/base/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipTrigger: ({ + render, + }: { + render: React.ReactNode + }) => <>{render}, +})) + +vi.mock('@remixicon/react', () => ({ + RiAddLine: () => add-icon, + RiArrowDownSLine: () => arrow-icon, + RiDeleteBinLine: () => delete-icon, +})) + +vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({ + Settings01: () => settings-icon, +})) + +vi.mock('@/app/components/base/icons/src/vender/solid/general', () => ({ + Tool03: () => tool-icon, +})) + +const createTool = (overrides: Partial = {}): ExternalDataTool => ({ + config: { + api_based_extension_id: 'extension-1', + }, + enabled: false, + icon: '๐Ÿค–', + icon_background: '#fff', + label: 'External tool', + type: 'api', + variable: 'tool_var', + ...overrides, +}) + +describe('configuration/tools/index', () => { + beforeEach(() => { + vi.clearAllMocks() + mockConfigContext = { + externalDataToolsConfig: [], + modelConfig: { + configs: { + prompt_variables: [], + }, + }, + setExternalDataToolsConfig: mockSetExternalDataToolsConfig, + } + }) + + it('should open the add-tool modal and reject prompt-variable conflicts before save', async () => { + const user = userEvent.setup() + mockConfigContext.modelConfig.configs!.prompt_variables = [{ key: 'prompt_var' }] + + render() + + await user.click(screen.getByText('common.operation.add')) + + const modalPayload = mockSetShowExternalDataToolModal.mock.calls[0][0] + + expect(modalPayload.payload).toEqual({}) + expect(modalPayload.onValidateBeforeSaveCallback(createTool({ variable: 'prompt_var' }))).toBe(false) + expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('prompt_var')) + }) + + it('should save, copy, edit, delete, and toggle configured tools', async () => { + const user = userEvent.setup() + const existingTool = createTool() + mockConfigContext.externalDataToolsConfig = [existingTool] + + const { container } = render() + + await user.click(screen.getByText('common.operation.add')) + + const modalPayload = mockSetShowExternalDataToolModal.mock.calls[0][0] + modalPayload.onSaveCallback(createTool({ label: 'New tool', variable: 'new_var' })) + + expect(mockSetExternalDataToolsConfig).toHaveBeenCalledWith([ + existingTool, + createTool({ label: 'New tool', variable: 'new_var' }), + ]) + + await user.click(screen.getByText('tool_var')) + + expect(mockCopy).toHaveBeenCalledWith('tool_var') + expect(screen.getByText('appApi.copied')).toBeInTheDocument() + + await user.click(screen.getByText('settings-icon')) + + const editPayload = mockSetShowExternalDataToolModal.mock.calls[1][0] + expect(editPayload.payload).toEqual(existingTool) + expect(editPayload.onValidateBeforeSaveCallback(createTool({ variable: 'new_var' }))).toBe(true) + + await user.click(screen.getByText('delete-icon')) + + expect(mockSetExternalDataToolsConfig).toHaveBeenCalledWith([]) + + await user.click(screen.getByText('switch-off')) + + expect(mockSetExternalDataToolsConfig).toHaveBeenCalledWith([ + createTool({ enabled: true }), + ]) + + await user.click(container.querySelector('.group') as HTMLElement) + + expect(screen.queryByText('External tool')).not.toBeInTheDocument() + expect(screen.getByText(/appDebug\.feature\.tools\.toolsInUse/)).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/text-generate/item/__tests__/action-bar.spec.tsx b/web/app/components/app/text-generate/item/__tests__/action-bar.spec.tsx new file mode 100644 index 0000000000..e93687dcdf --- /dev/null +++ b/web/app/components/app/text-generate/item/__tests__/action-bar.spec.tsx @@ -0,0 +1,181 @@ +import type { FeedbackType } from '@/app/components/base/chat/chat/type' +import type { WorkflowProcess } from '@/app/components/base/chat/types' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { WorkflowRunningStatus } from '@/app/components/workflow/types' +import { AppSourceType } from '@/service/share' +import GenerationItemActionBar from '../action-bar' + +vi.mock('@/app/components/base/action-button', () => ({ + default: ({ + children, + disabled, + onClick, + state, + }: { + children: React.ReactNode + disabled?: boolean + onClick?: () => void + state?: string + }) => ( + + ), + ActionButtonState: { + Active: 'active', + Default: 'default', + Destructive: 'destructive', + Disabled: 'disabled', + }, +})) + +vi.mock('@/app/components/base/new-audio-button', () => ({ + default: ({ id, voice }: { id: string, voice?: string }) =>
{`audio:${id}:${voice || ''}`}
, +})) + +vi.mock('@remixicon/react', () => ({ + RiBookmark3Line: () => bookmark-icon, + RiClipboardLine: () => copy-icon, + RiFileList3Line: () => log-icon, + RiResetLeftLine: () => retry-icon, + RiSparklingLine: () => more-like-this-icon, + RiThumbDownLine: () => thumb-down-icon, + RiThumbUpLine: () => thumb-up-icon, +})) + +const createWorkflowProcessData = (overrides: Partial = {}): WorkflowProcess => ({ + status: WorkflowRunningStatus.Succeeded, + tracing: [], + ...overrides, +}) + +const createProps = (overrides: Partial> = {}): React.ComponentProps => ({ + appSourceType: AppSourceType.webApp, + currentTab: 'RESULT', + depth: 1, + feedback: { rating: null } as FeedbackType, + isError: false, + isInWebApp: true, + isResponding: false, + isShowTextToSpeech: true, + isTryApp: false, + isWorkflow: false, + messageId: 'message-1', + moreLikeThis: true, + onCopy: vi.fn(), + onFeedback: vi.fn(), + onMoreLikeThis: vi.fn(), + onOpenLogModal: vi.fn(), + onRetry: vi.fn(), + onSave: vi.fn(), + supportFeedback: true, + voice: 'alloy', + workflowProcessData: createWorkflowProcessData({ resultText: 'done' }), + ...overrides, +}) + +describe('GenerationItemActionBar', () => { + it('should render non-web log actions and invoke the corresponding handlers', async () => { + const user = userEvent.setup() + const onOpenLogModal = vi.fn() + const onCopy = vi.fn() + const onMoreLikeThis = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('log-icon')) + await user.click(screen.getByText('more-like-this-icon')) + await user.click(screen.getByText('copy-icon')) + + expect(onOpenLogModal).toHaveBeenCalledTimes(1) + expect(onMoreLikeThis).toHaveBeenCalledTimes(1) + expect(onCopy).toHaveBeenCalledTimes(1) + expect(screen.getByText('audio:message-1:alloy')).toBeInTheDocument() + }) + + it('should render retry, save, and feedback controls for web apps', async () => { + const user = userEvent.setup() + const onRetry = vi.fn() + const onSave = vi.fn() + const onFeedback = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('retry-icon')) + await user.click(screen.getByText('bookmark-icon')) + + expect(onRetry).toHaveBeenCalledTimes(1) + expect(onSave).not.toHaveBeenCalled() + + render() + + await user.click(screen.getAllByText('bookmark-icon').at(-1)!) + + expect(onSave).toHaveBeenCalledWith('message-1') + + render() + + await user.click(screen.getAllByText('thumb-up-icon').at(-1)!) + await user.click(screen.getAllByText('thumb-down-icon').at(-1)!) + + expect(onFeedback).toHaveBeenCalledWith({ rating: 'like' }) + expect(onFeedback).toHaveBeenCalledWith({ rating: 'dislike' }) + }) + + it('should disable more-like-this at the maximum depth and render active feedback state toggles', async () => { + const user = userEvent.setup() + const onFeedback = vi.fn() + + render( + , + ) + + expect(screen.getByText('more-like-this-icon').closest('button')).toBeDisabled() + + await user.click(screen.getByText('thumb-up-icon')) + + expect(onFeedback).toHaveBeenCalledWith({ rating: null }) + + render( + , + ) + + await user.click(screen.getAllByText('thumb-down-icon')[0]) + + expect(onFeedback).toHaveBeenCalledWith({ rating: null }) + expect(screen.getByText('appDebug.errorMessage.waitForResponse')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/text-generate/item/__tests__/index.spec.tsx b/web/app/components/app/text-generate/item/__tests__/index.spec.tsx new file mode 100644 index 0000000000..8f555b689e --- /dev/null +++ b/web/app/components/app/text-generate/item/__tests__/index.spec.tsx @@ -0,0 +1,162 @@ +import type { FeedbackType } from '@/app/components/base/chat/chat/type' +import type { WorkflowProcess } from '@/app/components/base/chat/types' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { WorkflowRunningStatus } from '@/app/components/workflow/types' +import { AppSourceType } from '@/service/share' +import GenerationItem from '../index' + +const mockActionBar = vi.fn() +const mockUseGenerationItem = vi.fn() + +vi.mock('@/app/components/base/loading', () => ({ + default: ({ type }: { type?: string }) =>
{`loading:${type || 'default'}`}
, +})) + +vi.mock('@/app/components/base/markdown', () => ({ + Markdown: ({ content }: { content: string }) =>
{`markdown:${content}`}
, +})) + +vi.mock('../action-bar', () => ({ + default: (props: Record) => { + mockActionBar(props) + return
{`action-bar:${String(props.messageId)}`}
+ }, +})) + +vi.mock('../workflow-content', () => ({ + default: ({ + currentTab, + taskId, + }: { + currentTab: string + taskId?: string + }) =>
{`workflow-content:${currentTab}:${taskId || ''}`}
, +})) + +vi.mock('../use-generation-item', () => ({ + useGenerationItem: (...args: unknown[]) => mockUseGenerationItem(...args), +})) + +const createWorkflowProcessData = (overrides: Partial = {}): WorkflowProcess => ({ + status: WorkflowRunningStatus.Succeeded, + tracing: [], + ...overrides, +}) + +const createHookState = (overrides: Record = {}) => ({ + childMessageId: null, + childProps: { + appSourceType: AppSourceType.webApp, + content: 'child content', + depth: 2, + isError: false, + onRetry: vi.fn(), + siteInfo: null, + }, + config: { + text_to_speech: { + voice: 'alloy', + }, + }, + completionRes: '', + currentTab: 'RESULT', + handleCopy: vi.fn(), + handleMoreLikeThis: vi.fn(), + handleOpenLogModal: vi.fn(), + handleSubmitHumanInputForm: vi.fn(), + isQuerying: false, + isTop: true, + isTryApp: false, + setCurrentTab: vi.fn(), + showChildItem: false, + taskLabel: 'task-1', + ...overrides, +}) + +const createProps = (overrides: Partial> = {}): React.ComponentProps => ({ + appSourceType: AppSourceType.webApp, + content: 'Hello world', + feedback: { rating: null } as FeedbackType, + isError: false, + isLoading: false, + isMobile: false, + isWorkflow: false, + messageId: 'message-1', + onFeedback: vi.fn(), + onRetry: vi.fn(), + onSave: vi.fn(), + siteInfo: null, + taskId: 'task-1', + ...overrides, +}) + +describe('GenerationItem', () => { + it('should render the loading state while waiting for a response', () => { + mockUseGenerationItem.mockReturnValue(createHookState()) + + render() + + expect(screen.getByText('loading:area')).toBeInTheDocument() + expect(screen.queryByText(/action-bar:/)).not.toBeInTheDocument() + }) + + it('should render workflow content and pass the derived action-bar props', () => { + mockUseGenerationItem.mockReturnValue(createHookState({ + currentTab: 'DETAIL', + })) + + render( + , + ) + + expect(screen.getByText('workflow-content:DETAIL:task-9')).toBeInTheDocument() + expect(screen.queryByText(/common\.unit\.char/)).not.toBeInTheDocument() + expect(mockActionBar.mock.calls[0][0]).toEqual(expect.objectContaining({ + currentTab: 'DETAIL', + isWorkflow: true, + messageId: 'message-1', + voice: 'alloy', + })) + }) + + it('should render markdown output, task header, char count, and nested child items', () => { + mockUseGenerationItem + .mockReturnValueOnce(createHookState({ + childProps: createProps({ + content: 'Child answer', + depth: 2, + messageId: 'child-1', + }), + showChildItem: true, + })) + .mockReturnValueOnce(createHookState({ + childProps: createProps({ + content: '', + depth: 3, + messageId: 'child-2', + }), + isTop: false, + showChildItem: false, + taskLabel: 'task-1-1', + })) + + render() + + expect(screen.getAllByText('share.generation.execution')).toHaveLength(2) + expect(screen.getByText('task-1')).toBeInTheDocument() + expect(screen.getByText('markdown:Hello world')).toBeInTheDocument() + expect(screen.getByText(/11\s+common\.unit\.char/)).toBeInTheDocument() + expect(screen.getByText('markdown:Child answer')).toBeInTheDocument() + expect(screen.getAllByText(/action-bar:/)).toHaveLength(2) + }) +}) diff --git a/web/app/components/app/text-generate/item/__tests__/result-tab.spec.tsx b/web/app/components/app/text-generate/item/__tests__/result-tab.spec.tsx new file mode 100644 index 0000000000..d95eb0109d --- /dev/null +++ b/web/app/components/app/text-generate/item/__tests__/result-tab.spec.tsx @@ -0,0 +1,51 @@ +import type { WorkflowProcess } from '@/app/components/base/chat/types' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import ResultTab from '../result-tab' + +vi.mock('@/app/components/base/file-uploader', () => ({ + FileList: ({ files }: { files: Array<{ id: string }> }) =>
{`files:${files.length}`}
, +})) + +vi.mock('@/app/components/base/markdown', () => ({ + Markdown: ({ content }: { content: string }) =>
{`markdown:${content}`}
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ value }: { value: unknown }) =>
{`code-editor:${String(value)}`}
, +})) + +describe('ResultTab', () => { + it('should render markdown text and uploaded files in result mode', () => { + const workflowProcessData = { + files: [{ + list: [{ id: 'file-1' }], + varName: 'documents', + }], + resultText: 'Generated result', + } as unknown as WorkflowProcess + + render( + , + ) + + expect(screen.getByText('markdown:Generated result')).toBeInTheDocument() + expect(screen.getByText('documents')).toBeInTheDocument() + expect(screen.getByText('files:1')).toBeInTheDocument() + }) + + it('should render the JSON detail view in detail mode', () => { + render( + , + ) + + expect(screen.getByText('code-editor:{"raw":true}')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/text-generate/item/__tests__/use-generation-item.spec.tsx b/web/app/components/app/text-generate/item/__tests__/use-generation-item.spec.tsx new file mode 100644 index 0000000000..112f772302 --- /dev/null +++ b/web/app/components/app/text-generate/item/__tests__/use-generation-item.spec.tsx @@ -0,0 +1,339 @@ +import type { IChatItem } from '@/app/components/base/chat/chat/type' +import type { WorkflowProcess } from '@/app/components/base/chat/types' +import type { FileEntity } from '@/app/components/base/file-uploader/types' +import type { SiteInfo } from '@/models/share' +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from '@/app/components/base/ui/toast' +import { WorkflowRunningStatus } from '@/app/components/workflow/types' +import { AppSourceType } from '@/service/share' +import { TransferMethod } from '@/types/app' +import { generationItemHelpers, useGenerationItem } from '../use-generation-item' + +const mockCopy = vi.fn() +const mockFetchTextGenerationMessage = vi.fn() +const mockFetchMoreLikeThis = vi.fn() +const mockSubmitHumanInputForm = vi.fn() +const mockUpdateFeedback = vi.fn() +const mockSubmitHumanInputFormService = vi.fn() +const mockSetCurrentLogItem = vi.fn() +const mockSetShowPromptLogModal = vi.fn() + +vi.mock('copy-to-clipboard', () => ({ + default: (...args: unknown[]) => mockCopy(...args), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { + setCurrentLogItem: typeof mockSetCurrentLogItem + setShowPromptLogModal: typeof mockSetShowPromptLogModal + }) => unknown) => selector({ + setCurrentLogItem: mockSetCurrentLogItem, + setShowPromptLogModal: mockSetShowPromptLogModal, + }), +})) + +vi.mock('@/app/components/base/chat/chat/context', () => ({ + useChatContext: () => ({ + config: { + text_to_speech: { + voice: 'alloy', + }, + }, + }), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + success: vi.fn(), + warning: vi.fn(), + }, +})) + +vi.mock('@/next/navigation', () => ({ + useParams: () => ({ + appId: 'app-1', + }), +})) + +vi.mock('@/service/debug', () => ({ + fetchTextGenerationMessage: (...args: unknown[]) => mockFetchTextGenerationMessage(...args), +})) + +vi.mock('@/service/share', async () => { + const actual = await vi.importActual('@/service/share') + return { + ...actual, + fetchMoreLikeThis: (...args: unknown[]) => mockFetchMoreLikeThis(...args), + submitHumanInputForm: (...args: unknown[]) => mockSubmitHumanInputForm(...args), + updateFeedback: (...args: unknown[]) => mockUpdateFeedback(...args), + } +}) + +vi.mock('@/service/workflow', async () => { + const actual = await vi.importActual('@/service/workflow') + return { + ...actual, + submitHumanInputForm: (...args: unknown[]) => mockSubmitHumanInputFormService(...args), + } +}) + +const createSiteInfo = (overrides: Partial = {}): SiteInfo => ({ + title: 'App site', + show_workflow_steps: true, + ...overrides, +}) + +const createWorkflowProcessData = (overrides: Partial = {}): WorkflowProcess => ({ + status: WorkflowRunningStatus.Succeeded, + tracing: [], + ...overrides, +}) + +const createMessageFile = (overrides: Partial = {}): FileEntity => ({ + id: 'file-1', + name: 'file.txt', + progress: 100, + size: 1, + supportFileType: 'document', + transferMethod: TransferMethod.local_file, + type: 'text/plain', + ...overrides, +}) + +const createProps = (overrides: Partial[0]> = {}): Parameters[0] => ({ + appSourceType: AppSourceType.webApp, + content: 'Initial content', + controlClearMoreLikeThis: 0, + depth: 1, + installedAppId: 'installed-1', + isInWebApp: true, + isLoading: false, + isMobile: false, + isShowTextToSpeech: true, + isWorkflow: false, + messageId: 'message-1', + onRetry: vi.fn(), + onSave: vi.fn(), + siteInfo: createSiteInfo(), + taskId: 'task-1', + workflowProcessData: undefined, + ...overrides, +}) + +describe('useGenerationItem', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetchMoreLikeThis.mockResolvedValue({ + answer: 'Suggested answer', + id: 'child-1', + }) + mockFetchTextGenerationMessage.mockResolvedValue({ + answer: 'Assistant answer', + id: 'message-1', + message: 'Original prompt', + message_files: [createMessageFile()], + }) + }) + + it('should derive the current tab and helper flags from workflow data', async () => { + const { result, rerender } = renderHook(props => useGenerationItem(props), { + initialProps: createProps({ + appSourceType: AppSourceType.tryApp, + depth: 2, + isWorkflow: true, + workflowProcessData: createWorkflowProcessData({ + resultText: 'Workflow result', + }), + }), + }) + + expect(result.current.currentTab).toBe('RESULT') + expect(result.current.isTop).toBe(false) + expect(result.current.isTryApp).toBe(true) + expect(result.current.taskLabel).toBe('task-1-1') + expect(result.current.config?.text_to_speech?.voice).toBe('alloy') + + rerender(createProps({ + depth: 2, + isWorkflow: true, + workflowProcessData: createWorkflowProcessData(), + })) + + await waitFor(() => { + expect(result.current.currentTab).toBe('DETAIL') + }) + }) + + it('should request more-like-this responses and reuse the returned message for child feedback', async () => { + const { result } = renderHook(() => useGenerationItem(createProps())) + + await act(async () => { + await result.current.handleMoreLikeThis() + }) + + expect(mockFetchMoreLikeThis).toHaveBeenCalledWith('message-1', AppSourceType.webApp, 'installed-1') + expect(result.current.completionRes).toBe('Suggested answer') + expect(result.current.childMessageId).toBe('child-1') + expect(result.current.showChildItem).toBe(true) + expect(result.current.childProps.messageId).toBe('child-1') + expect(result.current.childProps.feedback).toEqual({ rating: null }) + + await act(async () => { + await result.current.childProps.onFeedback?.({ rating: 'like' }) + }) + + expect(mockUpdateFeedback).toHaveBeenCalledWith({ + body: { rating: 'like' }, + url: '/messages/child-1/feedbacks', + }, AppSourceType.webApp, 'installed-1') + }) + + it('should warn instead of requesting more-like-this when no source message is available', async () => { + const { result } = renderHook(() => useGenerationItem(createProps({ messageId: undefined }))) + + await act(async () => { + await result.current.handleMoreLikeThis() + }) + + expect(mockFetchMoreLikeThis).not.toHaveBeenCalled() + expect(toast.warning).toHaveBeenCalledWith('appDebug.errorMessage.waitForResponse') + }) + + it('should clear child content when the clear control changes or the parent starts loading', async () => { + const { result, rerender } = renderHook(props => useGenerationItem(props), { + initialProps: createProps(), + }) + + await act(async () => { + await result.current.handleMoreLikeThis() + }) + + expect(result.current.childMessageId).toBe('child-1') + + rerender(createProps({ controlClearMoreLikeThis: 1 })) + + await waitFor(() => { + expect(result.current.childMessageId).toBeNull() + expect(result.current.completionRes).toBe('') + }) + + await act(async () => { + await result.current.handleMoreLikeThis() + }) + + rerender(createProps({ isLoading: true })) + + await waitFor(() => { + expect(result.current.childMessageId).toBeNull() + }) + }) + + it('should normalize log entries and open the prompt log modal', async () => { + const { result } = renderHook(() => useGenerationItem(createProps())) + + await act(async () => { + await result.current.handleOpenLogModal() + }) + + expect(mockFetchTextGenerationMessage).toHaveBeenCalledWith({ + appId: 'app-1', + messageId: 'message-1', + }) + expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(true) + expect(mockSetCurrentLogItem).toHaveBeenCalledWith(expect.objectContaining({ + content: 'Assistant answer', + id: 'message-1', + isAnswer: true, + log: [{ + role: 'user', + text: 'Original prompt', + }], + })) + }) + + it('should route human input submissions to share and workflow services based on app source', async () => { + const shareHook = renderHook(() => useGenerationItem(createProps())) + + await act(async () => { + await shareHook.result.current.handleSubmitHumanInputForm('token-1', { + action: 'submit', + inputs: { city: 'Paris' }, + }) + }) + + expect(mockSubmitHumanInputForm).toHaveBeenCalledWith('token-1', { + action: 'submit', + inputs: { city: 'Paris' }, + }) + + const installedHook = renderHook(() => useGenerationItem(createProps({ + appSourceType: AppSourceType.installedApp, + }))) + + await act(async () => { + await installedHook.result.current.handleSubmitHumanInputForm('token-2', { + action: 'confirm', + inputs: { city: 'Berlin' }, + }) + }) + + expect(mockSubmitHumanInputFormService).toHaveBeenCalledWith('token-2', { + action: 'confirm', + inputs: { city: 'Berlin' }, + }) + }) + + it('should copy workflow text directly and stringify non-string content', async () => { + const { result: workflowResult } = renderHook(() => useGenerationItem(createProps({ + content: { raw: true }, + isWorkflow: true, + workflowProcessData: createWorkflowProcessData({ + resultText: 'Workflow text', + }), + }))) + + await act(async () => { + workflowResult.current.handleCopy() + }) + + expect(mockCopy).toHaveBeenCalledWith('Workflow text') + expect(toast.success).toHaveBeenCalledWith('common.actionMsg.copySuccessfully') + + const { result: jsonResult } = renderHook(() => useGenerationItem(createProps({ + content: { raw: true }, + }))) + + await act(async () => { + jsonResult.current.handleCopy() + }) + + expect(mockCopy).toHaveBeenCalledWith(JSON.stringify({ raw: true })) + }) +}) + +describe('generationItemHelpers', () => { + it('should build a normalized log item from text messages', () => { + const logItem = generationItemHelpers.buildLogItem({ + answer: 'Assistant answer', + data: { + answer: 'Assistant answer', + id: 'message-1', + message: 'Original prompt', + message_files: [createMessageFile()], + }, + messageId: 'message-1', + }) as IChatItem + + expect(logItem.id).toBe('message-1') + expect(logItem.log).toEqual([{ role: 'user', text: 'Original prompt' }]) + }) + + it('should compute the default tab from workflow result availability', () => { + expect(generationItemHelpers.getCurrentTab(createWorkflowProcessData({ resultText: 'done' }))).toBe('RESULT') + expect(generationItemHelpers.getCurrentTab(createWorkflowProcessData({ + files: [{ id: 'file-1' }] as unknown as WorkflowProcess['files'], + }))).toBe('RESULT') + expect(generationItemHelpers.getCurrentTab(createWorkflowProcessData())).toBe('DETAIL') + }) +}) diff --git a/web/app/components/app/text-generate/item/__tests__/workflow-content.spec.tsx b/web/app/components/app/text-generate/item/__tests__/workflow-content.spec.tsx new file mode 100644 index 0000000000..7f74267a41 --- /dev/null +++ b/web/app/components/app/text-generate/item/__tests__/workflow-content.spec.tsx @@ -0,0 +1,142 @@ +import type { WorkflowProcess } from '@/app/components/base/chat/types' +import type { SiteInfo } from '@/models/share' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { WorkflowRunningStatus } from '@/app/components/workflow/types' +import WorkflowContent from '../workflow-content' + +vi.mock('@/app/components/base/chat/chat/answer/human-input-filled-form-list', () => ({ + default: ({ humanInputFilledFormDataList }: { humanInputFilledFormDataList: Array }) => ( +
{`filled-forms:${humanInputFilledFormDataList.length}`}
+ ), +})) + +vi.mock('@/app/components/base/chat/chat/answer/human-input-form-list', () => ({ + default: ({ + humanInputFormDataList, + onHumanInputFormSubmit, + }: { + humanInputFormDataList: Array + onHumanInputFormSubmit: (formToken: string, formData: { inputs: Record, action: string }) => void + }) => ( + + ), +})) + +vi.mock('@/app/components/base/chat/chat/answer/workflow-process', () => ({ + default: ({ + readonly, + }: { + readonly: boolean + }) =>
{`workflow-process:${String(readonly)}`}
, +})) + +vi.mock('../result-tab', () => ({ + default: ({ + currentTab, + content, + }: { + currentTab: string + content: unknown + }) =>
{`result-tab:${currentTab}:${String(content)}`}
, +})) + +const createWorkflowProcessData = () => ({ + status: WorkflowRunningStatus.Succeeded, + tracing: [], + expand: true, + files: [{ list: [{ id: 'file-1' }], varName: 'documents' }], + humanInputFilledFormDataList: [{ id: 'filled-1' }], + humanInputFormDataList: [{ id: 'form-1' }], + resultText: 'Workflow result', +}) as unknown as WorkflowProcess + +const workflowSiteInfo: SiteInfo = { + title: 'App site', + show_workflow_steps: false, +} + +describe('WorkflowContent', () => { + it('should render workflow metadata, result tabs, and forward human input submissions', async () => { + const user = userEvent.setup() + const onSubmitHumanInputForm = vi.fn() + const onSwitchTab = vi.fn() + + render( + , + ) + + expect(screen.getByText('share.generation.execution')).toBeInTheDocument() + expect(screen.getByText('task-1')).toBeInTheDocument() + expect(screen.getByText('workflow-process:true')).toBeInTheDocument() + expect(screen.getByText('runLog.result')).toBeInTheDocument() + expect(screen.getByText('runLog.detail')).toBeInTheDocument() + expect(screen.getByText('human-forms:1')).toBeInTheDocument() + expect(screen.getByText('filled-forms:1')).toBeInTheDocument() + expect(screen.getByText('result-tab:RESULT:workflow-json')).toBeInTheDocument() + + await user.click(screen.getByText('runLog.detail')) + await user.click(screen.getByText('human-forms:1')) + + expect(onSwitchTab).toHaveBeenCalledWith('DETAIL') + expect(onSubmitHumanInputForm).toHaveBeenCalledWith('token-1', { + action: 'submit', + inputs: { city: 'Paris' }, + }) + }) + + it('should hide result tabs and content helpers when the workflow is in an error state', () => { + render( + , + ) + + expect(screen.queryByText('runLog.result')).not.toBeInTheDocument() + expect(screen.queryByText(/result-tab:/)).not.toBeInTheDocument() + expect(screen.queryByText(/workflow-process:/)).not.toBeInTheDocument() + }) + + it('should switch back to the result tab when the detail tab is active', async () => { + const user = userEvent.setup() + const onSwitchTab = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('runLog.result')) + + expect(onSwitchTab).toHaveBeenCalledWith('RESULT') + }) +}) diff --git a/web/app/components/app/text-generate/item/index.tsx b/web/app/components/app/text-generate/item/index.tsx index f3396e60ed..b71260af7a 100644 --- a/web/app/components/app/text-generate/item/index.tsx +++ b/web/app/components/app/text-generate/item/index.tsx @@ -1,32 +1,18 @@ 'use client' import type { FC } from 'react' -import type { FeedbackType, IChatItem } from '@/app/components/base/chat/chat/type' +import type { FeedbackType } from '@/app/components/base/chat/chat/type' import type { WorkflowProcess } from '@/app/components/base/chat/types' import type { SiteInfo } from '@/models/share' -import { - RiPlayList2Line, - RiSparklingFill, -} from '@remixicon/react' -import { useBoolean } from 'ahooks' -import copy from 'copy-to-clipboard' +import type { AppSourceType } from '@/service/share' import * as React from 'react' -import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useStore as useAppStore } from '@/app/components/app/store' -import { useChatContext } from '@/app/components/base/chat/chat/context' import Loading from '@/app/components/base/loading' import { Markdown } from '@/app/components/base/markdown' -import { toast } from '@/app/components/base/ui/toast' -import { useParams } from '@/next/navigation' -import { fetchTextGenerationMessage } from '@/service/debug' -import { AppSourceType, fetchMoreLikeThis, submitHumanInputForm, updateFeedback } from '@/service/share' -import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow' import { cn } from '@/utils/classnames' import GenerationItemActionBar from './action-bar' +import { useGenerationItem } from './use-generation-item' import WorkflowContent from './workflow-content' -const MAX_DEPTH = 3 - export type IGenerationItemProps = { isWorkflow?: boolean workflowProcessData?: WorkflowProcess @@ -90,142 +76,28 @@ const GenerationItem: FC = ({ inSidePanel, }) => { const { t } = useTranslation() - const params = useParams() - const isTop = depth === 1 - const isTryApp = appSourceType === AppSourceType.tryApp - const [completionRes, setCompletionRes] = useState('') - const [childMessageId, setChildMessageId] = useState(null) - const [childFeedback, setChildFeedback] = useState({ - rating: null, - }) - const { - config, - } = useChatContext() - - const setCurrentLogItem = useAppStore(s => s.setCurrentLogItem) - const setShowPromptLogModal = useAppStore(s => s.setShowPromptLogModal) - - const handleFeedback = async (childFeedback: FeedbackType) => { - await updateFeedback({ url: `/messages/${childMessageId}/feedbacks`, body: { rating: childFeedback.rating } }, appSourceType, installedAppId) - setChildFeedback(childFeedback) - } - - const [isQuerying, { setTrue: startQuerying, setFalse: stopQuerying }] = useBoolean(false) - - const childProps: IGenerationItemProps = { - isInWebApp, - content: completionRes, - messageId: childMessageId, - depth: depth + 1, - moreLikeThis: true, - onFeedback: handleFeedback, - isLoading: isQuerying, - feedback: childFeedback, - onSave, - isShowTextToSpeech, - isMobile, + const state = useGenerationItem({ appSourceType, - installedAppId, + content, controlClearMoreLikeThis, + depth, + installedAppId, + isInWebApp, + isLoading, + isMobile, + isShowTextToSpeech, isWorkflow, + messageId, + onRetry, + onSave, siteInfo, taskId, - isError: false, - onRetry, - } - - const handleMoreLikeThis = async () => { - if (isQuerying || !messageId) { - toast.warning(t('errorMessage.waitForResponse', { ns: 'appDebug' })) - return - } - startQuerying() - const res: any = await fetchMoreLikeThis(messageId as string, appSourceType, installedAppId) - setCompletionRes(res.answer) - setChildFeedback({ - rating: null, - }) - setChildMessageId(res.id) - stopQuerying() - } - - useEffect(() => { - if (controlClearMoreLikeThis) { - setChildMessageId(null) - setCompletionRes('') - } - }, [controlClearMoreLikeThis]) - - // regeneration clear child - useEffect(() => { - if (isLoading) - setChildMessageId(null) - }, [isLoading]) - - const handleOpenLogModal = async () => { - const data = await fetchTextGenerationMessage({ - appId: params.appId as string, - messageId: messageId!, - }) - const assistantFiles = data.message_files?.filter(file => file.belongs_to === 'assistant') || [] - const normalizedMessage = typeof data.message === 'string' - ? { role: 'user', text: data.message } - : data.message - const baseLog = Array.isArray(normalizedMessage) ? normalizedMessage : [normalizedMessage] - const log = Array.isArray(normalizedMessage) - ? [ - ...normalizedMessage, - ...(normalizedMessage.length > 0 && normalizedMessage[normalizedMessage.length - 1].role !== 'assistant' - ? [ - { - role: 'assistant', - text: data.answer || '', - files: assistantFiles, - }, - ] - : []), - ] - : baseLog - const logItem: IChatItem = { - id: data.id || messageId || '', - content: data.answer || '', - isAnswer: true, - log, - message_files: data.message_files, - } - setCurrentLogItem(logItem) - setShowPromptLogModal(true) - } - - const [currentTab, setCurrentTab] = useState('DETAIL') - const switchTab = async (tab: string) => { - setCurrentTab(tab) - } - useEffect(() => { - if (workflowProcessData?.resultText || !!workflowProcessData?.files?.length || (workflowProcessData?.humanInputFormDataList && workflowProcessData?.humanInputFormDataList.length > 0) || (workflowProcessData?.humanInputFilledFormDataList && workflowProcessData?.humanInputFilledFormDataList.length > 0)) - switchTab('RESULT') - else - switchTab('DETAIL') - }, [workflowProcessData?.files?.length, workflowProcessData?.resultText, workflowProcessData?.humanInputFormDataList, workflowProcessData?.humanInputFilledFormDataList]) - const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: { inputs: Record, action: string }) => { - if (appSourceType === AppSourceType.installedApp) - await submitHumanInputFormService(formToken, formData) - else - await submitHumanInputForm(formToken, formData) - }, [appSourceType]) - - const handleCopy = useCallback(() => { - const copyContent = isWorkflow ? workflowProcessData?.resultText : content - if (typeof copyContent === 'string') - copy(copyContent) - else - copy(JSON.stringify(copyContent)) - toast.success(t('actionMsg.copySuccessfully', { ns: 'common' })) - }, [content, isWorkflow, t, workflowProcessData?.resultText]) + workflowProcessData, + }) return ( <> -
+
{isLoading && (
)} @@ -238,26 +110,24 @@ const GenerationItem: FC = ({ )} > {workflowProcessData && ( - <> - - + )} {!workflowProcessData && taskId && (
- +
)} {isError && ( @@ -272,7 +142,7 @@ const GenerationItem: FC = ({ {/* meta data */}
{!isWorkflow && ( @@ -286,31 +156,31 @@ const GenerationItem: FC = ({
{/* more like this elements */} - {!isTop && ( + {!state.isTop && (
= ({ isMobile ? 'top-[3.5px]' : 'top-2', )} > - +
)} )}
- {((childMessageId || isQuerying) && depth < MAX_DEPTH) && ( - + {state.showChildItem && ( + )} ) diff --git a/web/app/components/app/text-generate/item/use-generation-item.ts b/web/app/components/app/text-generate/item/use-generation-item.ts new file mode 100644 index 0000000000..eefdd2be23 --- /dev/null +++ b/web/app/components/app/text-generate/item/use-generation-item.ts @@ -0,0 +1,308 @@ +'use client' + +import type { IGenerationItemProps } from './index' +import type { FeedbackType, IChatItem } from '@/app/components/base/chat/chat/type' +import type { WorkflowProcess } from '@/app/components/base/chat/types' +import { useBoolean } from 'ahooks' +import copy from 'copy-to-clipboard' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useStore as useAppStore } from '@/app/components/app/store' +import { useChatContext } from '@/app/components/base/chat/chat/context' +import { toast } from '@/app/components/base/ui/toast' +import { useParams } from '@/next/navigation' +import { fetchTextGenerationMessage } from '@/service/debug' +import { + AppSourceType, + fetchMoreLikeThis, + submitHumanInputForm, + updateFeedback, +} from '@/service/share' +import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow' + +const MAX_DEPTH = 3 + +const getCurrentTab = (workflowProcessData?: WorkflowProcess) => { + if ( + workflowProcessData?.resultText + || !!workflowProcessData?.files?.length + || !!workflowProcessData?.humanInputFormDataList?.length + || !!workflowProcessData?.humanInputFilledFormDataList?.length + ) { + return 'RESULT' + } + + return 'DETAIL' +} + +const buildLogItem = ({ + answer, + data, + messageId, +}: { + answer?: string + data: Awaited> + messageId?: string | null +}): IChatItem => { + const assistantFiles = data.message_files?.filter(file => file.belongs_to === 'assistant') || [] + const normalizedMessage = typeof data.message === 'string' + ? { role: 'user', text: data.message } + : data.message + const baseLog = Array.isArray(normalizedMessage) ? normalizedMessage : [normalizedMessage] + const log = Array.isArray(normalizedMessage) + ? [ + ...normalizedMessage, + ...(normalizedMessage.length > 0 && normalizedMessage[normalizedMessage.length - 1].role !== 'assistant' + ? [{ + role: 'assistant', + text: answer || '', + files: assistantFiles, + }] + : []), + ] + : baseLog + + return { + id: data.id || messageId || '', + content: answer || '', + isAnswer: true, + log, + message_files: data.message_files, + } +} + +type UseGenerationItemParams = Pick< + IGenerationItemProps, + | 'appSourceType' + | 'content' + | 'controlClearMoreLikeThis' + | 'depth' + | 'installedAppId' + | 'isInWebApp' + | 'isLoading' + | 'isMobile' + | 'isShowTextToSpeech' + | 'isWorkflow' + | 'messageId' + | 'onRetry' + | 'onSave' + | 'siteInfo' + | 'taskId' + | 'workflowProcessData' +> + +type MoreLikeThisState = { + childFeedback: FeedbackType + childMessageId: string | null + completionRes: string + controlVersion?: number +} + +type CurrentTabState = { + signature: string + value: string | null +} + +type MoreLikeThisResponse = { + answer?: string + id?: string +} + +const getWorkflowTabSignature = (workflowProcessData?: WorkflowProcess) => JSON.stringify({ + filesLength: workflowProcessData?.files?.length ?? 0, + humanInputFilledFormDataListLength: workflowProcessData?.humanInputFilledFormDataList?.length ?? 0, + humanInputFormDataListLength: workflowProcessData?.humanInputFormDataList?.length ?? 0, + resultText: workflowProcessData?.resultText ?? '', +}) + +export const useGenerationItem = ({ + appSourceType, + content, + controlClearMoreLikeThis, + depth = 1, + installedAppId, + isInWebApp = false, + isLoading, + isMobile, + isShowTextToSpeech, + isWorkflow, + messageId, + onRetry, + onSave, + siteInfo, + taskId, + workflowProcessData, +}: UseGenerationItemParams) => { + const { t } = useTranslation() + const params = useParams() + const { config } = useChatContext() + + const setCurrentLogItem = useAppStore(state => state.setCurrentLogItem) + const setShowPromptLogModal = useAppStore(state => state.setShowPromptLogModal) + + const workflowTabSignature = getWorkflowTabSignature(workflowProcessData) + const workflowDefaultTab = getCurrentTab(workflowProcessData) + + const [moreLikeThisState, setMoreLikeThisState] = useState(() => ({ + childFeedback: { + rating: null, + }, + childMessageId: null, + completionRes: '', + controlVersion: controlClearMoreLikeThis, + })) + const [currentTabState, setCurrentTabState] = useState(() => ({ + signature: workflowTabSignature, + value: null, + })) + const [isQuerying, { setTrue: startQuerying, setFalse: stopQuerying }] = useBoolean(false) + + const isTop = depth === 1 + const isTryApp = appSourceType === AppSourceType.tryApp + const taskLabel = taskId ? `${taskId}${depth > 1 ? `-${depth - 1}` : ''}` : '' + const isMoreLikeThisCleared = moreLikeThisState.controlVersion !== controlClearMoreLikeThis + const completionRes = isMoreLikeThisCleared ? '' : moreLikeThisState.completionRes + const childMessageId = (isLoading || isMoreLikeThisCleared) ? null : moreLikeThisState.childMessageId + const currentTab = currentTabState.signature === workflowTabSignature && currentTabState.value + ? currentTabState.value + : workflowDefaultTab + + const handleChildFeedback = useCallback(async (nextFeedback: FeedbackType) => { + await updateFeedback( + { + url: `/messages/${childMessageId}/feedbacks`, + body: { rating: nextFeedback.rating }, + }, + appSourceType, + installedAppId, + ) + setMoreLikeThisState(prev => ({ + ...prev, + childFeedback: nextFeedback, + })) + }, [appSourceType, childMessageId, installedAppId]) + + const handleMoreLikeThis = useCallback(async () => { + if (isQuerying || !messageId) { + toast.warning(t('errorMessage.waitForResponse', { ns: 'appDebug' })) + return + } + + startQuerying() + const response = await fetchMoreLikeThis(messageId, appSourceType, installedAppId) as MoreLikeThisResponse + setMoreLikeThisState({ + childFeedback: { rating: null }, + childMessageId: response.id ?? null, + completionRes: response.answer ?? '', + controlVersion: controlClearMoreLikeThis, + }) + stopQuerying() + }, [appSourceType, controlClearMoreLikeThis, installedAppId, isQuerying, messageId, startQuerying, stopQuerying, t]) + + const handleOpenLogModal = useCallback(async () => { + const data = await fetchTextGenerationMessage({ + appId: params.appId as string, + messageId: messageId!, + }) + const logItem = buildLogItem({ + answer: data.answer, + data, + messageId, + }) + + setCurrentLogItem(logItem) + setShowPromptLogModal(true) + }, [messageId, params.appId, setCurrentLogItem, setShowPromptLogModal]) + + const handleSubmitHumanInputForm = useCallback(async ( + formToken: string, + formData: { inputs: Record, action: string }, + ) => { + if (appSourceType === AppSourceType.installedApp) { + await submitHumanInputFormService(formToken, formData) + return + } + + await submitHumanInputForm(formToken, formData) + }, [appSourceType]) + + const handleCopy = useCallback(() => { + const copyContent = isWorkflow ? workflowProcessData?.resultText : content + if (typeof copyContent === 'string') + copy(copyContent) + else + copy(JSON.stringify(copyContent)) + + toast.success(t('actionMsg.copySuccessfully', { ns: 'common' })) + }, [content, isWorkflow, t, workflowProcessData?.resultText]) + + const setCurrentTab = useCallback((tab: string) => { + setCurrentTabState({ + signature: workflowTabSignature, + value: tab, + }) + }, [workflowTabSignature]) + + const childProps: IGenerationItemProps = useMemo(() => ({ + appSourceType, + content: completionRes, + controlClearMoreLikeThis, + depth: depth + 1, + feedback: moreLikeThisState.childFeedback, + installedAppId, + isError: false, + isInWebApp, + isLoading: isQuerying, + isMobile, + isShowTextToSpeech, + isWorkflow, + messageId: childMessageId, + moreLikeThis: true, + onFeedback: handleChildFeedback, + onRetry, + onSave, + siteInfo, + taskId, + }), [ + appSourceType, + childMessageId, + completionRes, + controlClearMoreLikeThis, + depth, + handleChildFeedback, + installedAppId, + isInWebApp, + isMobile, + isQuerying, + isShowTextToSpeech, + isWorkflow, + moreLikeThisState.childFeedback, + onRetry, + onSave, + siteInfo, + taskId, + ]) + + return { + childMessageId, + childProps, + config, + completionRes, + currentTab, + handleCopy, + handleMoreLikeThis, + handleOpenLogModal, + handleSubmitHumanInputForm, + isQuerying, + isTop, + isTryApp, + setCurrentTab, + showChildItem: (childMessageId || isQuerying) && depth < MAX_DEPTH, + taskLabel, + } +} + +export const generationItemHelpers = { + buildLogItem, + getCurrentTab, +} diff --git a/web/app/components/apps/__tests__/app-card-skeleton.spec.tsx b/web/app/components/apps/__tests__/app-card-skeleton.spec.tsx new file mode 100644 index 0000000000..31095940bc --- /dev/null +++ b/web/app/components/apps/__tests__/app-card-skeleton.spec.tsx @@ -0,0 +1,28 @@ +import { render } from '@testing-library/react' + +import { AppCardSkeleton } from '../app-card-skeleton' + +describe('AppCardSkeleton', () => { + it('should render six skeleton cards by default', () => { + const { container } = render() + + expect(container.querySelectorAll('.bg-components-card-bg')).toHaveLength(6) + }) + + it('should render the configured number of skeleton cards', () => { + const { container } = render() + + expect(container.querySelectorAll('.bg-components-card-bg')).toHaveLength(2) + expect(container.querySelectorAll('.animate-pulse')).toHaveLength(10) + }) + + it('should render nothing when count is zero', () => { + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + it('should expose a stable display name', () => { + expect(AppCardSkeleton.displayName).toBe('AppCardSkeleton') + }) +}) diff --git a/web/app/components/apps/__tests__/import-from-marketplace-template-modal.spec.tsx b/web/app/components/apps/__tests__/import-from-marketplace-template-modal.spec.tsx new file mode 100644 index 0000000000..88ee57153c --- /dev/null +++ b/web/app/components/apps/__tests__/import-from-marketplace-template-modal.spec.tsx @@ -0,0 +1,329 @@ +import type { MarketplaceTemplate } from '@/service/marketplace-templates' +import { skipToken } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' + +import ImportFromMarketplaceTemplateModal from '../import-from-marketplace-template-modal' + +const { + mockUseQuery, + mockTemplateDetailQueryOptions, + mockFetchMarketplaceTemplateDSL, + mockToastError, +} = vi.hoisted(() => ({ + mockUseQuery: vi.fn(), + mockTemplateDetailQueryOptions: vi.fn(), + mockFetchMarketplaceTemplateDSL: vi.fn(), + mockToastError: vi.fn(), +})) + +vi.mock('@tanstack/react-query', async () => { + const actual = await vi.importActual('@tanstack/react-query') + return { + ...actual, + useQuery: (options: unknown) => mockUseQuery(options), + } +}) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (options?.publisher) + return `${key}:${options.publisher}` + if (typeof options?.count === 'number') + return `${key}:${options.count}` + return key + }, + }), +})) + +vi.mock('@/config', () => ({ + MARKETPLACE_API_PREFIX: 'https://marketplace.example/api/v1', + MARKETPLACE_URL_PREFIX: 'https://marketplace.example', +})) + +vi.mock('@/service/client', () => ({ + marketplaceQuery: { + templateDetail: { + queryOptions: (options: unknown) => mockTemplateDetailQueryOptions(options), + }, + }, +})) + +vi.mock('@/service/marketplace-templates', async () => { + const actual = await vi.importActual('@/service/marketplace-templates') + return { + ...actual, + fetchMarketplaceTemplateDSL: (templateId: string) => mockFetchMarketplaceTemplateDSL(templateId), + } +}) + +vi.mock('@/app/components/base/app-icon', () => ({ + default: (props: Record) => React.createElement('div', { + 'data-testid': 'app-icon', + 'data-icon-type': props.iconType, + 'data-icon': props.icon, + 'data-background': props.background, + 'data-image-url': props.imageUrl, + }), +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, ...props }: React.ButtonHTMLAttributes) => React.createElement('button', props, children), +})) + +vi.mock('@/app/components/base/ui/dialog', () => ({ + Dialog: ({ + children, + onOpenChange, + }: { + children: React.ReactNode + onOpenChange?: (open: boolean) => void + }) => React.createElement( + 'div', + { 'data-testid': 'dialog-root' }, + React.createElement('button', { + 'data-testid': 'dialog-close', + 'onClick': () => onOpenChange?.(false), + }, 'close'), + children, + ), + DialogContent: ({ children, ...props }: React.HTMLAttributes) => React.createElement('div', props, children), + DialogTitle: ({ children, ...props }: React.HTMLAttributes) => React.createElement('div', props, children), + DialogCloseButton: (props: React.ButtonHTMLAttributes) => React.createElement('button', { ...props, 'data-testid': 'dialog-close-button' }, 'close'), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: (...args: unknown[]) => mockToastError(...args), + }, +})) + +const baseTemplate: MarketplaceTemplate = { + id: 'template-id', + publisher_type: 'individual', + publisher_unique_handle: 'publisher-handle', + template_name: 'Template Name', + icon: '๐Ÿš€', + icon_background: '#FFEAD5', + icon_file_key: 'icon-file', + kind: 'classic', + categories: [], + deps_plugins: [], + preferred_languages: [], + overview: 'Template overview', + readme: 'Template readme', + partner_link: '', + version: '1.0.0', + status: 'published', + usage_count: 3, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', +} + +describe('ImportFromMarketplaceTemplateModal', () => { + const onConfirm = vi.fn() + const onClose = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockTemplateDetailQueryOptions.mockImplementation(options => options) + }) + + it('should request template detail for the provided template id', () => { + mockUseQuery.mockReturnValue({ + data: { data: baseTemplate }, + isLoading: false, + isError: false, + }) + + render( + , + ) + + expect(mockTemplateDetailQueryOptions).toHaveBeenCalledWith({ + input: { + params: { + templateId: 'template-id', + }, + }, + }) + }) + + it('should pass skipToken when template id is empty', () => { + mockUseQuery.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + }) + + render( + , + ) + + expect(mockTemplateDetailQueryOptions).toHaveBeenCalledWith({ + input: skipToken, + }) + }) + + it('should render loading state while fetching template detail', () => { + mockUseQuery.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + }) + + const { container } = render( + , + ) + + const spinner = container.querySelector('.animate-spin') + expect(spinner).toBeInTheDocument() + expect(screen.queryByRole('link')).not.toBeInTheDocument() + }) + + it('should render error state and allow closing the modal', () => { + mockUseQuery.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + }) + + render( + , + ) + + expect(screen.getByText('marketplace.template.fetchFailed')).toBeInTheDocument() + + fireEvent.click(screen.getByText('newApp.Cancel')) + fireEvent.click(screen.getByTestId('dialog-close')) + + expect(onClose).toHaveBeenCalledTimes(2) + }) + + it('should render template information and marketplace link', () => { + mockUseQuery.mockReturnValue({ + data: { data: baseTemplate }, + isLoading: false, + isError: false, + }) + + render( + , + ) + + expect(screen.getByText('marketplace.template.modalTitle')).toBeInTheDocument() + expect(screen.getByText('Template Name')).toBeInTheDocument() + expect(screen.getByText('marketplace.template.publishedBy:publisher-handle')).toBeInTheDocument() + expect(screen.getByText('marketplace.template.overview')).toBeInTheDocument() + expect(screen.getByText('Template overview')).toBeInTheDocument() + expect(screen.getByText('marketplace.template.usageCount:3')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'marketplace.template.viewOnMarketplace' })).toHaveAttribute( + 'href', + 'https://marketplace.example/templates/template-id', + ) + expect(screen.getByTestId('app-icon')).toHaveAttribute('data-icon-type', 'image') + expect(screen.getByTestId('app-icon')).toHaveAttribute( + 'data-image-url', + 'https://marketplace.example/api/v1/templates/template-id/icon', + ) + }) + + it('should fall back to emoji icons and hide optional sections when data is missing', () => { + mockUseQuery.mockReturnValue({ + data: { + data: { + ...baseTemplate, + icon_file_key: '', + overview: '', + usage_count: 0, + }, + }, + isLoading: false, + isError: false, + }) + + render( + , + ) + + expect(screen.getByTestId('app-icon')).toHaveAttribute('data-icon-type', 'emoji') + expect(screen.queryByText('marketplace.template.overview')).not.toBeInTheDocument() + expect(screen.queryByText('marketplace.template.usageCount:0')).not.toBeInTheDocument() + }) + + it('should fetch the template DSL and confirm the import', async () => { + mockUseQuery.mockReturnValue({ + data: { data: baseTemplate }, + isLoading: false, + isError: false, + }) + mockFetchMarketplaceTemplateDSL.mockResolvedValue('yaml-content') + + render( + , + ) + + fireEvent.click(screen.getByText('marketplace.template.importConfirm')) + + await waitFor(() => { + expect(mockFetchMarketplaceTemplateDSL).toHaveBeenCalledWith('template-id') + expect(onConfirm).toHaveBeenCalledWith('yaml-content', baseTemplate) + }) + }) + + it('should show a toast and re-enable the button when importing fails', async () => { + mockUseQuery.mockReturnValue({ + data: { data: baseTemplate }, + isLoading: false, + isError: false, + }) + mockFetchMarketplaceTemplateDSL.mockRejectedValue(new Error('failed')) + + render( + , + ) + + const importButton = screen.getByText('marketplace.template.importConfirm') + fireEvent.click(importButton) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith('marketplace.template.importFailed') + }) + + expect(onConfirm).not.toHaveBeenCalled() + expect(screen.getByText('marketplace.template.importConfirm')).not.toBeDisabled() + }) +}) diff --git a/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx new file mode 100644 index 0000000000..29cdeff819 --- /dev/null +++ b/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx @@ -0,0 +1,116 @@ +import type { DebugInfo as DebugInfoType } from '../../types' +import { render, screen } from '@testing-library/react' +import * as React from 'react' + +import DebugInfo from '../debug-info' + +const { mockUseDebugKey } = vi.hoisted(() => ({ + mockUseDebugKey: vi.fn(), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.example.com${path}`, +})) + +vi.mock('@/service/use-plugins', () => ({ + useDebugKey: () => mockUseDebugKey(), +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, ...props }: React.ButtonHTMLAttributes) => React.createElement('button', props, children), +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ + children, + popupContent, + disabled, + }: { + children: React.ReactNode + popupContent: React.ReactNode + disabled?: boolean + }) => ( +
+ {children} + {!disabled &&
{popupContent}
} +
+ ), +})) + +vi.mock('../../base/key-value-item', () => ({ + default: ({ + label, + value, + maskedValue, + }: { + label: string + value: string + maskedValue?: string + }) => ( +
+ {label} + {value} + {maskedValue && {maskedValue}} +
+ ), +})) + +describe('DebugInfo', () => { + const debugInfo: DebugInfoType = { + host: '127.0.0.1', + port: 8765, + key: '12345678abcdefgh87654321', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render nothing while loading', () => { + mockUseDebugKey.mockReturnValue({ + data: undefined, + isLoading: true, + }) + + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + it('should render a disabled tooltip when debug info is unavailable', () => { + mockUseDebugKey.mockReturnValue({ + data: undefined, + isLoading: false, + }) + + render() + + expect(screen.getByTestId('tooltip')).toHaveAttribute('data-disabled', 'true') + expect(screen.queryByTestId('tooltip-content')).not.toBeInTheDocument() + }) + + it('should render masked debug information and docs link when data is available', () => { + mockUseDebugKey.mockReturnValue({ + data: debugInfo, + isLoading: false, + }) + + render() + + expect(screen.getAllByTestId('tooltip')[0]).toHaveAttribute('data-disabled', 'false') + expect(screen.getByText('debugInfo.title')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'debugInfo.viewDocs' })).toHaveAttribute( + 'href', + 'https://docs.example.com/develop-plugin/features-and-specs/plugin-types/remote-debug-a-plugin', + ) + expect(screen.getByTestId('key-value-URL')).toHaveTextContent('127.0.0.1:8765') + expect(screen.getByTestId('key-value-Key')).toHaveTextContent('12345678abcdefgh87654321') + expect(screen.getByTestId('key-value-Key')).toHaveTextContent('12345678********87654321') + }) +}) diff --git a/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx new file mode 100644 index 0000000000..4eac3f17ca --- /dev/null +++ b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx @@ -0,0 +1,369 @@ +import type { PluginDeclaration, PluginDetail } from '../../types' +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { PluginCategoryEnum, PluginSource } from '../../types' + +import PluginsPanel from '../plugins-panel' + +const { + mockSetFilters, + mockSetCurrentPluginID, + mockLoadNextPage, + mockInvalidateInstalledPluginList, +} = vi.hoisted(() => ({ + mockSetFilters: vi.fn(), + mockSetCurrentPluginID: vi.fn(), + mockLoadNextPage: vi.fn(), + mockInvalidateInstalledPluginList: vi.fn(), +})) + +type PluginPageContextState = { + filters: { + categories: string[] + tags: string[] + searchQuery: string + } + setFilters: (filters: { + categories: string[] + tags: string[] + searchQuery: string + }) => void + currentPluginID: string | undefined + setCurrentPluginID: (pluginID?: string) => void +} + +let mockContextState: PluginPageContextState +let mockPluginList: PluginDetail[] +let mockPluginListLoading = false +let mockPluginListFetching = false +let mockIsLastPage = true + +vi.mock('ahooks', () => ({ + useDebounceFn: (fn: (...args: unknown[]) => void) => ({ + run: fn, + }), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en-US', +})) + +vi.mock('@/i18n-config', () => ({ + renderI18nObject: (value: Record | undefined, locale: string) => + value?.[locale] ?? value?.['en-US'] ?? '', +})) + +vi.mock('@/service/use-plugins', () => ({ + useInstalledPluginList: () => ({ + data: { plugins: mockPluginList }, + isLoading: mockPluginListLoading, + isFetching: mockPluginListFetching, + isLastPage: mockIsLastPage, + loadNextPage: mockLoadNextPage, + }), + useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList, +})) + +vi.mock('../../hooks', () => ({ + usePluginsWithLatestVersion: (plugins: PluginDetail[] | undefined) => plugins ?? [], +})) + +vi.mock('../context', () => ({ + usePluginPageContext: (selector: (value: PluginPageContextState) => unknown) => selector(mockContextState), +})) + +vi.mock('../filter-management', () => ({ + default: ({ onFilterChange }: { + onFilterChange: (filters: { + categories: string[] + tags: string[] + searchQuery: string + }) => void + }) => ( + + ), +})) + +vi.mock('../list', () => ({ + default: ({ pluginList }: { pluginList: PluginDetail[] }) => ( +
+ {pluginList.map(plugin => plugin.plugin_id).join(',')} +
+ ), +})) + +vi.mock('../empty', () => ({ + default: () =>
empty
, +})) + +vi.mock('@/app/components/base/loading', () => ({ + default: ({ className, type }: { className?: string, type?: string }) => ( +
loading
+ ), +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, ...props }: React.ButtonHTMLAttributes) => React.createElement('button', props, children), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({ + default: ({ + detail, + onUpdate, + onHide, + }: { + detail?: PluginDetail + onUpdate: () => void + onHide: () => void + }) => ( +
+ {detail?.plugin_id ?? 'none'} + + +
+ ), +})) + +const createPluginDeclaration = (overrides: Partial = {}): PluginDeclaration => ({ + plugin_unique_identifier: 'plugin.unique', + version: '1.0.0', + author: 'Plugin Author', + icon: 'icon', + name: 'Declaration Name', + category: PluginCategoryEnum.tool, + label: { 'en-US': 'Label Text' } as PluginDeclaration['label'], + description: { 'en-US': 'Description Text' } as PluginDeclaration['description'], + created_at: '2024-01-01T00:00:00Z', + resource: {} as PluginDeclaration['resource'], + plugins: {} as PluginDeclaration['plugins'], + verified: false, + endpoint: { settings: [], endpoints: [] }, + tool: undefined, + datasource: undefined, + model: {} as PluginDeclaration['model'], + tags: ['featured'], + agent_strategy: {} as PluginDeclaration['agent_strategy'], + meta: { version: '1.0.0' }, + trigger: {} as PluginDeclaration['trigger'], + ...overrides, +}) + +const createPlugin = (overrides: Partial = {}): PluginDetail => ({ + id: 'plugin-id', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + name: 'Plugin Name', + plugin_id: 'plugin.id', + plugin_unique_identifier: 'plugin.unique', + declaration: createPluginDeclaration(), + installation_id: 'installation-id', + tenant_id: 'tenant-id', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '1.0.1', + latest_unique_identifier: 'plugin.unique@1.0.1', + source: PluginSource.marketplace, + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', + ...overrides, +}) + +describe('PluginsPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + mockContextState = { + filters: { + categories: [], + tags: [], + searchQuery: '', + }, + setFilters: mockSetFilters, + currentPluginID: undefined, + setCurrentPluginID: mockSetCurrentPluginID, + } + mockPluginList = [createPlugin()] + mockPluginListLoading = false + mockPluginListFetching = false + mockIsLastPage = true + }) + + it('should render loading while the plugin list is loading', () => { + mockPluginListLoading = true + + render() + + expect(screen.getByTestId('loading')).toHaveAttribute('data-type', 'app') + expect(screen.getByTestId('plugin-detail-id')).toHaveTextContent('none') + }) + + it('should update filters through the debounced callback', () => { + render() + + fireEvent.click(screen.getByTestId('filter-management')) + + expect(mockSetFilters).toHaveBeenCalledWith({ + categories: ['tool'], + tags: ['featured'], + searchQuery: 'needle', + }) + }) + + it('should filter plugins by category and tag', () => { + mockContextState.filters = { + categories: ['tool'], + tags: ['featured'], + searchQuery: '', + } + + render() + + expect(screen.getByTestId('plugin-list')).toHaveTextContent('plugin.id') + }) + + it('should filter plugins by plugin id', () => { + const plugin = createPlugin({ + plugin_id: 'needle-id', + name: 'Plugin Name', + declaration: createPluginDeclaration({ + name: 'Declaration Name', + label: { 'en-US': 'Label Text' } as PluginDeclaration['label'], + description: { 'en-US': 'Description Text' } as PluginDeclaration['description'], + }), + }) + + mockPluginList = [plugin] + mockContextState.filters = { + categories: [], + tags: [], + searchQuery: 'needle-id', + } + + render() + + expect(screen.getByTestId('plugin-list')).toHaveTextContent('needle-id') + }) + + it('should filter plugins by plugin name, declaration name, label, and description', () => { + const plugin = createPlugin({ + plugin_id: 'plugin-id', + name: 'Needle Name', + declaration: createPluginDeclaration({ + name: 'Needle Declaration', + label: { 'en-US': 'Needle Label' } as PluginDeclaration['label'], + description: { 'en-US': 'Needle Description' } as PluginDeclaration['description'], + }), + }) + + mockPluginList = [plugin] + const { rerender } = render() + + mockContextState.filters = { + categories: [], + tags: [], + searchQuery: 'needle name', + } + rerender() + expect(screen.getByTestId('plugin-list')).toHaveTextContent('plugin-id') + + mockContextState.filters = { + categories: [], + tags: [], + searchQuery: 'needle declaration', + } + rerender() + expect(screen.getByTestId('plugin-list')).toHaveTextContent('plugin-id') + + mockContextState.filters = { + categories: [], + tags: [], + searchQuery: 'needle label', + } + rerender() + expect(screen.getByTestId('plugin-list')).toHaveTextContent('plugin-id') + + mockContextState.filters = { + categories: [], + tags: [], + searchQuery: 'needle description', + } + rerender() + expect(screen.getByTestId('plugin-list')).toHaveTextContent('plugin-id') + }) + + it('should render empty state when no plugin matches the filters', () => { + mockContextState.filters = { + categories: ['model'], + tags: [], + searchQuery: '', + } + + render() + + expect(screen.getByTestId('empty')).toBeInTheDocument() + expect(screen.queryByTestId('plugin-list')).not.toBeInTheDocument() + }) + + it('should render empty state when the search query does not match any plugin field', () => { + mockContextState.filters = { + categories: [], + tags: [], + searchQuery: 'missing-value', + } + + render() + + expect(screen.getByTestId('empty')).toBeInTheDocument() + expect(screen.queryByTestId('plugin-list')).not.toBeInTheDocument() + }) + + it('should render a load more button and request the next page', () => { + mockIsLastPage = false + + render() + + fireEvent.click(screen.getByText('common.loadMore')) + + expect(mockLoadNextPage).toHaveBeenCalledTimes(1) + }) + + it('should render the inline loader when fetching more items', () => { + mockIsLastPage = false + mockPluginListFetching = true + + render() + + expect(screen.getByTestId('loading')).toHaveAttribute('data-class-name', 'size-8') + expect(screen.queryByText('common.loadMore')).not.toBeInTheDocument() + }) + + it('should pass the selected plugin to the detail panel and handle its callbacks', () => { + mockContextState.currentPluginID = 'plugin.id' + + render() + + expect(screen.getByTestId('plugin-detail-id')).toHaveTextContent('plugin.id') + + fireEvent.click(screen.getByTestId('plugin-detail-update')) + fireEvent.click(screen.getByTestId('plugin-detail-hide')) + + expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1) + expect(mockSetCurrentPluginID).toHaveBeenCalledWith(undefined) + }) +})