From 1ce6e279f0ee0f6de67528333651521fd9e08a6f Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Thu, 9 Apr 2026 15:30:51 +0800 Subject: [PATCH] test: add unit tests for AppPublisher, Sidebar, Chat, FileUploader, Form Demo, Notion Page Selector, Prompt Editor, and Header Navigation components (#34802) Co-authored-by: CodingOnStar --- .../app-sidebar/dataset-info-flow.test.tsx | 224 +++++++++++++ .../app-sidebar/sidebar-shell-flow.test.tsx | 199 ++++++++++++ .../app/app-access-control-flow.test.tsx | 139 +++++++++ web/__tests__/app/app-publisher-flow.test.tsx | 243 +++++++++++++++ web/__tests__/base/chat-flow.test.tsx | 154 +++++++++ .../base/file-uploader-flow.test.tsx | 106 +++++++ web/__tests__/base/form-demo-flow.test.tsx | 65 ++++ .../base/notion-page-selector-flow.test.tsx | 151 +++++++++ .../base/prompt-editor-flow.test.tsx | 191 ++++++++++++ .../custom/custom-page-flow.test.tsx | 107 +++++++ .../header/account-dropdown-flow.test.tsx | 182 +++++++++++ web/__tests__/header/nav-flow.test.tsx | 237 ++++++++++++++ .../plugins/plugin-page-shell-flow.test.tsx | 163 ++++++++++ .../share/text-generation-mode-flow.test.tsx | 155 +++++++++ .../tools/provider-list-shell-flow.test.tsx | 205 ++++++++++++ .../__tests__/dropdown-callbacks.spec.tsx | 50 +++ .../dataset-info/__tests__/index.spec.tsx | 68 +++- .../apps/__tests__/app-card-skeleton.spec.tsx | 24 ++ .../chat/__tests__/chat-log-modals.spec.tsx | 144 +++++++++ .../chat/__tests__/use-chat-layout.spec.tsx | 293 +++++++++++++++++ .../base/chat/chat/chat-log-modals.tsx | 56 ++++ web/app/components/base/chat/chat/index.tsx | 176 ++--------- .../base/chat/chat/use-chat-layout.ts | 144 +++++++++ .../page-selector/__tests__/page-row.spec.tsx | 113 +++++++ .../__tests__/use-page-selector-model.spec.ts | 127 ++++++++ .../page-selector/__tests__/utils.spec.ts | 118 +++++++ .../__tests__/virtual-page-list.spec.tsx | 144 +++++++++ .../__tests__/prompt-editor-content.spec.tsx | 295 ++++++++++++++++++ .../components/base/prompt-editor/index.tsx | 182 +---------- .../prompt-editor/prompt-editor-content.tsx | 257 +++++++++++++++ .../preview/__tests__/loading.spec.tsx | 30 ++ .../detail/__tests__/context.spec.tsx | 30 ++ .../__tests__/segment-list-context.spec.tsx | 55 ++++ .../hooks/__tests__/use-doc-toc.spec.tsx | 125 ++++++++ .../actions/__tests__/node-actions.spec.ts | 69 ++++ .../actions/commands/__tests__/slash.spec.tsx | 124 ++++++++ .../__tests__/menu-item-content.spec.tsx | 28 ++ .../model-auth/__tests__/index.spec.ts | 23 ++ .../__tests__/credits-fallback-alert.spec.tsx | 18 ++ .../__tests__/downloading-icon.spec.tsx | 16 + .../text-generation/__tests__/index.spec.tsx | 219 +++++++++++++ .../hooks/__tests__/use-is-chat-mode.spec.ts | 41 +++ web/eslint-suppressions.json | 6 - web/service/fetch.ts | 12 +- 44 files changed, 5177 insertions(+), 331 deletions(-) create mode 100644 web/__tests__/app-sidebar/dataset-info-flow.test.tsx create mode 100644 web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx create mode 100644 web/__tests__/app/app-access-control-flow.test.tsx create mode 100644 web/__tests__/app/app-publisher-flow.test.tsx create mode 100644 web/__tests__/base/chat-flow.test.tsx create mode 100644 web/__tests__/base/file-uploader-flow.test.tsx create mode 100644 web/__tests__/base/form-demo-flow.test.tsx create mode 100644 web/__tests__/base/notion-page-selector-flow.test.tsx create mode 100644 web/__tests__/base/prompt-editor-flow.test.tsx create mode 100644 web/__tests__/custom/custom-page-flow.test.tsx create mode 100644 web/__tests__/header/account-dropdown-flow.test.tsx create mode 100644 web/__tests__/header/nav-flow.test.tsx create mode 100644 web/__tests__/plugins/plugin-page-shell-flow.test.tsx create mode 100644 web/__tests__/share/text-generation-mode-flow.test.tsx create mode 100644 web/__tests__/tools/provider-list-shell-flow.test.tsx create mode 100644 web/app/components/apps/__tests__/app-card-skeleton.spec.tsx create mode 100644 web/app/components/base/chat/chat/__tests__/chat-log-modals.spec.tsx create mode 100644 web/app/components/base/chat/chat/__tests__/use-chat-layout.spec.tsx create mode 100644 web/app/components/base/chat/chat/chat-log-modals.tsx create mode 100644 web/app/components/base/chat/chat/use-chat-layout.ts create mode 100644 web/app/components/base/notion-page-selector/page-selector/__tests__/page-row.spec.tsx create mode 100644 web/app/components/base/notion-page-selector/page-selector/__tests__/use-page-selector-model.spec.ts create mode 100644 web/app/components/base/notion-page-selector/page-selector/__tests__/utils.spec.ts create mode 100644 web/app/components/base/notion-page-selector/page-selector/__tests__/virtual-page-list.spec.tsx create mode 100644 web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx create mode 100644 web/app/components/base/prompt-editor/prompt-editor-content.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/loading.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/__tests__/context.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/__tests__/segment-list-context.spec.tsx create mode 100644 web/app/components/develop/hooks/__tests__/use-doc-toc.spec.tsx create mode 100644 web/app/components/goto-anything/actions/__tests__/node-actions.spec.ts create mode 100644 web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx create mode 100644 web/app/components/header/account-dropdown/__tests__/menu-item-content.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/index.spec.ts create mode 100644 web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/credits-fallback-alert.spec.tsx create mode 100644 web/app/components/header/plugins-nav/__tests__/downloading-icon.spec.tsx create mode 100644 web/app/components/share/text-generation/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow-app/hooks/__tests__/use-is-chat-mode.spec.ts diff --git a/web/__tests__/app-sidebar/dataset-info-flow.test.tsx b/web/__tests__/app-sidebar/dataset-info-flow.test.tsx new file mode 100644 index 0000000000..d1ca233d96 --- /dev/null +++ b/web/__tests__/app-sidebar/dataset-info-flow.test.tsx @@ -0,0 +1,224 @@ +import type { DataSet } from '@/models/datasets' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import DatasetInfo from '@/app/components/app-sidebar/dataset-info' +import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' + +const mockReplace = vi.fn() +const mockInvalidDatasetList = vi.fn() +const mockInvalidDatasetDetail = vi.fn() +const mockExportPipeline = vi.fn() +const mockCheckIsUsedInApp = vi.fn() +const mockDeleteDataset = vi.fn() +const mockDownloadBlob = vi.fn() + +let mockDataset: DataSet + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ + replace: mockReplace, + }), +})) + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => selector({ + dataset: mockDataset, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useSelector: (selector: (state: { isCurrentWorkspaceDatasetOperator: boolean }) => unknown) => + selector({ isCurrentWorkspaceDatasetOperator: false }), +})) + +vi.mock('@/hooks/use-knowledge', () => ({ + useKnowledge: () => ({ + formatIndexingTechniqueAndMethod: () => 'indexing-technique', + }), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + datasetDetailQueryKeyPrefix: ['dataset', 'detail'], + useInvalidDatasetList: () => mockInvalidDatasetList, +})) + +vi.mock('@/service/use-base', () => ({ + useInvalid: () => mockInvalidDatasetDetail, +})) + +vi.mock('@/service/use-pipeline', () => ({ + useExportPipelineDSL: () => ({ + mutateAsync: mockExportPipeline, + }), +})) + +vi.mock('@/service/datasets', () => ({ + checkIsUsedInApp: (...args: unknown[]) => mockCheckIsUsedInApp(...args), + deleteDataset: (...args: unknown[]) => mockDeleteDataset(...args), +})) + +vi.mock('@/utils/download', () => ({ + downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args), +})) + +vi.mock('@/app/components/datasets/rename-modal', () => ({ + default: ({ + show, + onClose, + onSuccess, + }: { + show: boolean + onClose: () => void + onSuccess: () => void + }) => show + ? ( +
+ + +
+ ) + : null, +})) + +const createDataset = (overrides: Partial = {}): DataSet => ({ + id: 'dataset-1', + name: 'Dataset Name', + indexing_status: 'completed', + icon_info: { + icon: '📙', + icon_background: '#FFF4ED', + icon_type: 'emoji', + icon_url: '', + }, + description: 'Dataset description', + permission: DatasetPermission.onlyMe, + data_source_type: DataSourceType.FILE, + indexing_technique: 'high_quality' as DataSet['indexing_technique'], + created_by: 'user-1', + updated_by: 'user-1', + updated_at: 1690000000, + app_count: 0, + doc_form: ChunkingMode.text, + document_count: 1, + total_document_count: 1, + word_count: 1000, + provider: 'internal', + embedding_model: 'text-embedding-3', + embedding_model_provider: 'openai', + embedding_available: true, + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 5, + score_threshold_enabled: false, + score_threshold: 0, + }, + retrieval_model: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 5, + score_threshold_enabled: false, + score_threshold: 0, + }, + tags: [], + external_knowledge_info: { + external_knowledge_id: '', + external_knowledge_api_id: '', + external_knowledge_api_name: '', + external_knowledge_api_endpoint: '', + }, + external_retrieval_model: { + top_k: 0, + score_threshold: 0, + score_threshold_enabled: false, + }, + built_in_field_enabled: false, + runtime_mode: 'rag_pipeline', + pipeline_id: 'pipeline-1', + enable_api: false, + is_multimodal: false, + is_published: true, + ...overrides, +}) + +const openDropdown = () => { + fireEvent.click(screen.getByRole('button')) +} + +describe('App Sidebar Dataset Info Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDataset = createDataset() + mockExportPipeline.mockResolvedValue({ data: 'pipeline: demo' }) + mockCheckIsUsedInApp.mockResolvedValue({ is_using: false }) + mockDeleteDataset.mockResolvedValue({}) + }) + + it('exports the published pipeline from the dropdown menu', async () => { + render() + + expect(screen.getByText('Dataset Name')).toBeInTheDocument() + + openDropdown() + fireEvent.click(await screen.findByText('datasetPipeline.operations.exportPipeline')) + + await waitFor(() => { + expect(mockExportPipeline).toHaveBeenCalledWith({ + pipelineId: 'pipeline-1', + include: false, + }) + expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({ + fileName: 'Dataset Name.pipeline', + })) + }) + }) + + it('opens the rename modal and refreshes dataset caches after a successful rename', async () => { + render() + + openDropdown() + fireEvent.click(await screen.findByText('common.operation.edit')) + + expect(screen.getByTestId('rename-dataset-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'rename-success' })) + + expect(mockInvalidDatasetList).toHaveBeenCalledTimes(1) + expect(mockInvalidDatasetDetail).toHaveBeenCalledTimes(1) + }) + + it('checks app usage before deleting and redirects back to the dataset list after confirmation', async () => { + render() + + openDropdown() + fireEvent.click(await screen.findByText('common.operation.delete')) + + await waitFor(() => { + expect(mockCheckIsUsedInApp).toHaveBeenCalledWith('dataset-1') + expect(screen.getByText('dataset.deleteDatasetConfirmTitle')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) + + await waitFor(() => { + expect(mockDeleteDataset).toHaveBeenCalledWith('dataset-1') + expect(mockInvalidDatasetList).toHaveBeenCalled() + expect(mockReplace).toHaveBeenCalledWith('/datasets') + }) + }) +}) diff --git a/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx b/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx new file mode 100644 index 0000000000..3e3edba5dd --- /dev/null +++ b/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx @@ -0,0 +1,199 @@ +import type { SVGProps } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AppDetailNav from '@/app/components/app-sidebar' + +const mockSetAppSidebarExpand = vi.fn() + +let mockAppSidebarExpand = 'expand' +let mockPathname = '/app/app-1/logs' +let mockSelectedSegment = 'logs' +let mockIsHovering = true +let keyPressHandler: ((event: { preventDefault: () => void }) => void) | null = null + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: Record) => unknown) => selector({ + appDetail: { + id: 'app-1', + name: 'Demo App', + mode: 'chat', + icon: '🤖', + icon_type: 'emoji', + icon_background: '#FFEAD5', + icon_url: null, + }, + appSidebarExpand: mockAppSidebarExpand, + setAppSidebarExpand: mockSetAppSidebarExpand, + }), +})) + +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: unknown) => selector, +})) + +vi.mock('@/next/navigation', () => ({ + usePathname: () => mockPathname, + useSelectedLayoutSegment: () => mockSelectedSegment, +})) + +vi.mock('@/next/link', () => ({ + default: ({ + href, + children, + className, + title, + }: { + href: string + children?: React.ReactNode + className?: string + title?: string + }) => ( + + {children} + + ), +})) + +vi.mock('ahooks', () => ({ + useHover: () => mockIsHovering, + useKeyPress: (_key: string, handler: (event: { preventDefault: () => void }) => void) => { + keyPressHandler = handler + }, +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + default: () => 'desktop', + MediaType: { + mobile: 'mobile', + desktop: 'desktop', + }, +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { + useSubscription: vi.fn(), + }, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceEditor: true, + }), +})) + +vi.mock('@/app/components/workflow/utils', () => ({ + getKeyboardKeyCodeBySystem: () => 'ctrl', + getKeyboardKeyNameBySystem: (key: string) => key, +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', async () => { + const React = await vi.importActual('react') + const OpenContext = React.createContext(false) + + return { + PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( + +
{children}
+
+ ), + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: () => void + }) => ( + + ), + PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => { + const open = React.useContext(OpenContext) + return open ?
{children}
: null + }, + } +}) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children }: { children?: React.ReactNode }) => <>{children}, +})) + +vi.mock('@/app/components/app-sidebar/app-info', () => ({ + default: ({ + expand, + onlyShowDetail, + openState, + }: { + expand: boolean + onlyShowDetail?: boolean + openState?: boolean + }) => ( +
+ ), +})) + +const MockIcon = (props: SVGProps) => + +const navigation = [ + { name: 'Overview', href: '/app/app-1/overview', icon: MockIcon, selectedIcon: MockIcon }, + { name: 'Logs', href: '/app/app-1/logs', icon: MockIcon, selectedIcon: MockIcon }, +] + +describe('App Sidebar Shell Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mockAppSidebarExpand = 'expand' + mockPathname = '/app/app-1/logs' + mockSelectedSegment = 'logs' + mockIsHovering = true + keyPressHandler = null + }) + + it('renders the expanded sidebar, marks the active nav item, and toggles collapse by click and shortcut', () => { + render() + + expect(screen.getByTestId('app-info')).toHaveAttribute('data-expand', 'true') + + const logsLink = screen.getByRole('link', { name: /Logs/i }) + expect(logsLink.className).toContain('bg-components-menu-item-bg-active') + + fireEvent.click(screen.getByRole('button')) + expect(mockSetAppSidebarExpand).toHaveBeenCalledWith('collapse') + + const preventDefault = vi.fn() + keyPressHandler?.({ preventDefault }) + + expect(preventDefault).toHaveBeenCalled() + expect(mockSetAppSidebarExpand).toHaveBeenCalledWith('collapse') + }) + + it('switches to the workflow fullscreen dropdown shell and opens its navigation menu', () => { + mockPathname = '/app/app-1/workflow' + mockSelectedSegment = 'workflow' + localStorage.setItem('workflow-canvas-maximize', 'true') + + render() + + expect(screen.queryByTestId('app-info')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + expect(screen.getByText('Demo App')).toBeInTheDocument() + expect(screen.getByRole('link', { name: /Overview/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /Logs/i })).toBeInTheDocument() + }) +}) diff --git a/web/__tests__/app/app-access-control-flow.test.tsx b/web/__tests__/app/app-access-control-flow.test.tsx new file mode 100644 index 0000000000..49443eb4ec --- /dev/null +++ b/web/__tests__/app/app-access-control-flow.test.tsx @@ -0,0 +1,139 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AppPublisher from '@/app/components/app/app-publisher' +import { AccessMode } from '@/models/access-control' +import { AppModeEnum } from '@/types/app' + +const mockFetchAppDetailDirect = vi.fn() +const mockSetAppDetail = vi.fn() +const mockRefetch = vi.fn() + +let mockAppDetail: { + id: string + name: string + mode: AppModeEnum + access_mode: AccessMode + description: string + icon: string + icon_type: string + icon_background: string + site: { + app_base_url: string + access_token: string + } +} | null = null + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: Record) => unknown) => selector({ + appDetail: mockAppDetail, + setAppDetail: mockSetAppDetail, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: Record) => unknown) => selector({ + systemFeatures: { + webapp_auth: { + enabled: true, + }, + }, + }), +})) + +vi.mock('@/hooks/use-format-time-from-now', () => ({ + useFormatTimeFromNow: () => ({ + formatTimeFromNow: (value: number) => `ago:${value}`, + }), +})) + +vi.mock('@/hooks/use-async-window-open', () => ({ + useAsyncWindowOpen: () => vi.fn(), +})) + +vi.mock('@/service/access-control', () => ({ + useGetUserCanAccessApp: () => ({ + data: { result: true }, + isLoading: false, + refetch: mockRefetch, + }), + useAppWhiteListSubjects: () => ({ + data: { groups: [], members: [] }, + isLoading: false, + }), +})) + +vi.mock('@/service/apps', () => ({ + fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args), +})) + +vi.mock('@/app/components/app/overview/embedded', () => ({ + default: () => null, +})) + +vi.mock('@/app/components/app/app-access-control', () => ({ + default: ({ + onConfirm, + onClose, + }: { + onConfirm: () => Promise + onClose: () => void + }) => ( +
+ + +
+ ), +})) + +describe('App Access Control Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAppDetail = { + id: 'app-1', + name: 'Demo App', + mode: AppModeEnum.CHAT, + access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, + description: 'Demo app description', + icon: '🤖', + icon_type: 'emoji', + icon_background: '#FFEAD5', + site: { + app_base_url: 'https://example.com', + access_token: 'token-1', + }, + } + mockFetchAppDetailDirect.mockResolvedValue({ + ...mockAppDetail, + access_mode: AccessMode.PUBLIC, + }) + }) + + it('refreshes app detail after confirming access control updates', async () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.publish' })) + fireEvent.click(screen.getByText('app.accessControlDialog.accessItems.specific')) + + expect(screen.getByTestId('access-control-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'confirm-access-control' })) + + await waitFor(() => { + expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) + expect(mockSetAppDetail).toHaveBeenCalledWith(expect.objectContaining({ + id: 'app-1', + access_mode: AccessMode.PUBLIC, + })) + }) + + await waitFor(() => { + expect(screen.queryByTestId('access-control-modal')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/__tests__/app/app-publisher-flow.test.tsx b/web/__tests__/app/app-publisher-flow.test.tsx new file mode 100644 index 0000000000..5c330cf71e --- /dev/null +++ b/web/__tests__/app/app-publisher-flow.test.tsx @@ -0,0 +1,243 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AppPublisher from '@/app/components/app/app-publisher' +import { AccessMode } from '@/models/access-control' +import { AppModeEnum } from '@/types/app' + +const mockTrackEvent = vi.fn() +const mockRefetch = vi.fn() +const mockFetchInstalledAppList = vi.fn() +const mockFetchAppDetailDirect = vi.fn() +const mockToastError = vi.fn() +const mockOpenAsyncWindow = vi.fn() +const mockSetAppDetail = vi.fn() + +let mockAppDetail: { + id: string + name: string + mode: AppModeEnum + access_mode: AccessMode + description: string + icon: string + icon_type: string + icon_background: string + site: { + app_base_url: string + access_token: string + } +} | null = null + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('ahooks', () => ({ + useKeyPress: vi.fn(), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: Record) => unknown) => selector({ + appDetail: mockAppDetail, + setAppDetail: mockSetAppDetail, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: Record) => unknown) => selector({ + systemFeatures: { + webapp_auth: { + enabled: true, + }, + }, + }), +})) + +vi.mock('@/hooks/use-format-time-from-now', () => ({ + useFormatTimeFromNow: () => ({ + formatTimeFromNow: (value: number) => `ago:${value}`, + }), +})) + +vi.mock('@/hooks/use-async-window-open', () => ({ + useAsyncWindowOpen: () => mockOpenAsyncWindow, +})) + +vi.mock('@/service/access-control', () => ({ + useGetUserCanAccessApp: () => ({ + data: { result: true }, + isLoading: false, + refetch: mockRefetch, + }), + useAppWhiteListSubjects: () => ({ + data: { groups: [], members: [] }, + isLoading: false, + }), +})) + +vi.mock('@/service/explore', () => ({ + fetchInstalledAppList: (...args: unknown[]) => mockFetchInstalledAppList(...args), +})) + +vi.mock('@/service/apps', () => ({ + fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: (...args: unknown[]) => mockToastError(...args), + }, +})) + +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: (...args: unknown[]) => mockTrackEvent(...args), +})) + +vi.mock('@/app/components/app/overview/embedded', () => ({ + default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => ( + isShow + ? ( +
+ +
+ ) + : null + ), +})) + +vi.mock('@/app/components/app/app-access-control', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', async () => { + const React = await vi.importActual('react') + const OpenContext = React.createContext(false) + + return { + PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( + +
{children}
+
+ ), + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: () => void + }) => ( +
+ {children} +
+ ), + PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => { + const open = React.useContext(OpenContext) + return open ?
{children}
: null + }, + } +}) + +vi.mock('@/app/components/workflow/utils', () => ({ + getKeyboardKeyCodeBySystem: () => 'ctrl', + getKeyboardKeyNameBySystem: (key: string) => key, +})) + +describe('App Publisher Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAppDetail = { + id: 'app-1', + name: 'Demo App', + mode: AppModeEnum.CHAT, + access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, + description: 'Demo app description', + icon: '🤖', + icon_type: 'emoji', + icon_background: '#FFEAD5', + site: { + app_base_url: 'https://example.com', + access_token: 'token-1', + }, + } + mockFetchInstalledAppList.mockResolvedValue({ + installed_apps: [{ id: 'installed-1' }], + }) + mockFetchAppDetailDirect.mockResolvedValue({ + id: 'app-1', + access_mode: AccessMode.PUBLIC, + }) + mockOpenAsyncWindow.mockImplementation(async ( + resolver: () => Promise, + options?: { onError?: (error: Error) => void }, + ) => { + try { + return await resolver() + } + catch (error) { + options?.onError?.(error as Error) + } + }) + }) + + it('publishes from the summary panel and tracks the publish event', async () => { + const onPublish = vi.fn().mockResolvedValue(undefined) + + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + + expect(screen.getByText('common.latestPublished')).toBeInTheDocument() + expect(screen.getByText('common.publishUpdate')).toBeInTheDocument() + + fireEvent.click(screen.getByText('common.publishUpdate')) + + await waitFor(() => { + expect(onPublish).toHaveBeenCalledTimes(1) + expect(mockTrackEvent).toHaveBeenCalledWith('app_published_time', expect.objectContaining({ + action_mode: 'app', + app_id: 'app-1', + app_name: 'Demo App', + })) + }) + + expect(mockRefetch).toHaveBeenCalled() + }) + + it('opens embedded modal and resolves the installed explore target', async () => { + render() + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('common.embedIntoSite')) + + expect(screen.getByTestId('embedded-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('common.openInExplore')) + + await waitFor(() => { + expect(mockFetchInstalledAppList).toHaveBeenCalledWith('app-1') + expect(mockOpenAsyncWindow).toHaveBeenCalledTimes(1) + }) + }) + + it('shows a toast error when no installed explore app is available', async () => { + mockFetchInstalledAppList.mockResolvedValue({ + installed_apps: [], + }) + + render() + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('common.openInExplore')) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith('No app found in Explore') + }) + }) +}) diff --git a/web/__tests__/base/chat-flow.test.tsx b/web/__tests__/base/chat-flow.test.tsx new file mode 100644 index 0000000000..2a02c063fd --- /dev/null +++ b/web/__tests__/base/chat-flow.test.tsx @@ -0,0 +1,154 @@ +import type { RefObject } from 'react' +import type { ChatConfig } from '@/app/components/base/chat/types' +import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share' +import { fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ChatWithHistory from '@/app/components/base/chat/chat-with-history' +import { useChatWithHistory } from '@/app/components/base/chat/chat-with-history/hooks' +import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context' +import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import useDocumentTitle from '@/hooks/use-document-title' + +vi.mock('@/app/components/base/chat/chat-with-history/hooks', () => ({ + useChatWithHistory: vi.fn(), +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + default: vi.fn(), + MediaType: { + mobile: 'mobile', + tablet: 'tablet', + pc: 'pc', + }, +})) + +vi.mock('@/hooks/use-document-title', () => ({ + default: vi.fn(), +})) + +vi.mock('@/next/navigation', () => ({ + useRouter: vi.fn(() => ({ + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + })), + usePathname: vi.fn(() => '/'), + useSearchParams: vi.fn(() => new URLSearchParams()), + useParams: vi.fn(() => ({})), +})) + +type HookReturn = ReturnType + +const mockAppData = { + site: { title: 'Test Chat', chat_color_theme: 'blue', chat_color_theme_inverted: false }, +} as unknown as AppData + +const defaultHookReturn: HookReturn = { + isInstalledApp: false, + appId: 'test-app-id', + currentConversationId: '', + currentConversationItem: undefined, + handleConversationIdInfoChange: vi.fn(), + appData: mockAppData, + appParams: {} as ChatConfig, + appMeta: {} as AppMeta, + appPinnedConversationData: { data: [] as ConversationItem[], has_more: false, limit: 20 } as AppConversationData, + appConversationData: { data: [] as ConversationItem[], has_more: false, limit: 20 } as AppConversationData, + appConversationDataLoading: false, + appChatListData: { data: [] as ConversationItem[], has_more: false, limit: 20 } as AppConversationData, + appChatListDataLoading: false, + appPrevChatTree: [], + pinnedConversationList: [], + conversationList: [], + setShowNewConversationItemInList: vi.fn(), + newConversationInputs: {}, + newConversationInputsRef: { current: {} } as unknown as RefObject>, + handleNewConversationInputsChange: vi.fn(), + inputsForms: [], + handleNewConversation: vi.fn(), + handleStartChat: vi.fn(), + handleChangeConversation: vi.fn(), + handlePinConversation: vi.fn(), + handleUnpinConversation: vi.fn(), + conversationDeleting: false, + handleDeleteConversation: vi.fn(), + conversationRenaming: false, + handleRenameConversation: vi.fn(), + handleNewConversationCompleted: vi.fn(), + newConversationId: '', + chatShouldReloadKey: 'test-reload-key', + handleFeedback: vi.fn(), + currentChatInstanceRef: { current: { handleStop: vi.fn() } }, + sidebarCollapseState: false, + handleSidebarCollapse: vi.fn(), + clearChatList: false, + setClearChatList: vi.fn(), + isResponding: false, + setIsResponding: vi.fn(), + currentConversationInputs: {}, + setCurrentConversationInputs: vi.fn(), + allInputsHidden: false, + initUserVariables: {}, +} + +describe('Base Chat Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useBreakpoints).mockReturnValue(MediaType.pc) + vi.mocked(useChatWithHistory).mockReturnValue(defaultHookReturn) + renderHook(() => useThemeContext()).result.current.buildTheme() + }) + + // Chat-with-history shell integration across layout, responsive shell, and theme setup. + describe('Chat With History Shell', () => { + it('builds theme, updates the document title, and expands the collapsed desktop sidebar on hover', async () => { + const themeBuilder = renderHook(() => useThemeContext()).result.current + const { container } = render() + + const titles = screen.getAllByText('Test Chat') + expect(titles.length).toBeGreaterThan(0) + expect(useDocumentTitle).toHaveBeenCalledWith('Test Chat') + + await waitFor(() => { + expect(themeBuilder.theme.primaryColor).toBe('blue') + expect(themeBuilder.theme.chatColorThemeInverted).toBe(false) + }) + + vi.mocked(useChatWithHistory).mockReturnValue({ + ...defaultHookReturn, + sidebarCollapseState: true, + }) + + const { container: collapsedContainer } = render() + const hoverArea = collapsedContainer.querySelector('.absolute.top-0.z-20') + + expect(container.querySelector('.chat-history-shell')).toBeInTheDocument() + expect(hoverArea).toBeInTheDocument() + + if (hoverArea) { + fireEvent.mouseEnter(hoverArea) + expect(hoverArea).toHaveClass('left-0') + + fireEvent.mouseLeave(hoverArea) + expect(hoverArea).toHaveClass('left-[-248px]') + } + }) + + it('falls back to the mobile loading shell when site metadata is unavailable', () => { + vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile) + vi.mocked(useChatWithHistory).mockReturnValue({ + ...defaultHookReturn, + appData: null, + appChatListDataLoading: true, + }) + + const { container } = render() + + expect(useDocumentTitle).toHaveBeenCalledWith('Chat') + expect(screen.getByRole('status')).toBeInTheDocument() + expect(container.querySelector('.mobile-chat-shell')).toBeInTheDocument() + expect(container.querySelector('.rounded-t-2xl')).toBeInTheDocument() + expect(container.querySelector('.rounded-2xl')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/__tests__/base/file-uploader-flow.test.tsx b/web/__tests__/base/file-uploader-flow.test.tsx new file mode 100644 index 0000000000..81dccedfe5 --- /dev/null +++ b/web/__tests__/base/file-uploader-flow.test.tsx @@ -0,0 +1,106 @@ +import type { FileUpload } from '@/app/components/base/features/types' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import FileUploaderInAttachmentWrapper from '@/app/components/base/file-uploader/file-uploader-in-attachment' +import FileUploaderInChatInput from '@/app/components/base/file-uploader/file-uploader-in-chat-input' +import { FileContextProvider } from '@/app/components/base/file-uploader/store' +import { TransferMethod } from '@/types/app' + +const mockUploadRemoteFileInfo = vi.fn() + +vi.mock('@/next/navigation', () => ({ + useParams: () => ({}), +})) + +vi.mock('@/service/common', () => ({ + uploadRemoteFileInfo: (...args: unknown[]) => mockUploadRemoteFileInfo(...args), +})) + +const createFileConfig = (overrides: Partial = {}): FileUpload => ({ + enabled: true, + allowed_file_types: ['document'], + allowed_file_extensions: [], + allowed_file_upload_methods: [TransferMethod.remote_url], + number_limits: 5, + preview_config: { + enabled: false, + mode: 'current_page', + file_type_list: [], + }, + ...overrides, +} as FileUpload) + +const renderChatInput = (fileConfig: FileUpload, readonly = false) => { + return render( + + + , + ) +} + +describe('Base File Uploader Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUploadRemoteFileInfo.mockResolvedValue({ + id: 'remote-file-1', + mime_type: 'application/pdf', + size: 2048, + name: 'guide.pdf', + url: 'https://cdn.example.com/guide.pdf', + }) + }) + + it('uploads a remote file from the attachment wrapper and pushes the updated file list to consumers', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + + render( + , + ) + + await user.click(screen.getByRole('button', { name: /fileUploader\.pasteFileLink/i })) + await user.type(screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/i), 'https://example.com/guide.pdf') + await user.click(screen.getByRole('button', { name: /operation\.ok/i })) + + await waitFor(() => { + expect(mockUploadRemoteFileInfo).toHaveBeenCalledWith('https://example.com/guide.pdf', false) + }) + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith([ + expect.objectContaining({ + name: 'https://example.com/guide.pdf', + uploadedId: 'remote-file-1', + url: 'https://cdn.example.com/guide.pdf', + progress: 100, + }), + ]) + }) + + expect(screen.getByText('https://example.com/guide.pdf')).toBeInTheDocument() + }) + + it('opens the link picker from chat input and keeps the trigger disabled in readonly mode', async () => { + const user = userEvent.setup() + const fileConfig = createFileConfig() + + const { unmount } = renderChatInput(fileConfig) + + const activeTrigger = screen.getByRole('button') + expect(activeTrigger).toBeEnabled() + + await user.click(activeTrigger) + expect(screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/i)).toBeInTheDocument() + expect(screen.queryByText(/fileUploader\.uploadFromComputer/i)).not.toBeInTheDocument() + + unmount() + renderChatInput(fileConfig, true) + + expect(screen.getByRole('button')).toBeDisabled() + }) +}) diff --git a/web/__tests__/base/form-demo-flow.test.tsx b/web/__tests__/base/form-demo-flow.test.tsx new file mode 100644 index 0000000000..afb36528c0 --- /dev/null +++ b/web/__tests__/base/form-demo-flow.test.tsx @@ -0,0 +1,65 @@ +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import DemoForm from '@/app/components/base/form/form-scenarios/demo' + +describe('Base Form Demo Flow', () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('reveals contact fields and submits the composed form values through the shared form actions', async () => { + const user = userEvent.setup() + render() + + expect(screen.queryByRole('heading', { name: /contacts/i })).not.toBeInTheDocument() + + await user.type(screen.getByRole('textbox', { name: /^name$/i }), 'Alice') + await user.type(screen.getByRole('textbox', { name: /^surname$/i }), 'Smith') + await user.click(screen.getByText(/i accept the terms and conditions/i)) + + expect(await screen.findByRole('heading', { name: /contacts/i })).toBeInTheDocument() + + await user.type(screen.getByRole('textbox', { name: /^email$/i }), 'alice@example.com') + + const preferredMethodLabel = screen.getByText('Preferred Contact Method') + const preferredMethodField = preferredMethodLabel.parentElement?.parentElement + expect(preferredMethodField).toBeTruthy() + + await user.click(within(preferredMethodField as HTMLElement).getByText('Email')) + await user.click(screen.getByText('Whatsapp')) + + const submitButton = screen.getByRole('button', { name: /operation\.submit/i }) + expect(submitButton).toBeEnabled() + await user.click(submitButton) + + await waitFor(() => { + expect(consoleLogSpy).toHaveBeenCalledWith('Form submitted:', expect.objectContaining({ + name: 'Alice', + surname: 'Smith', + isAcceptingTerms: true, + contact: expect.objectContaining({ + email: 'alice@example.com', + preferredContactMethod: 'whatsapp', + }), + })) + }) + }) + + it('removes the nested contact section again when the name field is cleared', async () => { + const user = userEvent.setup() + render() + + const nameInput = screen.getByRole('textbox', { name: /^name$/i }) + await user.type(nameInput, 'Alice') + expect(await screen.findByRole('heading', { name: /contacts/i })).toBeInTheDocument() + + await user.clear(nameInput) + + await waitFor(() => { + expect(screen.queryByRole('heading', { name: /contacts/i })).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/__tests__/base/notion-page-selector-flow.test.tsx b/web/__tests__/base/notion-page-selector-flow.test.tsx new file mode 100644 index 0000000000..6295d2dc00 --- /dev/null +++ b/web/__tests__/base/notion-page-selector-flow.test.tsx @@ -0,0 +1,151 @@ +import type { DataSourceCredential } from '@/app/components/header/account-setting/data-source-page-new/types' +import type { DataSourceNotionWorkspace } from '@/models/common' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import NotionPageSelector from '@/app/components/base/notion-page-selector/base' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' +import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' + +const mockInvalidPreImportNotionPages = vi.fn() +const mockSetShowAccountSettingModal = vi.fn() +const mockUsePreImportNotionPages = vi.fn() + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ + index, + size: 28, + start: index * 28, + })), + getTotalSize: () => count * 28 + 16, + }), +})) + +vi.mock('@/service/knowledge/use-import', () => ({ + usePreImportNotionPages: (params: { datasetId: string, credentialId: string }) => mockUsePreImportNotionPages(params), + useInvalidPreImportNotionPages: () => mockInvalidPreImportNotionPages, +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContextSelector: (selector: (state: { setShowAccountSettingModal: typeof mockSetShowAccountSettingModal }) => unknown) => + selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), +})) + +const buildCredential = (id: string, name: string, workspaceName: string): DataSourceCredential => ({ + id, + name, + type: CredentialTypeEnum.OAUTH2, + is_default: false, + avatar_url: '', + credential: { + workspace_icon: '', + workspace_name: workspaceName, + }, +}) + +const credentials: DataSourceCredential[] = [ + buildCredential('c1', 'Cred 1', 'Workspace 1'), + buildCredential('c2', 'Cred 2', 'Workspace 2'), +] + +const workspacePagesByCredential: Record = { + c1: [ + { + workspace_id: 'w1', + workspace_icon: '', + workspace_name: 'Workspace 1', + pages: [ + { page_id: 'root-1', page_name: 'Root 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: false }, + { page_id: 'child-1', page_name: 'Child 1', parent_id: 'root-1', page_icon: null, type: 'page', is_bound: false }, + { page_id: 'bound-1', page_name: 'Bound 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: true }, + ], + }, + ], + c2: [ + { + workspace_id: 'w2', + workspace_icon: '', + workspace_name: 'Workspace 2', + pages: [ + { page_id: 'external-1', page_name: 'External 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: false }, + ], + }, + ], +} + +describe('Base Notion Page Selector Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUsePreImportNotionPages.mockImplementation(({ credentialId }: { credentialId: string }) => ({ + data: { + notion_info: workspacePagesByCredential[credentialId] ?? workspacePagesByCredential.c1, + }, + isFetching: false, + isError: false, + })) + }) + + it('selects a page tree, filters through search, clears search, and previews the current page', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + const onPreview = vi.fn() + + render( + , + ) + + await user.click(screen.getByTestId('checkbox-notion-page-checkbox-root-1')) + + expect(onSelect).toHaveBeenLastCalledWith(expect.arrayContaining([ + expect.objectContaining({ page_id: 'root-1', workspace_id: 'w1' }), + expect.objectContaining({ page_id: 'child-1', workspace_id: 'w1' }), + expect.objectContaining({ page_id: 'bound-1', workspace_id: 'w1' }), + ])) + + await user.type(screen.getByTestId('notion-search-input'), 'missing-page') + expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() + + await user.click(screen.getByTestId('notion-search-input-clear')) + expect(screen.getByTestId('notion-page-name-root-1')).toBeInTheDocument() + + await user.click(screen.getByTestId('notion-page-preview-root-1')) + expect(onPreview).toHaveBeenCalledWith(expect.objectContaining({ page_id: 'root-1', workspace_id: 'w1' })) + }) + + it('switches workspace credentials and opens the configuration entry point', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + const onSelectCredential = vi.fn() + + render( + , + ) + + expect(onSelectCredential).toHaveBeenCalledWith('c1') + + await user.click(screen.getByTestId('notion-credential-selector-btn')) + await user.click(screen.getByTestId('notion-credential-item-c2')) + + expect(mockInvalidPreImportNotionPages).toHaveBeenCalledWith({ datasetId: 'dataset-1', credentialId: 'c2' }) + expect(onSelect).toHaveBeenCalledWith([]) + + await waitFor(() => { + expect(onSelectCredential).toHaveBeenLastCalledWith('c2') + expect(screen.getByTestId('notion-page-name-external-1')).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'common.dataSource.notion.selector.configure' })) + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE }) + }) +}) diff --git a/web/__tests__/base/prompt-editor-flow.test.tsx b/web/__tests__/base/prompt-editor-flow.test.tsx new file mode 100644 index 0000000000..5fa96e6ee2 --- /dev/null +++ b/web/__tests__/base/prompt-editor-flow.test.tsx @@ -0,0 +1,191 @@ +import type { EventEmitter } from 'ahooks/lib/useEventEmitter' +import type { ComponentProps } from 'react' +import type { EventEmitterValue } from '@/context/event-emitter' +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { getNearestEditorFromDOMNode } from 'lexical' +import { useEffect } from 'react' +import PromptEditor from '@/app/components/base/prompt-editor' +import { + UPDATE_DATASETS_EVENT_EMITTER, + UPDATE_HISTORY_EVENT_EMITTER, +} from '@/app/components/base/prompt-editor/constants' +import { PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER } from '@/app/components/base/prompt-editor/plugins/update-block' +import { useEventEmitterContextContext } from '@/context/event-emitter' +import { EventEmitterContextProvider } from '@/context/event-emitter-provider' + +type Captures = { + eventEmitter: EventEmitter | null + events: EventEmitterValue[] +} + +const EventProbe = ({ captures }: { captures: Captures }) => { + const { eventEmitter } = useEventEmitterContextContext() + + useEffect(() => { + captures.eventEmitter = eventEmitter + }, [captures, eventEmitter]) + + eventEmitter?.useSubscription((value) => { + captures.events.push(value) + }) + + return +} + +const PromptEditorHarness = ({ + captures, + ...props +}: ComponentProps & { captures: Captures }) => ( + + + + +) + +describe('Base Prompt Editor Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Real prompt editor integration should emit block updates and transform editor updates into text output. + describe('Editor Shell', () => { + it('should render with the real editor, emit dataset/history events, and convert update events into text changes', async () => { + const captures: Captures = { eventEmitter: null, events: [] } + const onChange = vi.fn() + const onFocus = vi.fn() + const onBlur = vi.fn() + const user = userEvent.setup() + + const { rerender, container } = render( + , + ) + + expect(screen.getByText('Type prompt')).toBeInTheDocument() + + await waitFor(() => { + expect(captures.eventEmitter).not.toBeNull() + }) + + const editable = container.querySelector('[contenteditable="true"]') as HTMLElement + expect(editable).toBeInTheDocument() + + await user.click(editable) + await waitFor(() => { + expect(onFocus).toHaveBeenCalledTimes(1) + }) + + await user.click(screen.getByRole('button', { name: 'outside' })) + await waitFor(() => { + expect(onBlur).toHaveBeenCalledTimes(1) + }) + + act(() => { + captures.eventEmitter?.emit({ + type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER, + instanceId: 'editor-1', + payload: 'first line\nsecond line', + }) + }) + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith('first line\nsecond line') + }) + + expect(captures.events).toContainEqual({ + type: UPDATE_DATASETS_EVENT_EMITTER, + payload: [{ id: 'ds-1', name: 'Dataset One', type: 'dataset' }], + }) + expect(captures.events).toContainEqual({ + type: UPDATE_HISTORY_EVENT_EMITTER, + payload: { user: 'user-role', assistant: 'assistant-role' }, + }) + + rerender( + , + ) + + await waitFor(() => { + expect(captures.events).toContainEqual({ + type: UPDATE_DATASETS_EVENT_EMITTER, + payload: [{ id: 'ds-2', name: 'Dataset Two', type: 'dataset' }], + }) + }) + expect(captures.events).toContainEqual({ + type: UPDATE_HISTORY_EVENT_EMITTER, + payload: { user: 'user-next', assistant: 'assistant-next' }, + }) + }) + + it('should tolerate updates without onChange and rethrow lexical runtime errors through the configured handler', async () => { + const captures: Captures = { eventEmitter: null, events: [] } + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { container } = render( + , + ) + + await waitFor(() => { + expect(captures.eventEmitter).not.toBeNull() + }) + + act(() => { + captures.eventEmitter?.emit({ + type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER, + instanceId: 'editor-2', + payload: 'silent update', + }) + }) + + const editable = container.querySelector('[contenteditable="false"]') as HTMLElement + const editor = getNearestEditorFromDOMNode(editable) + + expect(editable).toBeInTheDocument() + expect(editor).not.toBeNull() + expect(screen.getByRole('textbox')).toHaveTextContent('silent update') + + expect(() => { + act(() => { + editor?.update(() => { + throw new Error('prompt-editor boom') + }) + }) + }).toThrow('prompt-editor boom') + + consoleErrorSpy.mockRestore() + }) + }) +}) diff --git a/web/__tests__/custom/custom-page-flow.test.tsx b/web/__tests__/custom/custom-page-flow.test.tsx new file mode 100644 index 0000000000..6eb5ccadb9 --- /dev/null +++ b/web/__tests__/custom/custom-page-flow.test.tsx @@ -0,0 +1,107 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockProviderContextValue } from '@/__mocks__/provider-context' +import { contactSalesUrl, defaultPlan } from '@/app/components/billing/config' +import { Plan } from '@/app/components/billing/type' +import CustomPage from '@/app/components/custom/custom-page' +import useWebAppBrand from '@/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand' + +const mockSetShowPricingModal = vi.fn() + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: vi.fn(), +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowPricingModal: mockSetShowPricingModal, + }), +})) + +vi.mock('@/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand', () => ({ + __esModule: true, + default: vi.fn(), +})) + +const { useProviderContext } = await import('@/context/provider-context') + +const mockUseProviderContext = vi.mocked(useProviderContext) +const mockUseWebAppBrand = vi.mocked(useWebAppBrand) + +const createBrandState = (overrides: Partial> = {}): ReturnType => ({ + fileId: '', + imgKey: 1, + uploadProgress: 0, + uploading: false, + webappLogo: 'https://example.com/logo.png', + webappBrandRemoved: false, + uploadDisabled: false, + workspaceLogo: 'https://example.com/workspace-logo.png', + isCurrentWorkspaceManager: true, + isSandbox: false, + handleApply: vi.fn(), + handleCancel: vi.fn(), + handleChange: vi.fn(), + handleRestore: vi.fn(), + handleSwitch: vi.fn(), + ...overrides, +}) + +const setProviderPlan = (planType: Plan, enableBilling = true) => { + mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ + enableBilling, + plan: { + ...defaultPlan, + type: planType, + }, + })) +} + +describe('Custom Page Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + setProviderPlan(Plan.professional) + mockUseWebAppBrand.mockReturnValue(createBrandState()) + }) + + it('shows the billing upgrade banner for sandbox workspaces and opens pricing modal', () => { + setProviderPlan(Plan.sandbox) + + render() + + expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument() + expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('billing.upgradeBtn.encourageShort')) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + + it('renders the branding controls and the sales contact footer for paid workspaces', () => { + const hookState = createBrandState({ + fileId: 'pending-logo', + }) + mockUseWebAppBrand.mockReturnValue(hookState) + + render() + + const contactLink = screen.getByText('custom.customize.contactUs').closest('a') + expect(contactLink).toHaveAttribute('href', contactSalesUrl) + + fireEvent.click(screen.getByRole('switch')) + fireEvent.click(screen.getByRole('button', { name: 'custom.restore' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + fireEvent.click(screen.getByRole('button', { name: 'custom.apply' })) + + expect(hookState.handleSwitch).toHaveBeenCalledWith(true) + expect(hookState.handleRestore).toHaveBeenCalledTimes(1) + expect(hookState.handleCancel).toHaveBeenCalledTimes(1) + expect(hookState.handleApply).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/__tests__/header/account-dropdown-flow.test.tsx b/web/__tests__/header/account-dropdown-flow.test.tsx new file mode 100644 index 0000000000..6a645c7a43 --- /dev/null +++ b/web/__tests__/header/account-dropdown-flow.test.tsx @@ -0,0 +1,182 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Plan } from '@/app/components/billing/type' +import AccountDropdown from '@/app/components/header/account-dropdown' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' + +const { + mockPush, + mockLogout, + mockResetUser, + mockSetShowAccountSettingModal, +} = vi.hoisted(() => ({ + mockPush: vi.fn(), + mockLogout: vi.fn(), + mockResetUser: vi.fn(), + mockSetShowAccountSettingModal: vi.fn(), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string, version?: string }) => { + if (options?.version) + return `${options.ns}.${key}:${options.version}` + return options?.ns ? `${options.ns}.${key}` : key + }, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + userProfile: { + name: 'Ada Lovelace', + email: 'ada@example.com', + avatar_url: '', + }, + langGeniusVersionInfo: { + current_version: '1.0.0', + latest_version: '1.1.0', + release_notes: 'https://example.com/releases/1.1.0', + }, + isCurrentWorkspaceOwner: false, + }), +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + isEducationAccount: false, + plan: { + type: Plan.professional, + }, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector?: (state: Record) => unknown) => { + const state = { + systemFeatures: { + branding: { + enabled: false, + workspace_logo: null, + }, + }, + } + return selector ? selector(state) : state + }, +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowAccountSettingModal: mockSetShowAccountSettingModal, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.example.com${path}`, +})) + +vi.mock('@/service/use-common', () => ({ + useLogout: () => ({ + mutateAsync: mockLogout, + }), +})) + +vi.mock('@/app/components/base/amplitude/utils', () => ({ + resetUser: mockResetUser, +})) + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})) + +vi.mock('@/next/link', () => ({ + default: ({ + href, + children, + ...props + }: { + href: string + children?: React.ReactNode + } & Record) => ( + + {children} + + ), +})) + +const renderAccountDropdown = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + + return render( + + + , + ) +} + +describe('Header Account Dropdown Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ + repo: { stars: 123456 }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + localStorage.clear() + }) + + it('opens account actions, fetches github stars, and opens the settings and about flows', async () => { + renderAccountDropdown() + + fireEvent.click(screen.getByRole('button', { name: 'common.account.account' })) + + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument() + expect(screen.getByText('ada@example.com')).toBeInTheDocument() + expect(await screen.findByText('123,456')).toBeInTheDocument() + + fireEvent.click(screen.getByText('common.userProfile.settings')) + + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: ACCOUNT_SETTING_TAB.MEMBERS, + }) + + fireEvent.click(screen.getByText('common.userProfile.about')) + + await waitFor(() => { + expect(screen.getByText(/Version/)).toBeInTheDocument() + expect(screen.getByText(/1\.0\.0/)).toBeInTheDocument() + }) + }) + + it('logs out, resets cached user markers, and redirects to signin', async () => { + localStorage.setItem('setup_status', 'done') + localStorage.setItem('education-reverify-prev-expire-at', '1') + localStorage.setItem('education-reverify-has-noticed', '1') + localStorage.setItem('education-expired-has-noticed', '1') + + renderAccountDropdown() + + fireEvent.click(screen.getByRole('button', { name: 'common.account.account' })) + fireEvent.click(screen.getByText('common.userProfile.logout')) + + await waitFor(() => { + expect(mockLogout).toHaveBeenCalledTimes(1) + expect(mockResetUser).toHaveBeenCalledTimes(1) + expect(mockPush).toHaveBeenCalledWith('/signin') + }) + + expect(localStorage.getItem('setup_status')).toBeNull() + expect(localStorage.getItem('education-reverify-prev-expire-at')).toBeNull() + expect(localStorage.getItem('education-reverify-has-noticed')).toBeNull() + expect(localStorage.getItem('education-expired-has-noticed')).toBeNull() + }) +}) diff --git a/web/__tests__/header/nav-flow.test.tsx b/web/__tests__/header/nav-flow.test.tsx new file mode 100644 index 0000000000..05955a6c83 --- /dev/null +++ b/web/__tests__/header/nav-flow.test.tsx @@ -0,0 +1,237 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Nav from '@/app/components/header/nav' +import { AppModeEnum } from '@/types/app' + +const mockPush = vi.fn() +const mockSetAppDetail = vi.fn() +const mockOnCreate = vi.fn() +const mockOnLoadMore = vi.fn() + +let mockSelectedSegment = 'app' +let mockIsCurrentWorkspaceEditor = true + +vi.mock('@headlessui/react', () => { + type MenuContextValue = { + open: boolean + setOpen: React.Dispatch> + } + const MenuContext = React.createContext(null) + + const Menu = ({ + children, + }: { + children: React.ReactNode | ((props: { open: boolean }) => React.ReactNode) + }) => { + const [open, setOpen] = React.useState(false) + const value = React.useMemo(() => ({ open, setOpen }), [open]) + + return ( + + {typeof children === 'function' ? children({ open }) : children} + + ) + } + + const MenuButton = ({ + children, + onClick, + ...props + }: React.ButtonHTMLAttributes) => { + const context = React.useContext(MenuContext) + + return ( + + ) + } + + const MenuItems = ({ + as: Component = 'div', + children, + ...props + }: { + as?: React.ElementType + children: React.ReactNode + } & Record) => { + const context = React.useContext(MenuContext) + if (!context?.open) + return null + + return {children} + } + + const MenuItem = ({ + as: Component = 'div', + children, + ...props + }: { + as?: React.ElementType + children: React.ReactNode + } & Record) => {children} + + return { + Menu, + MenuButton, + MenuItems, + MenuItem, + Transition: ({ children }: { children: React.ReactNode }) => <>{children}, + } +}) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/next/navigation', () => ({ + useSelectedLayoutSegment: () => mockSelectedSegment, + useRouter: () => ({ + push: mockPush, + }), +})) + +vi.mock('@/next/link', () => ({ + default: ({ + href, + children, + }: { + href: string + children?: React.ReactNode + }) => {children}, +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: () => mockSetAppDetail, +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor, + }), +})) + +const navigationItems = [ + { + id: 'app-1', + name: 'Alpha', + link: '/app/app-1/configuration', + icon_type: 'emoji' as const, + icon: '🤖', + icon_background: '#FFEAD5', + icon_url: null, + mode: AppModeEnum.CHAT, + }, + { + id: 'app-2', + name: 'Bravo', + link: '/app/app-2/workflow', + icon_type: 'emoji' as const, + icon: '⚙️', + icon_background: '#E0F2FE', + icon_url: null, + mode: AppModeEnum.WORKFLOW, + }, +] + +const curNav = { + id: 'app-1', + name: 'Alpha', + icon_type: 'emoji' as const, + icon: '🤖', + icon_background: '#FFEAD5', + icon_url: null, + mode: AppModeEnum.CHAT, +} + +const renderNav = (nav = curNav) => { + return render( +
} + marketplace={
marketplace view
} + />, + { searchParams }, + ) +} + +describe('Plugin Page Shell Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetchManifestFromMarketPlace.mockResolvedValue({ + data: { + plugin: { + org: 'langgenius', + name: 'plugin-demo', + }, + version: { + version: '1.0.0', + }, + }, + }) + }) + + it('switches from installed plugins to marketplace and syncs the active tab into the URL', async () => { + const { onUrlUpdate } = renderPluginPage() + + expect(screen.getByTestId('plugins-view')).toBeInTheDocument() + expect(screen.queryByTestId('marketplace-view')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('tab-item-discover')) + + await waitFor(() => { + expect(screen.getByTestId('marketplace-view')).toBeInTheDocument() + }) + + const tabUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] + expect(tabUpdate.searchParams.get('tab')).toBe('discover') + }) + + it('hydrates marketplace installation from query params and clears the install state when closed', async () => { + const { onUrlUpdate } = renderPluginPage('?package-ids=%5B%22langgenius%2Fplugin-demo%22%5D') + + await waitFor(() => { + expect(mockFetchManifestFromMarketPlace).toHaveBeenCalledWith('langgenius%2Fplugin-demo') + expect(screen.getByTestId('install-from-marketplace-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'close-install-modal' })) + + await waitFor(() => { + const clearUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] + expect(clearUpdate.searchParams.has('package-ids')).toBe(false) + }) + }) +}) diff --git a/web/__tests__/share/text-generation-mode-flow.test.tsx b/web/__tests__/share/text-generation-mode-flow.test.tsx new file mode 100644 index 0000000000..0d4307bca0 --- /dev/null +++ b/web/__tests__/share/text-generation-mode-flow.test.tsx @@ -0,0 +1,155 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import TextGeneration from '@/app/components/share/text-generation' + +const useSearchParamsMock = vi.fn(() => new URLSearchParams()) +const mockUseTextGenerationAppState = vi.fn() +const mockUseTextGenerationBatch = vi.fn() + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('@/next/navigation', () => ({ + useSearchParams: () => useSearchParamsMock(), +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + __esModule: true, + default: () => 'pc', + MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' }, +})) + +vi.mock('@/app/components/share/text-generation/hooks/use-text-generation-app-state', () => ({ + useTextGenerationAppState: (...args: unknown[]) => mockUseTextGenerationAppState(...args), +})) + +vi.mock('@/app/components/share/text-generation/hooks/use-text-generation-batch', () => ({ + useTextGenerationBatch: (...args: unknown[]) => mockUseTextGenerationBatch(...args), +})) + +vi.mock('@/app/components/share/text-generation/text-generation-sidebar', () => ({ + default: ({ + currentTab, + onTabChange, + }: { + currentTab: string + onTabChange: (tab: string) => void + }) => ( +
+ {currentTab} + + +
+ ), +})) + +vi.mock('@/app/components/share/text-generation/text-generation-result-panel', () => ({ + default: ({ + isCallBatchAPI, + resultExisted, + }: { + isCallBatchAPI: boolean + resultExisted: boolean + }) => ( +
+ ), +})) + +const createReadyAppState = () => ({ + accessMode: 'public', + appId: 'app-123', + appSourceType: 'published', + customConfig: { + remove_webapp_brand: false, + replace_webapp_logo: '', + }, + handleRemoveSavedMessage: vi.fn(), + handleSaveMessage: vi.fn(), + moreLikeThisConfig: { + enabled: true, + }, + promptConfig: { + user_input_form: [], + }, + savedMessages: [], + siteInfo: { + title: 'Text Generation', + }, + systemFeatures: { + branding: { + enabled: false, + workspace_logo: null, + }, + }, + textToSpeechConfig: { + enabled: true, + }, + visionConfig: null, +}) + +const createBatchState = () => ({ + allFailedTaskList: [], + allSuccessTaskList: [], + allTaskList: [], + allTasksRun: false, + controlRetry: 0, + exportRes: vi.fn(), + handleCompleted: vi.fn(), + handleRetryAllFailedTask: vi.fn(), + handleRunBatch: vi.fn(), + isCallBatchAPI: false, + noPendingTask: true, + resetBatchExecution: vi.fn(), + setIsCallBatchAPI: vi.fn(), + showTaskList: false, +}) + +describe('Text Generation Mode Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + useSearchParamsMock.mockReturnValue(new URLSearchParams()) + mockUseTextGenerationAppState.mockReturnValue(createReadyAppState()) + mockUseTextGenerationBatch.mockReturnValue(createBatchState()) + }) + + it('shows the loading state before app metadata is ready', () => { + mockUseTextGenerationAppState.mockReturnValue({ + ...createReadyAppState(), + appId: '', + promptConfig: null, + siteInfo: null, + }) + + render() + + expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument() + }) + + it('hydrates the initial tab from the mode query parameter and lets the sidebar switch it', () => { + useSearchParamsMock.mockReturnValue(new URLSearchParams('mode=batch')) + + render() + + expect(screen.getByTestId('current-tab')).toHaveTextContent('batch') + + fireEvent.click(screen.getByRole('button', { name: 'switch-to-create' })) + + expect(screen.getByTestId('current-tab')).toHaveTextContent('create') + }) + + it('falls back to create mode for unsupported query values', () => { + useSearchParamsMock.mockReturnValue(new URLSearchParams('mode=unsupported')) + + render() + + expect(screen.getByTestId('current-tab')).toHaveTextContent('create') + expect(screen.getByTestId('text-generation-result-panel')).toHaveAttribute('data-batch', 'false') + }) +}) diff --git a/web/__tests__/tools/provider-list-shell-flow.test.tsx b/web/__tests__/tools/provider-list-shell-flow.test.tsx new file mode 100644 index 0000000000..5b6ba8a64b --- /dev/null +++ b/web/__tests__/tools/provider-list-shell-flow.test.tsx @@ -0,0 +1,205 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ProviderList from '@/app/components/tools/provider-list' +import { CollectionType } from '@/app/components/tools/types' +import { renderWithNuqs } from '@/test/nuqs-testing' + +const mockInvalidateInstalledPluginList = vi.fn() + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: Record) => unknown) => selector({ + systemFeatures: { + enable_marketplace: true, + }, + }), +})) + +vi.mock('@/app/components/plugins/hooks', () => ({ + useTags: () => ({ + getTagLabel: (name: string) => name, + }), +})) + +vi.mock('@/service/use-tools', () => ({ + useAllToolProviders: () => ({ + data: [ + { + id: 'builtin-plugin', + name: 'plugin-tool', + author: 'Dify', + description: { en_US: 'Plugin Tool' }, + icon: 'icon-plugin', + label: { en_US: 'Plugin Tool' }, + type: CollectionType.builtIn, + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: ['search'], + plugin_id: 'langgenius/plugin-tool', + }, + { + id: 'builtin-basic', + name: 'basic-tool', + author: 'Dify', + description: { en_US: 'Basic Tool' }, + icon: 'icon-basic', + label: { en_US: 'Basic Tool' }, + type: CollectionType.builtIn, + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: ['utility'], + }, + ], + refetch: vi.fn(), + }), +})) + +vi.mock('@/service/use-plugins', () => ({ + useCheckInstalled: ({ enabled }: { enabled: boolean }) => ({ + data: enabled + ? { + plugins: [{ + plugin_id: 'langgenius/plugin-tool', + declaration: { + category: 'tool', + }, + }], + } + : null, + }), + useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList, +})) + +vi.mock('@/app/components/tools/labels/filter', () => ({ + default: ({ onChange }: { onChange: (value: string[]) => void }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/plugins/card', () => ({ + default: ({ payload, className }: { payload: { name: string }, className?: string }) => ( +
+ {payload.name} +
+ ), +})) + +vi.mock('@/app/components/plugins/card/card-more-info', () => ({ + default: ({ tags }: { tags: string[] }) =>
{tags.join(',')}
, +})) + +vi.mock('@/app/components/tools/provider/detail', () => ({ + default: ({ collection, onHide }: { collection: { name: string }, onHide: () => void }) => ( +
+ {collection.name} + +
+ ), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({ + default: ({ + detail, + onHide, + onUpdate, + }: { + detail?: { plugin_id: string } + onHide: () => void + onUpdate: () => void + }) => detail + ? ( +
+ {detail.plugin_id} + + +
+ ) + : null, +})) + +vi.mock('@/app/components/tools/provider/empty', () => ({ + default: () =>
workflow empty
, +})) + +vi.mock('@/app/components/plugins/marketplace/empty', () => ({ + default: ({ text }: { text: string }) =>
{text}
, +})) + +vi.mock('@/app/components/tools/marketplace', () => ({ + default: ({ + isMarketplaceArrowVisible, + showMarketplacePanel, + }: { + isMarketplaceArrowVisible: boolean + showMarketplacePanel: () => void + }) => ( + + ), +})) + +vi.mock('@/app/components/tools/marketplace/hooks', () => ({ + useMarketplace: () => ({ + handleScroll: vi.fn(), + }), +})) + +vi.mock('@/app/components/tools/mcp', () => ({ + default: ({ searchText }: { searchText: string }) =>
{searchText}
, +})) + +const renderProviderList = (searchParams = '') => { + return renderWithNuqs(, { searchParams }) +} + +describe('Tool Provider List Shell Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + Element.prototype.scrollTo = vi.fn() + }) + + it('opens a plugin-backed provider detail panel and invalidates installed plugins on update', async () => { + renderProviderList('?category=builtin') + + fireEvent.click(screen.getByTestId('tool-card-plugin-tool')) + + await waitFor(() => { + expect(screen.getByTestId('tool-plugin-detail-panel')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'update-plugin-detail' })) + expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole('button', { name: 'close-plugin-detail' })) + + await waitFor(() => { + expect(screen.queryByTestId('tool-plugin-detail-panel')).not.toBeInTheDocument() + }) + }) + + it('scrolls to the marketplace section and syncs workflow tab selection into the URL', async () => { + const { onUrlUpdate } = renderProviderList('?category=builtin') + + fireEvent.click(screen.getByTestId('marketplace-arrow')) + expect(Element.prototype.scrollTo).toHaveBeenCalled() + + fireEvent.click(screen.getByTestId('tab-item-workflow')) + + await waitFor(() => { + expect(screen.getByTestId('workflow-empty')).toBeInTheDocument() + }) + + const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] + expect(update.searchParams.get('category')).toBe('workflow') + }) +}) diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx index 1df6fa79b7..dcc9f9c98e 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx @@ -18,6 +18,7 @@ const mockInvalidDatasetDetail = vi.fn() const mockExportPipeline = vi.fn() const mockCheckIsUsedInApp = vi.fn() const mockDeleteDataset = vi.fn() +const mockToast = vi.fn() const createDataset = (overrides: Partial = {}): DataSet => ({ id: 'dataset-1', @@ -111,6 +112,10 @@ vi.mock('@/service/datasets', () => ({ deleteDataset: (...args: unknown[]) => mockDeleteDataset(...args), })) +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: (...args: unknown[]) => mockToast(...args), +})) + vi.mock('@/app/components/datasets/rename-modal', () => ({ default: ({ show, @@ -225,4 +230,49 @@ describe('Dropdown callback coverage', () => { expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() }) }) + + it('should show the used-by-app confirmation copy when the dataset is referenced by apps', async () => { + const user = userEvent.setup() + mockCheckIsUsedInApp.mockResolvedValueOnce({ is_using: true }) + + render() + + await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByText('common.operation.delete')) + + await waitFor(() => { + expect(screen.getByText('dataset.datasetUsedByApp')).toBeInTheDocument() + }) + }) + + it('should surface an export failure toast when pipeline export fails', async () => { + const user = userEvent.setup() + mockExportPipeline.mockRejectedValueOnce(new Error('export failed')) + + render() + + await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByText('datasetPipeline.operations.exportPipeline')) + + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith('app.exportFailed', { type: 'error' }) + }) + }) + + it('should surface the backend message when checking app usage fails', async () => { + const user = userEvent.setup() + mockCheckIsUsedInApp.mockRejectedValueOnce({ + json: vi.fn().mockResolvedValue({ message: 'check failed' }), + }) + + render() + + await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByText('common.operation.delete')) + + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith('check failed', { type: 'error' }) + }) + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() + }) }) diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx index a1e275d731..bb85e00c14 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import type { DataSet } from '@/models/datasets' import { RiEditLine } from '@remixicon/react' -import { render, screen, waitFor } from '@testing-library/react' +import { createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { @@ -218,6 +218,31 @@ describe('MenuItem', () => { // Assert expect(handleClick).toHaveBeenCalledTimes(1) }) + + it('should stop propagation before invoking the handler', () => { + const parentClick = vi.fn() + const handleClick = vi.fn() + + render( +
+ +
, + ) + + fireEvent.click(screen.getByText('Edit')) + + expect(handleClick).toHaveBeenCalledTimes(1) + expect(parentClick).not.toHaveBeenCalled() + }) + + it('should not crash when no click handler is provided', () => { + render() + + const event = createEvent.click(screen.getByText('Edit')) + fireEvent(screen.getByText('Edit'), event) + + expect(event.defaultPrevented).toBe(true) + }) }) }) @@ -265,6 +290,47 @@ describe('Menu', () => { expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() }) }) + + describe('Interactions', () => { + it('should invoke the rename callback when edit is clicked', async () => { + const user = userEvent.setup() + const openRenameModal = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('common.operation.edit')) + + expect(openRenameModal).toHaveBeenCalledTimes(1) + }) + + it('should invoke export and delete callbacks from their menu items', async () => { + const user = userEvent.setup() + const handleExportPipeline = vi.fn() + const detectIsUsedByApp = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('datasetPipeline.operations.exportPipeline')) + await user.click(screen.getByText('common.operation.delete')) + + expect(handleExportPipeline).toHaveBeenCalledTimes(1) + expect(detectIsUsedByApp).toHaveBeenCalledTimes(1) + }) + }) }) describe('Dropdown', () => { 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..f43db2f5f9 --- /dev/null +++ b/web/app/components/apps/__tests__/app-card-skeleton.spec.tsx @@ -0,0 +1,24 @@ +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.childElementCount).toBe(6) + expect(AppCardSkeleton.displayName).toBe('AppCardSkeleton') + }) + + it('should respect the custom skeleton count and card classes', () => { + const { container } = render() + + expect(container.childElementCount).toBe(2) + expect(container.firstElementChild).toHaveClass( + 'h-[160px]', + 'rounded-xl', + 'border-[0.5px]', + 'bg-components-card-bg', + 'p-4', + ) + }) +}) diff --git a/web/app/components/base/chat/chat/__tests__/chat-log-modals.spec.tsx b/web/app/components/base/chat/chat/__tests__/chat-log-modals.spec.tsx new file mode 100644 index 0000000000..36e7cec67d --- /dev/null +++ b/web/app/components/base/chat/chat/__tests__/chat-log-modals.spec.tsx @@ -0,0 +1,144 @@ +import type { IChatItem } from '../type' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useStore as useAppStore } from '@/app/components/app/store' +import { fetchAgentLogDetail } from '@/service/log' +import ChatLogModals from '../chat-log-modals' + +vi.mock('@/service/log', () => ({ + fetchAgentLogDetail: vi.fn(), +})) + +describe('ChatLogModals', () => { + beforeEach(() => { + vi.clearAllMocks() + useAppStore.setState({ appDetail: { id: 'app-1' } as ReturnType['appDetail'] }) + }) + + // Modal visibility should follow the two booleans unless log modals are globally hidden. + describe('Rendering', () => { + it('should render real prompt and agent log modals when enabled', async () => { + vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) + + render( + , + ) + + expect(screen.getByText('PROMPT LOG')).toBeInTheDocument() + expect(screen.getByText('Prompt body')).toBeInTheDocument() + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i })).toBeInTheDocument() + }) + }) + + it('should render nothing when hideLogModal is true', () => { + render( + , + ) + + expect(screen.queryByText('PROMPT LOG')).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: /appLog.runDetail.workflowTitle/i })).not.toBeInTheDocument() + }) + }) + + // Cancel actions should clear the current item and close only the targeted modal. + describe('User Interactions', () => { + it('should close the prompt log modal through the real close action', async () => { + const user = userEvent.setup() + const setCurrentLogItem = vi.fn() + const setShowPromptLogModal = vi.fn() + const setShowAgentLogModal = vi.fn() + + render( + , + ) + + await user.click(screen.getByTestId('close-btn-container')) + + expect(setCurrentLogItem).toHaveBeenCalled() + expect(setShowPromptLogModal).toHaveBeenCalledWith(false) + expect(setShowAgentLogModal).not.toHaveBeenCalled() + }) + + it('should close the agent log modal through the real close action', async () => { + const user = userEvent.setup() + const setCurrentLogItem = vi.fn() + const setShowPromptLogModal = vi.fn() + const setShowAgentLogModal = vi.fn() + vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) + + render( + , + ) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i })).toBeInTheDocument() + }) + await user.click(screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i }).nextElementSibling as HTMLElement) + + expect(setCurrentLogItem).toHaveBeenCalled() + expect(setShowAgentLogModal).toHaveBeenCalledWith(false) + expect(setShowPromptLogModal).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/base/chat/chat/__tests__/use-chat-layout.spec.tsx b/web/app/components/base/chat/chat/__tests__/use-chat-layout.spec.tsx new file mode 100644 index 0000000000..15da63e4d0 --- /dev/null +++ b/web/app/components/base/chat/chat/__tests__/use-chat-layout.spec.tsx @@ -0,0 +1,293 @@ +import type { ChatItem } from '../../types' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest' +import { useChatLayout } from '../use-chat-layout' + +type ResizeCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => void + +let capturedResizeCallbacks: ResizeCallback[] = [] +let disconnectSpy: ReturnType +let rafCallbacks: FrameRequestCallback[] = [] + +const makeChatItem = (overrides: Partial = {}): ChatItem => ({ + id: `item-${Math.random().toString(36).slice(2)}`, + content: 'Test content', + isAnswer: false, + ...overrides, +}) + +const makeResizeEntry = (blockSize: number, inlineSize: number): ResizeObserverEntry => ({ + borderBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize], + contentBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize], + contentRect: new DOMRect(0, 0, inlineSize, blockSize), + devicePixelContentBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize], + target: document.createElement('div'), +}) + +const assignMetric = (node: HTMLElement, key: 'clientWidth' | 'clientHeight' | 'scrollHeight', value: number) => { + Object.defineProperty(node, key, { + configurable: true, + value, + }) +} + +const LayoutHarness = ({ + chatList, + sidebarCollapseState, + attachRefs = true, +}: { + chatList: ChatItem[] + sidebarCollapseState?: boolean + attachRefs?: boolean +}) => { + const { + width, + chatContainerRef, + chatContainerInnerRef, + chatFooterRef, + chatFooterInnerRef, + } = useChatLayout({ chatList, sidebarCollapseState }) + + return ( + <> +
{ + chatContainerRef.current = attachRefs ? node : null + if (node && attachRefs) { + assignMetric(node, 'clientWidth', 400) + assignMetric(node, 'clientHeight', 240) + assignMetric(node, 'scrollHeight', 640) + if (!node.dataset.metricsReady) { + node.scrollTop = 0 + node.dataset.metricsReady = 'true' + } + } + }} + > +
{ + chatContainerInnerRef.current = attachRefs ? node : null + if (node && attachRefs) + assignMetric(node, 'clientWidth', 360) + }} + /> +
+
{ + chatFooterRef.current = attachRefs ? node : null + }} + > +
{ + chatFooterInnerRef.current = attachRefs ? node : null + }} + /> +
+ {width} + + ) +} + +const flushAnimationFrames = () => { + const queuedCallbacks = [...rafCallbacks] + rafCallbacks = [] + queuedCallbacks.forEach(callback => callback(0)) +} + +describe('useChatLayout', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + capturedResizeCallbacks = [] + disconnectSpy = vi.fn() + rafCallbacks = [] + + Object.defineProperty(document.body, 'clientWidth', { + configurable: true, + value: 1024, + }) + + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + rafCallbacks.push(cb) + return rafCallbacks.length + }) + + vi.stubGlobal('ResizeObserver', class { + constructor(cb: ResizeCallback) { + capturedResizeCallbacks.push(cb) + } + + observe() { } + unobserve() { } + disconnect = disconnectSpy + }) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + // The hook should compute shell dimensions and auto-scroll when enough chat items exist. + describe('Layout Calculation', () => { + it('should auto-scroll and compute the chat shell widths on mount', () => { + const addSpy = vi.spyOn(window, 'addEventListener') + + render( + , + ) + + act(() => { + flushAnimationFrames() + vi.runAllTimers() + }) + + expect(screen.getByTestId('layout-width')).toHaveTextContent('600') + expect(screen.getByTestId('chat-footer').style.width).toBe('400px') + expect(screen.getByTestId('chat-footer-inner').style.width).toBe('360px') + expect((screen.getByTestId('chat-container') as HTMLDivElement).scrollTop).toBe(640) + expect(addSpy).toHaveBeenCalledWith('resize', expect.any(Function)) + }) + }) + + // Resize observers should keep padding and widths in sync, then fully clean up on unmount. + describe('Resize Observers', () => { + it('should react to observer updates and disconnect both observers on unmount', () => { + const removeSpy = vi.spyOn(window, 'removeEventListener') + const { unmount } = render( + , + ) + + act(() => { + capturedResizeCallbacks[0]?.([makeResizeEntry(80, 400)], {} as ResizeObserver) + }) + expect(screen.getByTestId('chat-container').style.paddingBottom).toBe('80px') + + act(() => { + capturedResizeCallbacks[1]?.([makeResizeEntry(50, 560)], {} as ResizeObserver) + }) + expect(screen.getByTestId('chat-footer').style.width).toBe('560px') + + unmount() + + expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function)) + expect(disconnectSpy).toHaveBeenCalledTimes(2) + }) + + it('should respect manual scrolling until a new first message arrives and safely ignore missing refs', () => { + const { rerender } = render( + , + ) + + const container = screen.getByTestId('chat-container') as HTMLDivElement + + act(() => { + fireEvent.scroll(container) + flushAnimationFrames() + }) + + act(() => { + container.scrollTop = 10 + fireEvent.scroll(container) + }) + + rerender( + , + ) + + act(() => { + flushAnimationFrames() + vi.runAllTimers() + }) + + act(() => { + container.scrollTop = 420 + fireEvent.scroll(container) + }) + + rerender( + , + ) + + act(() => { + flushAnimationFrames() + vi.runAllTimers() + }) + + expect(container.scrollTop).toBe(640) + + rerender( + , + ) + + act(() => { + fireEvent.scroll(container) + flushAnimationFrames() + }) + }) + + it('should keep the hook stable when the DOM refs are not attached', () => { + render( + , + ) + + act(() => { + flushAnimationFrames() + vi.runAllTimers() + }) + + expect(screen.getByTestId('layout-width')).toHaveTextContent('0') + expect(capturedResizeCallbacks).toHaveLength(0) + expect(screen.getByTestId('chat-footer').style.width).toBe('') + expect(screen.getByTestId('chat-footer-inner').style.width).toBe('') + }) + }) +}) diff --git a/web/app/components/base/chat/chat/chat-log-modals.tsx b/web/app/components/base/chat/chat/chat-log-modals.tsx new file mode 100644 index 0000000000..d1bf43b81c --- /dev/null +++ b/web/app/components/base/chat/chat/chat-log-modals.tsx @@ -0,0 +1,56 @@ +import type { FC } from 'react' +import type { IChatItem } from './type' +import AgentLogModal from '@/app/components/base/agent-log-modal' +import PromptLogModal from '@/app/components/base/prompt-log-modal' + +type ChatLogModalsProps = { + width: number + currentLogItem?: IChatItem + showPromptLogModal: boolean + showAgentLogModal: boolean + hideLogModal?: boolean + setCurrentLogItem: (item?: IChatItem) => void + setShowPromptLogModal: (showPromptLogModal: boolean) => void + setShowAgentLogModal: (showAgentLogModal: boolean) => void +} + +const ChatLogModals: FC = ({ + width, + currentLogItem, + showPromptLogModal, + showAgentLogModal, + hideLogModal, + setCurrentLogItem, + setShowPromptLogModal, + setShowAgentLogModal, +}) => { + if (hideLogModal) + return null + + return ( + <> + {showPromptLogModal && ( + { + setCurrentLogItem() + setShowPromptLogModal(false) + }} + /> + )} + {showAgentLogModal && ( + { + setCurrentLogItem() + setShowAgentLogModal(false) + }} + /> + )} + + ) +} + +export default ChatLogModals diff --git a/web/app/components/base/chat/chat/index.tsx b/web/app/components/base/chat/chat/index.tsx index ed44c8719d..f04169327f 100644 --- a/web/app/components/base/chat/chat/index.tsx +++ b/web/app/components/base/chat/chat/index.tsx @@ -13,26 +13,19 @@ import type { import type { InputForm } from './type' import type { Emoji } from '@/app/components/tools/types' import type { AppData } from '@/models/share' -import { debounce } from 'es-toolkit/compat' -import { - memo, - useCallback, - useEffect, - useRef, - useState, -} from 'react' +import { memo } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import { useStore as useAppStore } from '@/app/components/app/store' -import AgentLogModal from '@/app/components/base/agent-log-modal' import Button from '@/app/components/base/button' -import PromptLogModal from '@/app/components/base/prompt-log-modal' import { cn } from '@/utils/classnames' import Answer from './answer' import ChatInputArea from './chat-input-area' +import ChatLogModals from './chat-log-modals' import { ChatContextProvider } from './context-provider' import Question from './question' import TryToAsk from './try-to-ask' +import { useChatLayout } from './use-chat-layout' export type ChatProps = { isTryApp?: boolean @@ -133,128 +126,17 @@ const Chat: FC = ({ showAgentLogModal: state.showAgentLogModal, setShowAgentLogModal: state.setShowAgentLogModal, }))) - const [width, setWidth] = useState(0) - const chatContainerRef = useRef(null) - const chatContainerInnerRef = useRef(null) - const chatFooterRef = useRef(null) - const chatFooterInnerRef = useRef(null) - const userScrolledRef = useRef(false) - const isAutoScrollingRef = useRef(false) - - const handleScrollToBottom = useCallback(() => { - if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current) { - isAutoScrollingRef.current = true - chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight - - requestAnimationFrame(() => { - isAutoScrollingRef.current = false - }) - } - }, [chatList.length]) - - const handleWindowResize = useCallback(() => { - if (chatContainerRef.current) - setWidth(document.body.clientWidth - (chatContainerRef.current?.clientWidth + 16) - 8) - - if (chatContainerRef.current && chatFooterRef.current) - chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px` - - if (chatContainerInnerRef.current && chatFooterInnerRef.current) - chatFooterInnerRef.current.style.width = `${chatContainerInnerRef.current.clientWidth}px` - }, []) - - useEffect(() => { - handleScrollToBottom() - handleWindowResize() - }, [handleScrollToBottom, handleWindowResize]) - - useEffect(() => { - /* v8 ignore next - @preserve */ - if (chatContainerRef.current) { - requestAnimationFrame(() => { - handleScrollToBottom() - handleWindowResize() - }) - } + const { + width, + chatContainerRef, + chatContainerInnerRef, + chatFooterRef, + chatFooterInnerRef, + } = useChatLayout({ + chatList, + sidebarCollapseState, }) - useEffect(() => { - const debouncedHandler = debounce(handleWindowResize, 200) - window.addEventListener('resize', debouncedHandler) - - return () => { - window.removeEventListener('resize', debouncedHandler) - debouncedHandler.cancel() - } - }, [handleWindowResize]) - - useEffect(() => { - /* v8 ignore next - @preserve */ - if (chatFooterRef.current && chatContainerRef.current) { - const resizeContainerObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - const { blockSize } = entry.borderBoxSize[0] - chatContainerRef.current!.style.paddingBottom = `${blockSize}px` - handleScrollToBottom() - } - }) - resizeContainerObserver.observe(chatFooterRef.current) - - const resizeFooterObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - const { inlineSize } = entry.borderBoxSize[0] - chatFooterRef.current!.style.width = `${inlineSize}px` - } - }) - resizeFooterObserver.observe(chatContainerRef.current) - - return () => { - resizeContainerObserver.disconnect() - resizeFooterObserver.disconnect() - } - } - }, [handleScrollToBottom]) - - useEffect(() => { - const setUserScrolled = () => { - const container = chatContainerRef.current - /* v8 ignore next 2 - @preserve */ - if (!container) - return - /* v8 ignore next 2 - @preserve */ - if (isAutoScrollingRef.current) - return - - const distanceToBottom = container.scrollHeight - container.clientHeight - container.scrollTop - const SCROLL_UP_THRESHOLD = 100 - - userScrolledRef.current = distanceToBottom > SCROLL_UP_THRESHOLD - } - - const container = chatContainerRef.current - /* v8 ignore next 2 - @preserve */ - if (!container) - return - - container.addEventListener('scroll', setUserScrolled) - return () => container.removeEventListener('scroll', setUserScrolled) - }, []) - - const prevFirstMessageIdRef = useRef(undefined) - useEffect(() => { - const firstMessageId = chatList[0]?.id - if (chatList.length <= 1 || (firstMessageId && prevFirstMessageIdRef.current !== firstMessageId)) - userScrolledRef.current = false - prevFirstMessageIdRef.current = firstMessageId - }, [chatList]) - - useEffect(() => { - if (!sidebarCollapseState) { - const timer = setTimeout(handleWindowResize, 200) - return () => clearTimeout(timer) - } - }, [handleWindowResize, sidebarCollapseState]) - const hasTryToAsk = config?.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend return ( @@ -279,7 +161,7 @@ const Chat: FC = ({
{chatNode}
= ({ !noStopResponding && isResponding && (
@@ -375,26 +257,16 @@ const Chat: FC = ({ }
- {showPromptLogModal && !hideLogModal && ( - { - setCurrentLogItem() - setShowPromptLogModal(false) - }} - /> - )} - {showAgentLogModal && !hideLogModal && ( - { - setCurrentLogItem() - setShowAgentLogModal(false) - }} - /> - )} +
) diff --git a/web/app/components/base/chat/chat/use-chat-layout.ts b/web/app/components/base/chat/chat/use-chat-layout.ts new file mode 100644 index 0000000000..41f622c523 --- /dev/null +++ b/web/app/components/base/chat/chat/use-chat-layout.ts @@ -0,0 +1,144 @@ +import type { ChatItem } from '../types' +import { debounce } from 'es-toolkit/compat' +import { + useCallback, + useEffect, + useRef, + useState, +} from 'react' + +type UseChatLayoutOptions = { + chatList: ChatItem[] + sidebarCollapseState?: boolean +} + +export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutOptions) => { + const [width, setWidth] = useState(0) + const chatContainerRef = useRef(null) + const chatContainerInnerRef = useRef(null) + const chatFooterRef = useRef(null) + const chatFooterInnerRef = useRef(null) + const userScrolledRef = useRef(false) + const isAutoScrollingRef = useRef(false) + const prevFirstMessageIdRef = useRef(undefined) + + const handleScrollToBottom = useCallback(() => { + if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current) { + isAutoScrollingRef.current = true + chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight + + requestAnimationFrame(() => { + isAutoScrollingRef.current = false + }) + } + }, [chatList.length]) + + const handleWindowResize = useCallback(() => { + if (chatContainerRef.current) + setWidth(document.body.clientWidth - (chatContainerRef.current.clientWidth + 16) - 8) + + if (chatContainerRef.current && chatFooterRef.current) + chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px` + + if (chatContainerInnerRef.current && chatFooterInnerRef.current) + chatFooterInnerRef.current.style.width = `${chatContainerInnerRef.current.clientWidth}px` + }, []) + + useEffect(() => { + handleScrollToBottom() + const animationFrame = requestAnimationFrame(handleWindowResize) + + return () => { + cancelAnimationFrame(animationFrame) + } + }, [handleScrollToBottom, handleWindowResize]) + + useEffect(() => { + if (chatContainerRef.current) { + requestAnimationFrame(() => { + handleScrollToBottom() + handleWindowResize() + }) + } + }) + + useEffect(() => { + const debouncedHandler = debounce(handleWindowResize, 200) + window.addEventListener('resize', debouncedHandler) + + return () => { + window.removeEventListener('resize', debouncedHandler) + debouncedHandler.cancel() + } + }, [handleWindowResize]) + + useEffect(() => { + if (chatFooterRef.current && chatContainerRef.current) { + const resizeContainerObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { blockSize } = entry.borderBoxSize[0] + chatContainerRef.current!.style.paddingBottom = `${blockSize}px` + handleScrollToBottom() + } + }) + resizeContainerObserver.observe(chatFooterRef.current) + + const resizeFooterObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { inlineSize } = entry.borderBoxSize[0] + chatFooterRef.current!.style.width = `${inlineSize}px` + } + }) + resizeFooterObserver.observe(chatContainerRef.current) + + return () => { + resizeContainerObserver.disconnect() + resizeFooterObserver.disconnect() + } + } + }, [handleScrollToBottom]) + + useEffect(() => { + const setUserScrolled = () => { + const container = chatContainerRef.current + if (!container) + return + if (isAutoScrollingRef.current) + return + + const distanceToBottom = container.scrollHeight - container.clientHeight - container.scrollTop + const scrollUpThreshold = 100 + + userScrolledRef.current = distanceToBottom > scrollUpThreshold + } + + const container = chatContainerRef.current + if (!container) + return + + container.addEventListener('scroll', setUserScrolled) + return () => container.removeEventListener('scroll', setUserScrolled) + }, []) + + useEffect(() => { + const firstMessageId = chatList[0]?.id + if (chatList.length <= 1 || (firstMessageId && prevFirstMessageIdRef.current !== firstMessageId)) + userScrolledRef.current = false + prevFirstMessageIdRef.current = firstMessageId + }, [chatList]) + + useEffect(() => { + if (!sidebarCollapseState) { + const timer = setTimeout(handleWindowResize, 200) + return () => clearTimeout(timer) + } + }, [handleWindowResize, sidebarCollapseState]) + + return { + width, + chatContainerRef, + chatContainerInnerRef, + chatFooterRef, + chatFooterInnerRef, + } +} diff --git a/web/app/components/base/notion-page-selector/page-selector/__tests__/page-row.spec.tsx b/web/app/components/base/notion-page-selector/page-selector/__tests__/page-row.spec.tsx new file mode 100644 index 0000000000..dba53d7642 --- /dev/null +++ b/web/app/components/base/notion-page-selector/page-selector/__tests__/page-row.spec.tsx @@ -0,0 +1,113 @@ +import type { ComponentProps } from 'react' +import type { NotionPageRow } from '../types' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import PageRow from '../page-row' + +const buildRow = (overrides: Partial = {}): NotionPageRow => ({ + page: { + page_id: 'page-1', + page_name: 'Page 1', + parent_id: 'root', + page_icon: null, + type: 'page', + is_bound: false, + }, + parentExists: false, + depth: 0, + expand: false, + hasChild: false, + ancestors: [], + ...overrides, +}) + +const renderPageRow = (overrides: Partial> = {}) => { + const props: ComponentProps = { + checked: false, + disabled: false, + isPreviewed: false, + onPreview: vi.fn(), + onSelect: vi.fn(), + onToggle: vi.fn(), + row: buildRow(), + searchValue: '', + selectionMode: 'multiple', + showPreview: true, + style: { height: 28 }, + ...overrides, + } + + return { + ...render(), + props, + } +} + +describe('PageRow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should call onSelect with the page id when the checkbox is clicked', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + + renderPageRow({ onSelect }) + + await user.click(screen.getByTestId('checkbox-notion-page-checkbox-page-1')) + + expect(onSelect).toHaveBeenCalledWith('page-1') + }) + + it('should call onToggle when the row has children and the toggle is clicked', async () => { + const user = userEvent.setup() + const onToggle = vi.fn() + + renderPageRow({ + onToggle, + row: buildRow({ + hasChild: true, + expand: true, + }), + }) + + await user.click(screen.getByTestId('notion-page-toggle-page-1')) + + expect(onToggle).toHaveBeenCalledWith('page-1') + }) + + it('should render breadcrumbs and hide the toggle while searching', () => { + renderPageRow({ + searchValue: 'Page', + row: buildRow({ + parentExists: true, + ancestors: ['Workspace', 'Section'], + }), + }) + + expect(screen.queryByTestId('notion-page-toggle-page-1')).not.toBeInTheDocument() + expect(screen.getByText('Workspace / Section / Page 1')).toBeInTheDocument() + }) + + it('should render preview state and call onPreview when the preview button is clicked', async () => { + const user = userEvent.setup() + const onPreview = vi.fn() + + renderPageRow({ + isPreviewed: true, + onPreview, + }) + + expect(screen.getByTestId('notion-page-row-page-1')).toHaveClass('bg-state-base-hover') + + await user.click(screen.getByTestId('notion-page-preview-page-1')) + + expect(onPreview).toHaveBeenCalledWith('page-1') + }) + + it('should hide the preview button when showPreview is false', () => { + renderPageRow({ showPreview: false }) + + expect(screen.queryByTestId('notion-page-preview-page-1')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/notion-page-selector/page-selector/__tests__/use-page-selector-model.spec.ts b/web/app/components/base/notion-page-selector/page-selector/__tests__/use-page-selector-model.spec.ts new file mode 100644 index 0000000000..d90c50308d --- /dev/null +++ b/web/app/components/base/notion-page-selector/page-selector/__tests__/use-page-selector-model.spec.ts @@ -0,0 +1,127 @@ +import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common' +import { act, renderHook, waitFor } from '@testing-library/react' +import { usePageSelectorModel } from '../use-page-selector-model' + +const buildPage = (overrides: Partial): DataSourceNotionPage => ({ + page_id: 'page-id', + page_name: 'Page name', + parent_id: 'root', + page_icon: null, + type: 'page', + is_bound: false, + ...overrides, +}) + +const list: DataSourceNotionPage[] = [ + buildPage({ page_id: 'root-1', page_name: 'Root 1', parent_id: 'root' }), + buildPage({ page_id: 'child-1', page_name: 'Child 1', parent_id: 'root-1' }), + buildPage({ page_id: 'grandchild-1', page_name: 'Grandchild 1', parent_id: 'child-1' }), + buildPage({ page_id: 'child-2', page_name: 'Child 2', parent_id: 'root-1' }), +] + +const pagesMap: DataSourceNotionPageMap = { + 'root-1': { ...list[0], workspace_id: 'workspace-1' }, + 'child-1': { ...list[1], workspace_id: 'workspace-1' }, + 'grandchild-1': { ...list[2], workspace_id: 'workspace-1' }, + 'child-2': { ...list[3], workspace_id: 'workspace-1' }, +} + +const createProps = ( + overrides: Partial[0]> = {}, +): Parameters[0] => ({ + checkedIds: new Set(), + searchValue: '', + pagesMap, + list, + onSelect: vi.fn(), + previewPageId: undefined, + onPreview: vi.fn(), + selectionMode: 'multiple', + ...overrides, +}) + +describe('usePageSelectorModel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should build visible rows from the expanded tree state', async () => { + const { result } = renderHook(() => usePageSelectorModel(createProps())) + + expect(result.current.rows.map(row => row.page.page_id)).toEqual(['root-1']) + + act(() => { + result.current.handleToggle('root-1') + }) + + await waitFor(() => { + expect(result.current.rows.map(row => row.page.page_id)).toEqual([ + 'root-1', + 'child-1', + 'child-2', + ]) + }) + + act(() => { + result.current.handleToggle('child-1') + }) + + await waitFor(() => { + expect(result.current.rows.map(row => row.page.page_id)).toEqual([ + 'root-1', + 'child-1', + 'grandchild-1', + 'child-2', + ]) + }) + }) + + it('should select descendants when selecting a parent in multiple mode', () => { + const onSelect = vi.fn() + const { result } = renderHook(() => usePageSelectorModel(createProps({ onSelect }))) + + act(() => { + result.current.handleSelect('root-1') + }) + + expect(onSelect).toHaveBeenCalledWith(new Set([ + 'root-1', + 'child-1', + 'grandchild-1', + 'child-2', + ])) + }) + + it('should update local preview and respect the controlled previewPageId when provided', () => { + const onPreview = vi.fn() + const { result, rerender } = renderHook( + props => usePageSelectorModel(props), + { initialProps: createProps({ onPreview }) }, + ) + + act(() => { + result.current.handlePreview('child-1') + }) + + expect(onPreview).toHaveBeenCalledWith('child-1') + expect(result.current.currentPreviewPageId).toBe('child-1') + + rerender(createProps({ onPreview, previewPageId: 'grandchild-1' })) + + expect(result.current.currentPreviewPageId).toBe('grandchild-1') + }) + + it('should expose filtered rows when the deferred search value changes', async () => { + const { result, rerender } = renderHook( + props => usePageSelectorModel(props), + { initialProps: createProps() }, + ) + + rerender(createProps({ searchValue: 'Grandchild' })) + + await waitFor(() => { + expect(result.current.effectiveSearchValue).toBe('Grandchild') + expect(result.current.rows.map(row => row.page.page_id)).toEqual(['grandchild-1']) + }) + }) +}) diff --git a/web/app/components/base/notion-page-selector/page-selector/__tests__/utils.spec.ts b/web/app/components/base/notion-page-selector/page-selector/__tests__/utils.spec.ts new file mode 100644 index 0000000000..2e6005c573 --- /dev/null +++ b/web/app/components/base/notion-page-selector/page-selector/__tests__/utils.spec.ts @@ -0,0 +1,118 @@ +import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common' +import { + buildNotionPageTree, + getNextSelectedPageIds, + getRootPageIds, + getVisiblePageRows, +} from '../utils' + +const buildPage = (overrides: Partial): DataSourceNotionPage => ({ + page_id: 'page-id', + page_name: 'Page name', + parent_id: 'root', + page_icon: null, + type: 'page', + is_bound: false, + ...overrides, +}) + +const list: DataSourceNotionPage[] = [ + buildPage({ page_id: 'root-1', page_name: 'Root 1', parent_id: 'root' }), + buildPage({ page_id: 'child-1', page_name: 'Child 1', parent_id: 'root-1' }), + buildPage({ page_id: 'grandchild-1', page_name: 'Grandchild 1', parent_id: 'child-1' }), + buildPage({ page_id: 'child-2', page_name: 'Child 2', parent_id: 'root-1' }), + buildPage({ page_id: 'orphan-1', page_name: 'Orphan 1', parent_id: 'missing-parent' }), +] + +const pagesMap: DataSourceNotionPageMap = { + 'root-1': { ...list[0], workspace_id: 'workspace-1' }, + 'child-1': { ...list[1], workspace_id: 'workspace-1' }, + 'grandchild-1': { ...list[2], workspace_id: 'workspace-1' }, + 'child-2': { ...list[3], workspace_id: 'workspace-1' }, + 'orphan-1': { ...list[4], workspace_id: 'workspace-1' }, +} + +describe('page-selector utils', () => { + it('should build a tree with descendants, depth, and ancestors', () => { + const treeMap = buildNotionPageTree(list, pagesMap) + + expect(treeMap['root-1'].children).toEqual(new Set(['child-1', 'child-2'])) + expect(treeMap['root-1'].descendants).toEqual(new Set(['child-1', 'grandchild-1', 'child-2'])) + expect(treeMap['grandchild-1'].depth).toBe(2) + expect(treeMap['grandchild-1'].ancestors).toEqual(['Root 1', 'Child 1']) + }) + + it('should return root page ids for true roots and pages with missing parents', () => { + expect(getRootPageIds(list, pagesMap)).toEqual(['root-1', 'orphan-1']) + }) + + it('should return expanded tree rows in depth-first order when not searching', () => { + const treeMap = buildNotionPageTree(list, pagesMap) + + const rows = getVisiblePageRows({ + list, + pagesMap, + searchValue: '', + treeMap, + rootPageIds: ['root-1'], + expandedIds: new Set(['root-1', 'child-1']), + }) + + expect(rows.map(row => row.page.page_id)).toEqual([ + 'root-1', + 'child-1', + 'grandchild-1', + 'child-2', + ]) + }) + + it('should return filtered search rows with ancestry metadata when searching', () => { + const treeMap = buildNotionPageTree(list, pagesMap) + + const rows = getVisiblePageRows({ + list, + pagesMap, + searchValue: 'Grandchild', + treeMap, + rootPageIds: ['root-1'], + expandedIds: new Set(), + }) + + expect(rows).toEqual([ + expect.objectContaining({ + page: expect.objectContaining({ page_id: 'grandchild-1' }), + ancestors: ['Root 1', 'Child 1'], + hasChild: false, + parentExists: true, + }), + ]) + }) + + it('should toggle selected ids correctly in single and multiple mode', () => { + const treeMap = buildNotionPageTree(list, pagesMap) + + expect(getNextSelectedPageIds({ + checkedIds: new Set(['root-1']), + pageId: 'child-1', + searchValue: '', + selectionMode: 'single', + treeMap, + })).toEqual(new Set(['child-1'])) + + expect(getNextSelectedPageIds({ + checkedIds: new Set(), + pageId: 'root-1', + searchValue: '', + selectionMode: 'multiple', + treeMap, + })).toEqual(new Set(['root-1', 'child-1', 'grandchild-1', 'child-2'])) + + expect(getNextSelectedPageIds({ + checkedIds: new Set(['child-1']), + pageId: 'child-1', + searchValue: 'Child', + selectionMode: 'multiple', + treeMap, + })).toEqual(new Set()) + }) +}) diff --git a/web/app/components/base/notion-page-selector/page-selector/__tests__/virtual-page-list.spec.tsx b/web/app/components/base/notion-page-selector/page-selector/__tests__/virtual-page-list.spec.tsx new file mode 100644 index 0000000000..7ad4f29d3e --- /dev/null +++ b/web/app/components/base/notion-page-selector/page-selector/__tests__/virtual-page-list.spec.tsx @@ -0,0 +1,144 @@ +import type { ComponentProps } from 'react' +import type { NotionPageRow } from '../types' +import { render, screen } from '@testing-library/react' +import VirtualPageList from '../virtual-page-list' + +vi.mock('@tanstack/react-virtual') + +const pageRowPropsSpy = vi.fn() +type MockPageRowProps = ComponentProps + +vi.mock('../page-row', () => ({ + default: ({ + checked, + disabled, + isPreviewed, + onPreview, + onSelect, + onToggle, + row, + searchValue, + selectionMode, + showPreview, + style, + }: MockPageRowProps) => { + pageRowPropsSpy({ + checked, + disabled, + isPreviewed, + onPreview, + onSelect, + onToggle, + row, + searchValue, + selectionMode, + showPreview, + style, + }) + return
+ }, +})) + +const buildRow = (overrides: Partial = {}): NotionPageRow => ({ + page: { + page_id: 'page-1', + page_name: 'Page 1', + parent_id: 'root', + page_icon: null, + type: 'page', + is_bound: false, + }, + parentExists: false, + depth: 0, + expand: false, + hasChild: false, + ancestors: [], + ...overrides, +}) + +describe('VirtualPageList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render virtual rows and pass row state to PageRow', () => { + const rows = [ + buildRow(), + buildRow({ + page: { + page_id: 'page-2', + page_name: 'Page 2', + parent_id: 'root', + page_icon: null, + type: 'page', + is_bound: false, + }, + }), + ] + + render( + , + ) + + expect(screen.getByTestId('virtual-list')).toBeInTheDocument() + expect(screen.getByTestId('page-row-page-1')).toBeInTheDocument() + expect(screen.getByTestId('page-row-page-2')).toBeInTheDocument() + expect(pageRowPropsSpy).toHaveBeenNthCalledWith(1, expect.objectContaining({ + checked: true, + disabled: false, + isPreviewed: false, + searchValue: '', + selectionMode: 'multiple', + showPreview: true, + row: rows[0], + style: expect.objectContaining({ + height: '28px', + width: 'calc(100% - 16px)', + }), + })) + expect(pageRowPropsSpy).toHaveBeenNthCalledWith(2, expect.objectContaining({ + checked: false, + disabled: true, + isPreviewed: true, + row: rows[1], + })) + }) + + it('should size the virtual container using the row estimate', () => { + const rows = [buildRow(), buildRow()] + + render( + ()} + disabledValue={new Set()} + onPreview={vi.fn()} + onSelect={vi.fn()} + onToggle={vi.fn()} + previewPageId="" + rows={rows} + searchValue="" + selectionMode="multiple" + showPreview={false} + />, + ) + + const list = screen.getByTestId('virtual-list') + const innerContainer = list.firstElementChild as HTMLElement + + expect(innerContainer).toHaveStyle({ + height: '56px', + position: 'relative', + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx b/web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx new file mode 100644 index 0000000000..02de482073 --- /dev/null +++ b/web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx @@ -0,0 +1,295 @@ +import type { EventEmitter } from 'ahooks/lib/useEventEmitter' +import type { LexicalEditor } from 'lexical' +import type { ComponentProps } from 'react' +import type { EventEmitterValue } from '@/context/event-emitter' +import { CodeNode } from '@lexical/code' +import { LexicalComposer } from '@lexical/react/LexicalComposer' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { + BLUR_COMMAND, + COMMAND_PRIORITY_EDITOR, + createCommand, + FOCUS_COMMAND, + TextNode, +} from 'lexical' +import { useEffect } from 'react' +import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types' +import { useEventEmitterContextContext } from '@/context/event-emitter' +import { EventEmitterContextProvider } from '@/context/event-emitter-provider' +import { ContextBlockNode } from '../plugins/context-block' +import { CurrentBlockNode } from '../plugins/current-block' +import { CustomTextNode } from '../plugins/custom-text/node' +import { ErrorMessageBlockNode } from '../plugins/error-message-block' +import { HistoryBlockNode } from '../plugins/history-block' +import { HITLInputNode } from '../plugins/hitl-input-block' +import { LastRunBlockNode } from '../plugins/last-run-block' +import { QueryBlockNode } from '../plugins/query-block' +import { RequestURLBlockNode } from '../plugins/request-url-block' +import { PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER } from '../plugins/update-block' +import { VariableValueBlockNode } from '../plugins/variable-value-block/node' +import { WorkflowVariableBlockNode } from '../plugins/workflow-variable-block' +import PromptEditorContent from '../prompt-editor-content' +import { textToEditorState } from '../utils' + +type Captures = { + editor: LexicalEditor | null + eventEmitter: EventEmitter | null +} + +const mockDOMRect = { + x: 100, + y: 100, + width: 100, + height: 20, + top: 100, + right: 200, + bottom: 120, + left: 100, + toJSON: () => ({}), +} + +const originalRangeGetClientRects = Range.prototype.getClientRects +const originalRangeGetBoundingClientRect = Range.prototype.getBoundingClientRect + +const setSelectionOnEditable = (editable: HTMLElement) => { + const lexicalTextNode = editable.querySelector('[data-lexical-text="true"]')?.firstChild + const range = document.createRange() + + if (lexicalTextNode) { + range.setStart(lexicalTextNode, 0) + range.setEnd(lexicalTextNode, 1) + } + else { + range.selectNodeContents(editable) + } + + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) +} + +const CaptureEditorAndEmitter = ({ captures }: { captures: Captures }) => { + const { eventEmitter } = useEventEmitterContextContext() + const [editor] = useLexicalComposerContext() + + useEffect(() => { + captures.editor = editor + }, [captures, editor]) + + useEffect(() => { + captures.eventEmitter = eventEmitter + }, [captures, eventEmitter]) + + return null +} + +const PromptEditorContentHarness = ({ + captures, + initialText = '', + ...props +}: ComponentProps & { captures: Captures, initialText?: string }) => ( + + new CustomTextNode(node.__text), + withKlass: CustomTextNode, + }, + ContextBlockNode, + HistoryBlockNode, + QueryBlockNode, + RequestURLBlockNode, + WorkflowVariableBlockNode, + VariableValueBlockNode, + HITLInputNode, + CurrentBlockNode, + ErrorMessageBlockNode, + LastRunBlockNode, + ], + editorState: textToEditorState(initialText), + onError: (error: Error) => { + throw error + }, + }} + > + + + + +) + +describe('PromptEditorContent', () => { + beforeAll(() => { + Range.prototype.getClientRects = vi.fn(() => { + const rectList = [mockDOMRect] as unknown as DOMRectList + Object.defineProperty(rectList, 'length', { value: 1 }) + Object.defineProperty(rectList, 'item', { value: (index: number) => index === 0 ? mockDOMRect : null }) + return rectList + }) + Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect) + }) + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterAll(() => { + Range.prototype.getClientRects = originalRangeGetClientRects + Range.prototype.getBoundingClientRect = originalRangeGetBoundingClientRect + }) + + // The extracted content shell should run with the real Lexical stack and forward editor commands through its composed plugins. + describe('Rendering', () => { + it('should render with real dependencies and forward update/focus/blur events', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + const onEditorChange = vi.fn() + const onFocus = vi.fn() + const onBlur = vi.fn() + const anchorElem = document.createElement('div') + + const { container } = render( + , + ) + + expect(screen.getByText('Type prompt')).toBeInTheDocument() + + const editable = container.querySelector('[contenteditable="true"]') as HTMLElement + expect(editable.className).toContain('text-[13px]') + + await waitFor(() => { + expect(captures.editor).not.toBeNull() + expect(captures.eventEmitter).not.toBeNull() + }) + + act(() => { + captures.eventEmitter?.emit({ + type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER, + instanceId: 'content-editor', + payload: 'updated prompt', + }) + }) + + await waitFor(() => { + expect(onEditorChange).toHaveBeenCalled() + }) + + act(() => { + captures.editor?.dispatchCommand(FOCUS_COMMAND, new FocusEvent('focus')) + captures.editor?.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: null })) + }) + + expect(onFocus).toHaveBeenCalledTimes(1) + expect(onBlur).toHaveBeenCalledTimes(1) + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('should render optional blocks and open shortcut popups with the real editor runtime', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + const onEditorChange = vi.fn() + const insertCommand = createCommand('prompt-editor-shortcut-insert') + const insertSpy = vi.fn() + const Popup = ({ onClose, onInsert }: { onClose: () => void, onInsert: (command: typeof insertCommand, params: string[]) => void }) => ( + <> + + + + ) + + const { container } = render( + , + ) + + await waitFor(() => { + expect(captures.editor).not.toBeNull() + }) + + const unregister = captures.editor?.registerCommand( + insertCommand, + (payload) => { + insertSpy(payload) + return true + }, + COMMAND_PRIORITY_EDITOR, + ) + + const editable = container.querySelector('[contenteditable="true"]') as HTMLElement + editable.focus() + setSelectionOnEditable(editable) + + fireEvent.keyDown(document, { key: '/', ctrlKey: true }) + + const insertButton = await screen.findByRole('button', { name: 'Insert shortcut' }) + fireEvent.click(insertButton) + + expect(insertSpy).toHaveBeenCalledWith(['from-shortcut']) + expect(onEditorChange).toHaveBeenCalled() + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Insert shortcut' })).not.toBeInTheDocument() + }) + + unregister?.() + }) + + it('should keep the shell stable without optional anchor or placeholder overrides', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + + render( + , + ) + + await waitFor(() => { + expect(captures.editor).not.toBeNull() + }) + + expect(screen.queryByTestId('draggable-target-line')).not.toBeInTheDocument() + expect(screen.getByText('common.promptEditor.placeholder')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/index.tsx b/web/app/components/base/prompt-editor/index.tsx index 772d15e4cf..6f6da6901b 100644 --- a/web/app/components/base/prompt-editor/index.tsx +++ b/web/app/components/base/prompt-editor/index.tsx @@ -22,11 +22,6 @@ import type { } from './types' import { CodeNode } from '@lexical/code' import { LexicalComposer } from '@lexical/react/LexicalComposer' -import { ContentEditable } from '@lexical/react/LexicalContentEditable' -import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary' -import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin' -import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin' -import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' import { $getRoot, TextNode, @@ -39,63 +34,37 @@ import { UPDATE_DATASETS_EVENT_EMITTER, UPDATE_HISTORY_EVENT_EMITTER, } from './constants' -import ComponentPickerBlock from './plugins/component-picker-block' import { - ContextBlock, ContextBlockNode, - ContextBlockReplacementBlock, } from './plugins/context-block' import { - CurrentBlock, CurrentBlockNode, - CurrentBlockReplacementBlock, } from './plugins/current-block' import { CustomTextNode } from './plugins/custom-text/node' -import DraggableBlockPlugin from './plugins/draggable-plugin' import { - ErrorMessageBlock, ErrorMessageBlockNode, - ErrorMessageBlockReplacementBlock, } from './plugins/error-message-block' import { - HistoryBlock, HistoryBlockNode, - HistoryBlockReplacementBlock, } from './plugins/history-block' import { - HITLInputBlock, - HITLInputBlockReplacementBlock, HITLInputNode, } from './plugins/hitl-input-block' import { - LastRunBlock, LastRunBlockNode, - LastRunReplacementBlock, } from './plugins/last-run-block' -import OnBlurBlock from './plugins/on-blur-or-focus-block' -// import TreeView from './plugins/tree-view' -import Placeholder from './plugins/placeholder' import { - QueryBlock, QueryBlockNode, - QueryBlockReplacementBlock, } from './plugins/query-block' import { - RequestURLBlock, RequestURLBlockNode, - RequestURLBlockReplacementBlock, } from './plugins/request-url-block' -import ShortcutsPopupPlugin from './plugins/shortcuts-popup-plugin' -import UpdateBlock from './plugins/update-block' -import VariableBlock from './plugins/variable-block' -import VariableValueBlock from './plugins/variable-value-block' import { VariableValueBlockNode } from './plugins/variable-value-block/node' import { - WorkflowVariableBlock, WorkflowVariableBlockNode, - WorkflowVariableBlockReplacementBlock, } from './plugins/workflow-variable-block' +import PromptEditorContent from './prompt-editor-content' import { textToEditorState } from './utils' export type PromptEditorProps = { @@ -214,152 +183,31 @@ const PromptEditor: FC = ({ return (
- - )} - placeholder={( - - )} - ErrorBoundary={LexicalErrorBoundary} - /> - {shortcutPopups?.map(({ hotkey, Popup }, idx) => ( - - {(closePortal, onInsert) => } - - ))} - - - { - contextBlock?.show && ( - <> - - - - ) - } - { - queryBlock?.show && ( - <> - - - - ) - } - { - historyBlock?.show && ( - <> - - - - ) - } - { - (variableBlock?.show || externalToolBlock?.show) && ( - <> - - - - ) - } - { - workflowVariableBlock?.show && ( - <> - - - - ) - } - { - hitlInputBlock?.show && ( - <> - - - - ) - } - { - currentBlock?.show && ( - <> - - - - ) - } - { - requestURLBlock?.show && ( - <> - - - - ) - } - { - errorMessageBlock?.show && ( - <> - - - - ) - } - { - lastRunBlock?.show && ( - <> - - - - ) - } - { - isSupportFileVar && ( - - ) - } - - - - - {floatingAnchorElem && ( - - )} - {/* */}
) diff --git a/web/app/components/base/prompt-editor/prompt-editor-content.tsx b/web/app/components/base/prompt-editor/prompt-editor-content.tsx new file mode 100644 index 0000000000..07db69cfc8 --- /dev/null +++ b/web/app/components/base/prompt-editor/prompt-editor-content.tsx @@ -0,0 +1,257 @@ +import type { + EditorState, + LexicalCommand, +} from 'lexical' +import type { FC } from 'react' +import type { Hotkey } from './plugins/shortcuts-popup-plugin' +import type { + ContextBlockType, + CurrentBlockType, + ErrorMessageBlockType, + ExternalToolBlockType, + HistoryBlockType, + HITLInputBlockType, + LastRunBlockType, + QueryBlockType, + RequestURLBlockType, + VariableBlockType, + WorkflowVariableBlockType, +} from './types' +import { ContentEditable } from '@lexical/react/LexicalContentEditable' +import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary' +import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin' +import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin' +import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' +import * as React from 'react' +import { cn } from '@/utils/classnames' +import ComponentPickerBlock from './plugins/component-picker-block' +import { + ContextBlock, + ContextBlockReplacementBlock, +} from './plugins/context-block' +import { + CurrentBlock, + CurrentBlockReplacementBlock, +} from './plugins/current-block' +import DraggableBlockPlugin from './plugins/draggable-plugin' +import { + ErrorMessageBlock, + ErrorMessageBlockReplacementBlock, +} from './plugins/error-message-block' +import { + HistoryBlock, + HistoryBlockReplacementBlock, +} from './plugins/history-block' +import { + HITLInputBlock, + HITLInputBlockReplacementBlock, +} from './plugins/hitl-input-block' +import { + LastRunBlock, + LastRunReplacementBlock, +} from './plugins/last-run-block' +import OnBlurBlock from './plugins/on-blur-or-focus-block' +import Placeholder from './plugins/placeholder' +import { + QueryBlock, + QueryBlockReplacementBlock, +} from './plugins/query-block' +import { + RequestURLBlock, + RequestURLBlockReplacementBlock, +} from './plugins/request-url-block' +import ShortcutsPopupPlugin from './plugins/shortcuts-popup-plugin' +import UpdateBlock from './plugins/update-block' +import VariableBlock from './plugins/variable-block' +import VariableValueBlock from './plugins/variable-value-block' +import { + WorkflowVariableBlock, + WorkflowVariableBlockReplacementBlock, +} from './plugins/workflow-variable-block' + +type ShortcutPopup = { + hotkey: Hotkey + Popup: React.ComponentType<{ onClose: () => void, onInsert: (command: LexicalCommand, params: unknown[]) => void }> +} + +type PromptEditorContentProps = { + compact?: boolean + className?: string + placeholder?: string | React.ReactNode + placeholderClassName?: string + style?: React.CSSProperties + shortcutPopups: ShortcutPopup[] + contextBlock?: ContextBlockType + queryBlock?: QueryBlockType + requestURLBlock?: RequestURLBlockType + historyBlock?: HistoryBlockType + variableBlock?: VariableBlockType + externalToolBlock?: ExternalToolBlockType + workflowVariableBlock?: WorkflowVariableBlockType + hitlInputBlock?: HITLInputBlockType + currentBlock?: CurrentBlockType + errorMessageBlock?: ErrorMessageBlockType + lastRunBlock?: LastRunBlockType + isSupportFileVar?: boolean + onBlur?: () => void + onFocus?: () => void + instanceId?: string + floatingAnchorElem: HTMLDivElement | null + onEditorChange: (editorState: EditorState) => void +} + +const PromptEditorContent: FC = ({ + compact, + className, + placeholder, + placeholderClassName, + style, + shortcutPopups, + contextBlock, + queryBlock, + requestURLBlock, + historyBlock, + variableBlock, + externalToolBlock, + workflowVariableBlock, + hitlInputBlock, + currentBlock, + errorMessageBlock, + lastRunBlock, + isSupportFileVar, + onBlur, + onFocus, + instanceId, + floatingAnchorElem, + onEditorChange, +}) => { + return ( + <> + + )} + placeholder={( + + )} + ErrorBoundary={LexicalErrorBoundary} + /> + {shortcutPopups.map(({ hotkey, Popup }, idx) => ( + + {(closePortal, onInsert) => } + + ))} + + + {contextBlock?.show && ( + <> + + + + )} + {queryBlock?.show && ( + <> + + + + )} + {historyBlock?.show && ( + <> + + + + )} + {(variableBlock?.show || externalToolBlock?.show) && ( + <> + + + + )} + {workflowVariableBlock?.show && ( + <> + + + + )} + {hitlInputBlock?.show && ( + <> + + + + )} + {currentBlock?.show && ( + <> + + + + )} + {requestURLBlock?.show && ( + <> + + + + )} + {errorMessageBlock?.show && ( + <> + + + + )} + {lastRunBlock?.show && ( + <> + + + + )} + {isSupportFileVar && ( + + )} + + + + + {floatingAnchorElem && ( + + )} + + ) +} + +export default PromptEditorContent diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/loading.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/loading.spec.tsx new file mode 100644 index 0000000000..ada7aa7b7f --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/loading.spec.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import Loading from '../loading' + +vi.mock('@/app/components/base/skeleton', () => ({ + SkeletonContainer: ({ children, className }: { children?: ReactNode, className?: string }) => ( +
{children}
+ ), + SkeletonRectangle: ({ className }: { className?: string }) => ( +
+ ), +})) + +describe('CreateFromPipelinePreviewLoading', () => { + it('should render the preview loading shell and all skeleton blocks', () => { + const { container } = render() + + expect(container.firstElementChild).toHaveClass( + 'flex', + 'h-full', + 'w-full', + 'flex-col', + 'overflow-hidden', + 'px-6', + 'py-5', + ) + expect(screen.getAllByTestId('skeleton-container')).toHaveLength(6) + expect(screen.getAllByTestId('skeleton-rectangle')).toHaveLength(29) + }) +}) diff --git a/web/app/components/datasets/documents/detail/__tests__/context.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/context.spec.tsx new file mode 100644 index 0000000000..9524998290 --- /dev/null +++ b/web/app/components/datasets/documents/detail/__tests__/context.spec.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react' +import { renderHook } from '@testing-library/react' +import { DocumentContext, useDocumentContext } from '../context' + +describe('DocumentContext', () => { + it('should return the default empty context value when no provider is present', () => { + const { result } = renderHook(() => useDocumentContext(value => value)) + + expect(result.current).toEqual({}) + }) + + it('should select values from the nearest provider', () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + + const { result } = renderHook( + () => useDocumentContext(value => `${value.datasetId}:${value.documentId}`), + { wrapper }, + ) + + expect(result.current).toBe('dataset-1:document-1') + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/segment-list-context.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/segment-list-context.spec.tsx new file mode 100644 index 0000000000..f3fa0d0929 --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/__tests__/segment-list-context.spec.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from 'react' +import { renderHook } from '@testing-library/react' +import { SegmentListContext, useSegmentListContext } from '../segment-list-context' + +describe('SegmentListContext', () => { + it('should expose the default collapsed state', () => { + const { result } = renderHook(() => useSegmentListContext(value => value)) + + expect(result.current).toEqual({ + isCollapsed: true, + fullScreen: false, + toggleFullScreen: expect.any(Function), + currSegment: { showModal: false }, + currChildChunk: { showModal: false }, + }) + }) + + it('should select provider values from the current segment list context', () => { + const toggleFullScreen = vi.fn() + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + + const { result } = renderHook( + () => useSegmentListContext(value => ({ + fullScreen: value.fullScreen, + segmentOpen: value.currSegment.showModal, + childOpen: value.currChildChunk.showModal, + })), + { wrapper }, + ) + + expect(result.current).toEqual({ + fullScreen: true, + segmentOpen: true, + childOpen: true, + }) + }) +}) diff --git a/web/app/components/develop/hooks/__tests__/use-doc-toc.spec.tsx b/web/app/components/develop/hooks/__tests__/use-doc-toc.spec.tsx new file mode 100644 index 0000000000..b153f24179 --- /dev/null +++ b/web/app/components/develop/hooks/__tests__/use-doc-toc.spec.tsx @@ -0,0 +1,125 @@ +import type { TocItem } from '../use-doc-toc' +import { act, renderHook } from '@testing-library/react' +import { useDocToc } from '../use-doc-toc' + +const mockMatchMedia = (matches: boolean) => { + vi.stubGlobal('matchMedia', vi.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }))) +} + +const setupDocument = () => { + document.body.innerHTML = ` +
+ + ` + + const scrollContainer = document.querySelector('.overflow-auto') as HTMLDivElement + scrollContainer.scrollTo = vi.fn() + + const intro = document.getElementById('intro') as HTMLElement + const details = document.getElementById('details') as HTMLElement + + Object.defineProperty(intro, 'offsetTop', { configurable: true, value: 140 }) + Object.defineProperty(details, 'offsetTop', { configurable: true, value: 320 }) + + return { + scrollContainer, + intro, + details, + } +} + +describe('useDocToc', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + document.body.innerHTML = '' + mockMatchMedia(false) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('should extract headings and expand the TOC on wide screens', async () => { + setupDocument() + mockMatchMedia(true) + + const { result } = renderHook(() => useDocToc({ + appDetail: { id: 'app-1' }, + locale: 'en', + })) + + act(() => { + vi.runAllTimers() + }) + + expect(result.current.toc).toEqual([ + { href: '#intro', text: 'Intro' }, + { href: '#details', text: 'Details' }, + ]) + expect(result.current.activeSection).toBe('intro') + expect(result.current.isTocExpanded).toBe(true) + }) + + it('should update the active section when the scroll container scrolls', async () => { + const { scrollContainer, intro, details } = setupDocument() + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 }) + + intro.getBoundingClientRect = vi.fn(() => ({ top: 500 } as DOMRect)) + details.getBoundingClientRect = vi.fn(() => ({ top: 300 } as DOMRect)) + + const { result } = renderHook(() => useDocToc({ + appDetail: { id: 'app-1' }, + locale: 'en', + })) + + act(() => { + vi.runAllTimers() + }) + + act(() => { + scrollContainer.dispatchEvent(new Event('scroll')) + }) + + expect(result.current.activeSection).toBe('details') + }) + + it('should scroll the container to the clicked heading offset', async () => { + const { scrollContainer } = setupDocument() + const { result } = renderHook(() => useDocToc({ + appDetail: { id: 'app-1' }, + locale: 'en', + })) + + act(() => { + vi.runAllTimers() + }) + + const preventDefault = vi.fn() + act(() => { + result.current.handleTocClick( + { preventDefault } as unknown as React.MouseEvent, + { href: '#details', text: 'Details' }, + ) + }) + + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(scrollContainer.scrollTo).toHaveBeenCalledWith({ + top: 240, + behavior: 'smooth', + }) + }) +}) diff --git a/web/app/components/goto-anything/actions/__tests__/node-actions.spec.ts b/web/app/components/goto-anything/actions/__tests__/node-actions.spec.ts new file mode 100644 index 0000000000..1594662691 --- /dev/null +++ b/web/app/components/goto-anything/actions/__tests__/node-actions.spec.ts @@ -0,0 +1,69 @@ +import type { SearchResult } from '../types' +import { ragPipelineNodesAction } from '../rag-pipeline-nodes' +import { workflowNodesAction } from '../workflow-nodes' + +describe('workflowNodesAction', () => { + beforeEach(() => { + vi.clearAllMocks() + workflowNodesAction.searchFn = undefined + }) + + it('should return an empty result when no workflow search function is registered', async () => { + await expect(workflowNodesAction.search('@node llm', 'llm', 'en')).resolves.toEqual([]) + }) + + it('should delegate to the injected workflow search function', async () => { + const results: SearchResult[] = [ + { id: 'workflow-node-1', title: 'LLM', type: 'workflow-node', data: {} as never }, + ] + workflowNodesAction.searchFn = vi.fn().mockReturnValue(results) + + await expect(workflowNodesAction.search('@node llm', 'llm', 'en')).resolves.toEqual(results) + expect(workflowNodesAction.searchFn).toHaveBeenCalledWith('llm') + }) + + it('should warn and return an empty list when workflow node search throws', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + workflowNodesAction.searchFn = vi.fn(() => { + throw new Error('failed') + }) + + await expect(workflowNodesAction.search('@node llm', 'llm', 'en')).resolves.toEqual([]) + expect(warnSpy).toHaveBeenCalledWith('Workflow nodes search failed:', expect.any(Error)) + + warnSpy.mockRestore() + }) +}) + +describe('ragPipelineNodesAction', () => { + beforeEach(() => { + vi.clearAllMocks() + ragPipelineNodesAction.searchFn = undefined + }) + + it('should return an empty result when no rag pipeline search function is registered', async () => { + await expect(ragPipelineNodesAction.search('@node embed', 'embed', 'en')).resolves.toEqual([]) + }) + + it('should delegate to the injected rag pipeline search function', async () => { + const results: SearchResult[] = [ + { id: 'rag-node-1', title: 'Retriever', type: 'workflow-node', data: {} as never }, + ] + ragPipelineNodesAction.searchFn = vi.fn().mockReturnValue(results) + + await expect(ragPipelineNodesAction.search('@node retrieve', 'retrieve', 'en')).resolves.toEqual(results) + expect(ragPipelineNodesAction.searchFn).toHaveBeenCalledWith('retrieve') + }) + + it('should warn and return an empty list when rag pipeline node search throws', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + ragPipelineNodesAction.searchFn = vi.fn(() => { + throw new Error('failed') + }) + + await expect(ragPipelineNodesAction.search('@node retrieve', 'retrieve', 'en')).resolves.toEqual([]) + expect(warnSpy).toHaveBeenCalledWith('RAG pipeline nodes search failed:', expect.any(Error)) + + warnSpy.mockRestore() + }) +}) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx b/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx new file mode 100644 index 0000000000..46d1faba2e --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx @@ -0,0 +1,124 @@ +import type { SearchResult } from '../../types' +import { render } from '@testing-library/react' +import { slashAction, SlashCommandProvider } from '../slash' + +const { + mockSetTheme, + mockSetLocale, + mockExecuteCommand, + mockRegister, + mockSearch, + mockUnregister, +} = vi.hoisted(() => ({ + mockSetTheme: vi.fn(), + mockSetLocale: vi.fn(), + mockExecuteCommand: vi.fn(), + mockRegister: vi.fn(), + mockSearch: vi.fn(), + mockUnregister: vi.fn(), +})) + +vi.mock('next-themes', () => ({ + useTheme: () => ({ + setTheme: mockSetTheme, + }), +})) + +vi.mock('react-i18next', () => ({ + getI18n: () => ({ + language: 'ja', + t: (key: string) => key, + }), +})) + +vi.mock('@/i18n-config', () => ({ + setLocaleOnClient: mockSetLocale, +})) + +vi.mock('../command-bus', () => ({ + executeCommand: (...args: unknown[]) => mockExecuteCommand(...args), +})) + +vi.mock('../registry', () => ({ + slashCommandRegistry: { + register: (...args: unknown[]) => mockRegister(...args), + search: (...args: unknown[]) => mockSearch(...args), + unregister: (...args: unknown[]) => mockUnregister(...args), + }, +})) + +describe('slashAction', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should expose translated title and description', () => { + expect(slashAction.title).toBe('gotoAnything.actions.slashTitle') + expect(slashAction.description).toBe('gotoAnything.actions.slashDesc') + }) + + it('should execute command results and ignore non-command results', () => { + slashAction.action?.({ + id: 'cmd-1', + title: 'Command', + type: 'command', + data: { + command: 'navigation.docs', + args: { path: '/docs' }, + }, + } as SearchResult) + + slashAction.action?.({ + id: 'app-1', + title: 'App', + type: 'app', + data: {} as never, + } as SearchResult) + + expect(mockExecuteCommand).toHaveBeenCalledTimes(1) + expect(mockExecuteCommand).toHaveBeenCalledWith('navigation.docs', { path: '/docs' }) + }) + + it('should delegate search to the slash command registry with the active language', async () => { + mockSearch.mockResolvedValue([{ id: 'theme', title: '/theme', type: 'command', data: { command: 'theme' } }]) + + const results = await slashAction.search('/theme dark', 'dark') + + expect(mockSearch).toHaveBeenCalledWith('/theme dark', 'ja') + expect(results).toEqual([{ id: 'theme', title: '/theme', type: 'command', data: { command: 'theme' } }]) + }) +}) + +describe('SlashCommandProvider', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should register commands on mount and unregister them on unmount', () => { + const { unmount } = render() + + expect(mockRegister.mock.calls.map(call => call[0].name)).toEqual([ + 'theme', + 'language', + 'forum', + 'docs', + 'community', + 'account', + 'zen', + ]) + expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'theme' }), { setTheme: mockSetTheme }) + expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'language' }), { setLocale: mockSetLocale }) + + unmount() + + expect(mockUnregister.mock.calls.map(call => call[0])).toEqual([ + 'theme', + 'language', + 'forum', + 'docs', + 'community', + 'account', + 'zen', + ]) + }) +}) diff --git a/web/app/components/header/account-dropdown/__tests__/menu-item-content.spec.tsx b/web/app/components/header/account-dropdown/__tests__/menu-item-content.spec.tsx new file mode 100644 index 0000000000..f7f53b6ab4 --- /dev/null +++ b/web/app/components/header/account-dropdown/__tests__/menu-item-content.spec.tsx @@ -0,0 +1,28 @@ +import { render, screen } from '@testing-library/react' +import { ExternalLinkIndicator, MenuItemContent } from '../menu-item-content' + +describe('MenuItemContent', () => { + it('should render the icon, label, and trailing content', () => { + const { container } = render( + Soon} + />, + ) + + expect(screen.getByText('Settings')).toBeInTheDocument() + expect(screen.getByTestId('menu-trailing')).toHaveTextContent('Soon') + expect(container.querySelector('.i-ri-settings-4-line')).toBeInTheDocument() + }) +}) + +describe('ExternalLinkIndicator', () => { + it('should render the external-link icon with aria-hidden semantics', () => { + const { container } = render() + + const indicator = container.querySelector('.i-ri-arrow-right-up-line') + expect(indicator).toBeInTheDocument() + expect(indicator).toHaveAttribute('aria-hidden') + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/index.spec.ts b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/index.spec.ts new file mode 100644 index 0000000000..7387234c67 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/index.spec.ts @@ -0,0 +1,23 @@ +import * as ModelAuth from '../index' + +vi.mock('../add-credential-in-load-balancing', () => ({ default: 'AddCredentialInLoadBalancing' })) +vi.mock('../add-custom-model', () => ({ default: 'AddCustomModel' })) +vi.mock('../authorized', () => ({ default: 'Authorized' })) +vi.mock('../config-model', () => ({ default: 'ConfigModel' })) +vi.mock('../credential-selector', () => ({ default: 'CredentialSelector' })) +vi.mock('../manage-custom-model-credentials', () => ({ default: 'ManageCustomModelCredentials' })) +vi.mock('../switch-credential-in-load-balancing', () => ({ default: 'SwitchCredentialInLoadBalancing' })) + +describe('model-auth index exports', () => { + it('should re-export the model auth entry points', () => { + expect(ModelAuth).toMatchObject({ + AddCredentialInLoadBalancing: 'AddCredentialInLoadBalancing', + AddCustomModel: 'AddCustomModel', + Authorized: 'Authorized', + ConfigModel: 'ConfigModel', + CredentialSelector: 'CredentialSelector', + ManageCustomModelCredentials: 'ManageCustomModelCredentials', + SwitchCredentialInLoadBalancing: 'SwitchCredentialInLoadBalancing', + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/credits-fallback-alert.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/credits-fallback-alert.spec.tsx new file mode 100644 index 0000000000..2d634d8673 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/credits-fallback-alert.spec.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react' +import CreditsFallbackAlert from '../credits-fallback-alert' + +describe('CreditsFallbackAlert', () => { + it('should render the credential fallback copy and description when credentials exist', () => { + render() + + expect(screen.getByText('common.modelProvider.card.apiKeyUnavailableFallback')).toBeInTheDocument() + expect(screen.getByText('common.modelProvider.card.apiKeyUnavailableFallbackDescription')).toBeInTheDocument() + }) + + it('should render the no-credentials fallback copy without the description', () => { + render() + + expect(screen.getByText('common.modelProvider.card.noApiKeysFallback')).toBeInTheDocument() + expect(screen.queryByText('common.modelProvider.card.apiKeyUnavailableFallbackDescription')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/plugins-nav/__tests__/downloading-icon.spec.tsx b/web/app/components/header/plugins-nav/__tests__/downloading-icon.spec.tsx new file mode 100644 index 0000000000..2481a8c0b3 --- /dev/null +++ b/web/app/components/header/plugins-nav/__tests__/downloading-icon.spec.tsx @@ -0,0 +1,16 @@ +import { render } from '@testing-library/react' +import DownloadingIcon from '../downloading-icon' + +describe('DownloadingIcon', () => { + it('should render the animated install icon wrapper and svg markup', () => { + const { container } = render() + + const wrapper = container.firstElementChild as HTMLElement + const svg = container.querySelector('svg.install-icon') + + expect(wrapper).toHaveClass('inline-flex', 'text-components-button-secondary-text') + expect(svg).toBeInTheDocument() + expect(svg).toHaveAttribute('viewBox', '0 0 24 24') + expect(svg?.querySelectorAll('path')).toHaveLength(3) + }) +}) diff --git a/web/app/components/share/text-generation/__tests__/index.spec.tsx b/web/app/components/share/text-generation/__tests__/index.spec.tsx new file mode 100644 index 0000000000..e3746b1da1 --- /dev/null +++ b/web/app/components/share/text-generation/__tests__/index.spec.tsx @@ -0,0 +1,219 @@ +import type { TextGenerationRunControl } from '../types' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { AccessMode } from '@/models/access-control' +import TextGeneration from '../index' + +const { + mockMode, + mockMedia, + mockAppStateRef, + mockBatchStateRef, + sidebarPropsSpy, + resultPanelPropsSpy, + mockSetIsCallBatchAPI, + mockResetBatchExecution, + mockHandleRunBatch, +} = vi.hoisted(() => ({ + mockMode: { value: 'create' }, + mockMedia: { value: 'pc' }, + mockAppStateRef: { value: null as unknown }, + mockBatchStateRef: { value: null as unknown }, + sidebarPropsSpy: vi.fn(), + resultPanelPropsSpy: vi.fn(), + mockSetIsCallBatchAPI: vi.fn(), + mockResetBatchExecution: vi.fn(), + mockHandleRunBatch: vi.fn(), +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + MediaType: { + mobile: 'mobile', + pc: 'pc', + tablet: 'tablet', + }, + default: () => mockMedia.value, +})) + +vi.mock('@/next/navigation', () => ({ + useSearchParams: () => ({ + get: (key: string) => key === 'mode' ? mockMode.value : null, + }), +})) + +vi.mock('@/app/components/base/loading', () => ({ + default: ({ type }: { type: string }) =>
{type}
, +})) + +vi.mock('../hooks/use-text-generation-app-state', () => ({ + useTextGenerationAppState: () => mockAppStateRef.value, +})) + +vi.mock('../hooks/use-text-generation-batch', () => ({ + useTextGenerationBatch: () => mockBatchStateRef.value, +})) + +vi.mock('../text-generation-sidebar', () => ({ + default: (props: { + currentTab: string + onRunOnceSend: () => void + onBatchSend: (data: string[][]) => void + }) => { + sidebarPropsSpy(props) + return ( +
+ {props.currentTab} + + +
+ ) + }, +})) + +vi.mock('../text-generation-result-panel', () => ({ + default: (props: { + allTaskList: unknown[] + controlSend: number + controlStopResponding: number + isShowResultPanel: boolean + onRunControlChange: (value: TextGenerationRunControl | null) => void + onRunStart: () => void + }) => { + resultPanelPropsSpy(props) + return ( +
+ {props.isShowResultPanel ? 'shown' : 'hidden'} + {String(props.controlSend)} + {String(props.controlStopResponding)} + {String(props.allTaskList.length)} + + +
+ ) + }, +})) + +const createAppState = (overrides: Record = {}) => ({ + accessMode: AccessMode.PUBLIC, + appId: 'app-1', + appSourceType: 'webApp', + customConfig: { + remove_webapp_brand: false, + replace_webapp_logo: '', + }, + handleRemoveSavedMessage: vi.fn(), + handleSaveMessage: vi.fn(), + moreLikeThisConfig: { enabled: true }, + promptConfig: { + prompt_template: '', + prompt_variables: [{ key: 'name', name: 'Name', type: 'string', required: true }], + }, + savedMessages: [], + siteInfo: { + title: 'Generator', + description: 'Description', + }, + systemFeatures: {}, + textToSpeechConfig: { enabled: true }, + visionConfig: { enabled: false }, + ...overrides, +}) + +const createBatchState = (overrides: Record = {}) => ({ + allFailedTaskList: [], + allSuccessTaskList: [], + allTaskList: [], + allTasksRun: true, + controlRetry: 0, + exportRes: [], + handleCompleted: vi.fn(), + handleRetryAllFailedTask: vi.fn(), + handleRunBatch: (data: string[][], options: { onStart: () => void }) => { + mockHandleRunBatch(data, options) + options.onStart() + return true + }, + isCallBatchAPI: false, + noPendingTask: true, + resetBatchExecution: () => mockResetBatchExecution(), + setIsCallBatchAPI: (value: boolean) => mockSetIsCallBatchAPI(value), + showTaskList: [], + ...overrides, +}) + +describe('TextGeneration', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + mockMode.value = 'create' + mockMedia.value = 'pc' + mockAppStateRef.value = createAppState() + mockBatchStateRef.value = createBatchState() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should render the loading state until app state is ready', () => { + mockAppStateRef.value = createAppState({ appId: '', siteInfo: null, promptConfig: null }) + + render() + + expect(screen.getByTestId('loading-app')).toHaveTextContent('app') + }) + + it('should fall back to create mode for unsupported query params and keep installed-app layout classes', () => { + mockMode.value = 'unsupported' + + const { container } = render() + + expect(screen.getByTestId('sidebar-current-tab')).toHaveTextContent('create') + expect(sidebarPropsSpy).toHaveBeenCalledWith(expect.objectContaining({ + currentTab: 'create', + isInstalledApp: true, + isPC: true, + })) + + const root = container.firstElementChild as HTMLElement + expect(root).toHaveClass('flex', 'h-full', 'rounded-2xl', 'shadow-md') + }) + + it('should orchestrate a run-once request and reveal the result panel', async () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'run-once' })) + + act(() => { + vi.runAllTimers() + }) + + expect(mockSetIsCallBatchAPI).toHaveBeenCalledWith(false) + expect(mockResetBatchExecution).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('show-result')).toHaveTextContent('shown') + expect(Number(screen.getByTestId('control-send').textContent)).toBeGreaterThan(0) + }) + + it('should orchestrate batch runs through the batch hook and expose the result panel', async () => { + mockMode.value = 'batch' + + render() + + fireEvent.click(screen.getByRole('button', { name: 'run-batch' })) + + act(() => { + vi.runAllTimers() + }) + + expect(mockHandleRunBatch).toHaveBeenCalledWith( + [['name'], ['Alice']], + expect.objectContaining({ onStart: expect.any(Function) }), + ) + expect(screen.getByTestId('show-result')).toHaveTextContent('shown') + expect(Number(screen.getByTestId('control-stop').textContent)).toBeGreaterThan(0) + }) +}) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-is-chat-mode.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-is-chat-mode.spec.ts new file mode 100644 index 0000000000..76c3e6b2ce --- /dev/null +++ b/web/app/components/workflow-app/hooks/__tests__/use-is-chat-mode.spec.ts @@ -0,0 +1,41 @@ +import { renderHook } from '@testing-library/react' +import { AppModeEnum } from '@/types/app' +import { useIsChatMode } from '../use-is-chat-mode' + +const { mockStoreState } = vi.hoisted(() => ({ + mockStoreState: { + appDetail: undefined as { mode?: AppModeEnum } | undefined, + }, +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: typeof mockStoreState) => unknown) => selector(mockStoreState), +})) + +describe('useIsChatMode', () => { + beforeEach(() => { + vi.clearAllMocks() + mockStoreState.appDetail = undefined + }) + + it('should return true when the app mode is ADVANCED_CHAT', () => { + mockStoreState.appDetail = { mode: AppModeEnum.ADVANCED_CHAT } + + const { result } = renderHook(() => useIsChatMode()) + + expect(result.current).toBe(true) + }) + + it('should return false when the app mode is not chat or app detail is missing', () => { + mockStoreState.appDetail = { mode: AppModeEnum.WORKFLOW } + + const { result, rerender } = renderHook(() => useIsChatMode()) + + expect(result.current).toBe(false) + + mockStoreState.appDetail = undefined + rerender() + + expect(result.current).toBe(false) + }) +}) diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 722eb8a7e4..846e9f11ec 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -1880,12 +1880,6 @@ } }, "app/components/base/chat/chat/index.tsx": { - "react/set-state-in-effect": { - "count": 1 - }, - "tailwindcss/enforce-consistent-class-order": { - "count": 2 - }, "ts/no-explicit-any": { "count": 3 } diff --git a/web/service/fetch.ts b/web/service/fetch.ts index 74ee7a9614..82870a8d2e 100644 --- a/web/service/fetch.ts +++ b/web/service/fetch.ts @@ -61,13 +61,15 @@ const createResponseFromHTTPError = (error: HTTPError): Response => { const afterResponseErrorCode = (otherOptions: IOtherOptions): AfterResponseHook => { return async ({ response }) => { if (!/^([23])\d{2}$/.test(String(response.status))) { - const errorData = await response.clone() - .json() - .then(data => data as ResponseError) - .catch(() => null) + let errorData: ResponseError | null = null + try { + const data: unknown = await response.clone().json() + errorData = data as ResponseError + } + catch {} const shouldNotifyError = response.status !== 401 && errorData && !otherOptions.silent - if (shouldNotifyError) + if (shouldNotifyError && errorData) toast.error(errorData.message) if (response.status === 403 && errorData?.code === 'already_setup')