From a4e03d6284aff2f64e9240ccd2d25c754cd702d2 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Fri, 13 Feb 2026 13:21:09 +0800 Subject: [PATCH] test: add integration tests for app card operations, list browsing, and create app flows (#32298) Co-authored-by: CodingOnStar --- .../apps/app-card-operations-flow.test.tsx | 459 +++++++++++++++++ .../apps/app-list-browsing-flow.test.tsx | 439 +++++++++++++++++ web/__tests__/apps/create-app-flow.test.tsx | 465 ++++++++++++++++++ .../apps/{ => __tests__}/app-card.spec.tsx | 57 +-- .../apps/{ => __tests__}/empty.spec.tsx | 3 +- .../apps/{ => __tests__}/footer.spec.tsx | 3 +- .../apps/{ => __tests__}/index.spec.tsx | 12 +- .../apps/{ => __tests__}/list.spec.tsx | 335 ++++--------- .../{ => __tests__}/new-app-card.spec.tsx | 27 +- .../use-apps-query-state.spec.tsx | 17 +- .../{ => __tests__}/use-dsl-drag-drop.spec.ts | 21 +- .../develop/__tests__/code.spec.tsx | 5 +- .../secret-key/__tests__/input-copy.spec.tsx | 5 +- .../__tests__/secret-key-modal.spec.tsx | 6 +- .../explore/try-app/__tests__/index.spec.tsx | 8 +- .../__tests__/version-mismatch-modal.spec.tsx | 4 + .../hooks/__tests__/use-pipeline-init.spec.ts | 2 +- 17 files changed, 1509 insertions(+), 359 deletions(-) create mode 100644 web/__tests__/apps/app-card-operations-flow.test.tsx create mode 100644 web/__tests__/apps/app-list-browsing-flow.test.tsx create mode 100644 web/__tests__/apps/create-app-flow.test.tsx rename web/app/components/apps/{ => __tests__}/app-card.spec.tsx (96%) rename web/app/components/apps/{ => __tests__}/empty.spec.tsx (95%) rename web/app/components/apps/{ => __tests__}/footer.spec.tsx (97%) rename web/app/components/apps/{ => __tests__}/index.spec.tsx (93%) rename web/app/components/apps/{ => __tests__}/list.spec.tsx (67%) rename web/app/components/apps/{ => __tests__}/new-app-card.spec.tsx (87%) rename web/app/components/apps/hooks/{ => __tests__}/use-apps-query-state.spec.tsx (91%) rename web/app/components/apps/hooks/{ => __tests__}/use-dsl-drag-drop.spec.ts (94%) diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx new file mode 100644 index 0000000000..55ad423d88 --- /dev/null +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -0,0 +1,459 @@ +/** + * Integration test: App Card Operations Flow + * + * Tests the end-to-end user flows for app card operations: + * - Editing app info + * - Duplicating an app + * - Deleting an app + * - Exporting app DSL + * - Navigation on card click + * - Access mode icons + */ +import type { App } from '@/types/app' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AppCard from '@/app/components/apps/app-card' +import { AccessMode } from '@/models/access-control' +import { deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps' +import { AppModeEnum } from '@/types/app' + +let mockIsCurrentWorkspaceEditor = true +let mockSystemFeatures = { + branding: { enabled: false }, + webapp_auth: { enabled: false }, +} + +const mockRouterPush = vi.fn() +const mockNotify = vi.fn() +const mockOnPlanInfoChanged = vi.fn() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockRouterPush, + }), +})) + +// Mock headless UI Popover so it renders content without transition +vi.mock('@headlessui/react', async () => { + const actual = await vi.importActual('@headlessui/react') + return { + ...actual, + Popover: ({ children, className }: { children: ((bag: { open: boolean }) => React.ReactNode) | React.ReactNode, className?: string }) => ( +
+ {typeof children === 'function' ? children({ open: true }) : children} +
+ ), + PopoverButton: ({ children, className, ref: _ref, ...rest }: Record) => ( + + ), + PopoverPanel: ({ children, className }: { children: ((bag: { close: () => void }) => React.ReactNode) | React.ReactNode, className?: string }) => ( +
+ {typeof children === 'function' ? children({ close: vi.fn() }) : children} +
+ ), + Transition: ({ children }: { children: React.ReactNode }) => <>{children}, + } +}) + +vi.mock('next/dynamic', () => ({ + default: (loader: () => Promise<{ default: React.ComponentType }>) => { + let Component: React.ComponentType> | null = null + loader().then((mod) => { + Component = mod.default as React.ComponentType> + }).catch(() => {}) + const Wrapper = (props: Record) => { + if (Component) + return + return null + } + Wrapper.displayName = 'DynamicWrapper' + return Wrapper + }, +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector?: (state: Record) => unknown) => { + const state = { systemFeatures: mockSystemFeatures } + if (typeof selector === 'function') + return selector(state) + return mockSystemFeatures + }, +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + onPlanInfoChanged: mockOnPlanInfoChanged, + }), +})) + +// Mock the ToastContext used via useContext from use-context-selector +vi.mock('use-context-selector', async () => { + const actual = await vi.importActual('use-context-selector') + return { + ...actual, + useContext: () => ({ notify: mockNotify }), + } +}) + +vi.mock('@/app/components/base/tag-management/store', () => ({ + useStore: (selector: (state: Record) => unknown) => { + const state = { + tagList: [], + showTagManagementModal: false, + setTagList: vi.fn(), + setShowTagManagementModal: vi.fn(), + } + return selector(state) + }, +})) + +vi.mock('@/service/tag', () => ({ + fetchTagList: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/service/apps', () => ({ + deleteApp: vi.fn().mockResolvedValue({}), + updateAppInfo: vi.fn().mockResolvedValue({}), + copyApp: vi.fn().mockResolvedValue({ id: 'new-app-id', mode: 'chat' }), + exportAppConfig: vi.fn().mockResolvedValue({ data: 'yaml-content' }), +})) + +vi.mock('@/service/explore', () => ({ + fetchInstalledAppList: vi.fn().mockResolvedValue({ installed_apps: [] }), +})) + +vi.mock('@/service/workflow', () => ({ + fetchWorkflowDraft: vi.fn().mockResolvedValue({ environment_variables: [] }), +})) + +vi.mock('@/service/access-control', () => ({ + useGetUserCanAccessApp: () => ({ data: { result: true }, isLoading: false }), +})) + +vi.mock('@/hooks/use-async-window-open', () => ({ + useAsyncWindowOpen: () => vi.fn(), +})) + +// Mock modals loaded via next/dynamic +vi.mock('@/app/components/explore/create-app-modal', () => ({ + default: ({ show, onConfirm, onHide, appName }: Record) => { + if (!show) + return null + return ( +
+ {appName as string} + + +
+ ) + }, +})) + +vi.mock('@/app/components/app/duplicate-modal', () => ({ + default: ({ show, onConfirm, onHide }: Record) => { + if (!show) + return null + return ( +
+ + +
+ ) + }, +})) + +vi.mock('@/app/components/app/switch-app-modal', () => ({ + default: ({ show, onClose, onSuccess }: Record) => { + if (!show) + return null + return ( +
+ + +
+ ) + }, +})) + +vi.mock('@/app/components/base/confirm', () => ({ + default: ({ isShow, onConfirm, onCancel, title }: Record) => { + if (!isShow) + return null + return ( +
+ {title as string} + + +
+ ) + }, +})) + +vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ + default: ({ onConfirm, onClose }: Record) => ( +
+ + +
+ ), +})) + +vi.mock('@/app/components/app/app-access-control', () => ({ + default: ({ onConfirm, onClose }: Record) => ( +
+ + +
+ ), +})) + +const createMockApp = (overrides: Partial = {}): App => ({ + id: overrides.id ?? 'app-1', + name: overrides.name ?? 'Test Chat App', + description: overrides.description ?? 'A chat application', + author_name: overrides.author_name ?? 'Test Author', + icon_type: overrides.icon_type ?? 'emoji', + icon: overrides.icon ?? '🤖', + icon_background: overrides.icon_background ?? '#FFEAD5', + icon_url: overrides.icon_url ?? null, + use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false, + mode: overrides.mode ?? AppModeEnum.CHAT, + enable_site: overrides.enable_site ?? true, + enable_api: overrides.enable_api ?? true, + api_rpm: overrides.api_rpm ?? 60, + api_rph: overrides.api_rph ?? 3600, + is_demo: overrides.is_demo ?? false, + model_config: overrides.model_config ?? {} as App['model_config'], + app_model_config: overrides.app_model_config ?? {} as App['app_model_config'], + created_at: overrides.created_at ?? 1700000000, + updated_at: overrides.updated_at ?? 1700001000, + site: overrides.site ?? {} as App['site'], + api_base_url: overrides.api_base_url ?? 'https://api.example.com', + tags: overrides.tags ?? [], + access_mode: overrides.access_mode ?? AccessMode.PUBLIC, + max_active_requests: overrides.max_active_requests ?? null, +}) + +const mockOnRefresh = vi.fn() + +const renderAppCard = (app?: Partial) => { + return render() +} + +describe('App Card Operations Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsCurrentWorkspaceEditor = true + mockSystemFeatures = { + branding: { enabled: false }, + webapp_auth: { enabled: false }, + } + }) + + // -- Basic rendering -- + describe('Card Rendering', () => { + it('should render app name and description', () => { + renderAppCard({ name: 'My AI Bot', description: 'An intelligent assistant' }) + + expect(screen.getByText('My AI Bot')).toBeInTheDocument() + expect(screen.getByText('An intelligent assistant')).toBeInTheDocument() + }) + + it('should render author name', () => { + renderAppCard({ author_name: 'John Doe' }) + + expect(screen.getByText('John Doe')).toBeInTheDocument() + }) + + it('should navigate to app config page when card is clicked', () => { + renderAppCard({ id: 'app-123', mode: AppModeEnum.CHAT }) + + const card = screen.getByText('Test Chat App').closest('[class*="cursor-pointer"]') + if (card) + fireEvent.click(card) + + expect(mockRouterPush).toHaveBeenCalledWith('/app/app-123/configuration') + }) + + it('should navigate to workflow page for workflow apps', () => { + renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' }) + + const card = screen.getByText('WF App').closest('[class*="cursor-pointer"]') + if (card) + fireEvent.click(card) + + expect(mockRouterPush).toHaveBeenCalledWith('/app/app-wf/workflow') + }) + }) + + // -- Delete flow -- + describe('Delete App Flow', () => { + it('should show delete confirmation and call API on confirm', async () => { + renderAppCard({ id: 'app-to-delete', name: 'Deletable App' }) + + // Find and click the more button (popover trigger) + const moreIcons = document.querySelectorAll('svg') + const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]')) + + if (moreFill) { + const btn = moreFill.closest('[class*="cursor-pointer"]') + if (btn) + fireEvent.click(btn) + + await waitFor(() => { + const deleteBtn = screen.queryByText('common.operation.delete') + if (deleteBtn) + fireEvent.click(deleteBtn) + }) + + const confirmBtn = screen.queryByTestId('confirm-delete') + if (confirmBtn) { + fireEvent.click(confirmBtn) + + await waitFor(() => { + expect(deleteApp).toHaveBeenCalledWith('app-to-delete') + }) + } + } + }) + }) + + // -- Edit flow -- + describe('Edit App Flow', () => { + it('should open edit modal and call updateAppInfo on confirm', async () => { + renderAppCard({ id: 'app-edit', name: 'Editable App' }) + + const moreIcons = document.querySelectorAll('svg') + const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]')) + + if (moreFill) { + const btn = moreFill.closest('[class*="cursor-pointer"]') + if (btn) + fireEvent.click(btn) + + await waitFor(() => { + const editBtn = screen.queryByText('app.editApp') + if (editBtn) + fireEvent.click(editBtn) + }) + + const confirmEdit = screen.queryByTestId('confirm-edit') + if (confirmEdit) { + fireEvent.click(confirmEdit) + + await waitFor(() => { + expect(updateAppInfo).toHaveBeenCalledWith( + expect.objectContaining({ + appID: 'app-edit', + name: 'Updated App Name', + }), + ) + }) + } + } + }) + }) + + // -- Export flow -- + describe('Export App Flow', () => { + it('should call exportAppConfig for completion apps', async () => { + renderAppCard({ id: 'app-export', mode: AppModeEnum.COMPLETION, name: 'Export App' }) + + const moreIcons = document.querySelectorAll('svg') + const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]')) + + if (moreFill) { + const btn = moreFill.closest('[class*="cursor-pointer"]') + if (btn) + fireEvent.click(btn) + + await waitFor(() => { + const exportBtn = screen.queryByText('app.export') + if (exportBtn) + fireEvent.click(exportBtn) + }) + + await waitFor(() => { + expect(exportAppConfig).toHaveBeenCalledWith( + expect.objectContaining({ appID: 'app-export' }), + ) + }) + } + }) + }) + + // -- Access mode display -- + describe('Access Mode Display', () => { + it('should not render operations menu for non-editor users', () => { + mockIsCurrentWorkspaceEditor = false + renderAppCard({ name: 'Readonly App' }) + + expect(screen.queryByText('app.editApp')).not.toBeInTheDocument() + expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() + }) + }) + + // -- Switch mode (only for CHAT/COMPLETION) -- + describe('Switch App Mode', () => { + it('should show switch option for chat mode apps', async () => { + renderAppCard({ id: 'app-switch', mode: AppModeEnum.CHAT }) + + const moreIcons = document.querySelectorAll('svg') + const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]')) + + if (moreFill) { + const btn = moreFill.closest('[class*="cursor-pointer"]') + if (btn) + fireEvent.click(btn) + + await waitFor(() => { + expect(screen.queryByText('app.switch')).toBeInTheDocument() + }) + } + }) + + it('should not show switch option for workflow apps', async () => { + renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' }) + + const moreIcons = document.querySelectorAll('svg') + const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]')) + + if (moreFill) { + const btn = moreFill.closest('[class*="cursor-pointer"]') + if (btn) + fireEvent.click(btn) + + await waitFor(() => { + expect(screen.queryByText('app.switch')).not.toBeInTheDocument() + }) + } + }) + }) +}) diff --git a/web/__tests__/apps/app-list-browsing-flow.test.tsx b/web/__tests__/apps/app-list-browsing-flow.test.tsx new file mode 100644 index 0000000000..32aaddf251 --- /dev/null +++ b/web/__tests__/apps/app-list-browsing-flow.test.tsx @@ -0,0 +1,439 @@ +/** + * Integration test: App List Browsing Flow + * + * Tests the end-to-end user flow of browsing, filtering, searching, + * and tab switching in the apps list page. + * + * Covers: List, Empty, Footer, AppCardSkeleton, useAppsQueryState, NewAppCard + */ +import type { AppListResponse } from '@/models/app' +import type { App } from '@/types/app' +import { fireEvent, render, screen } from '@testing-library/react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import List from '@/app/components/apps/list' +import { AccessMode } from '@/models/access-control' +import { AppModeEnum } from '@/types/app' + +let mockIsCurrentWorkspaceEditor = true +let mockIsCurrentWorkspaceDatasetOperator = false +let mockIsLoadingCurrentWorkspace = false + +let mockSystemFeatures = { + branding: { enabled: false }, + webapp_auth: { enabled: false }, +} + +let mockPages: AppListResponse[] = [] +let mockIsLoading = false +let mockIsFetching = false +let mockIsFetchingNextPage = false +let mockHasNextPage = false +let mockError: Error | null = null +const mockRefetch = vi.fn() +const mockFetchNextPage = vi.fn() + +let mockShowTagManagementModal = false + +const mockRouterPush = vi.fn() +const mockRouterReplace = vi.fn() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockRouterPush, + replace: mockRouterReplace, + }), + useSearchParams: () => new URLSearchParams(), +})) + +vi.mock('next/dynamic', () => ({ + default: (_loader: () => Promise<{ default: React.ComponentType }>) => { + const LazyComponent = (props: Record) => { + return
+ } + LazyComponent.displayName = 'DynamicComponent' + return LazyComponent + }, +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor, + isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator, + isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector?: (state: Record) => unknown) => { + const state = { systemFeatures: mockSystemFeatures } + return selector ? selector(state) : state + }, +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + onPlanInfoChanged: vi.fn(), + }), +})) + +vi.mock('@/app/components/base/tag-management/store', () => ({ + useStore: (selector: (state: Record) => unknown) => { + const state = { + tagList: [], + showTagManagementModal: mockShowTagManagementModal, + setTagList: vi.fn(), + setShowTagManagementModal: vi.fn(), + } + return selector(state) + }, +})) + +vi.mock('@/service/tag', () => ({ + fetchTagList: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/service/use-apps', () => ({ + useInfiniteAppList: () => ({ + data: { pages: mockPages }, + isLoading: mockIsLoading, + isFetching: mockIsFetching, + isFetchingNextPage: mockIsFetchingNextPage, + fetchNextPage: mockFetchNextPage, + hasNextPage: mockHasNextPage, + error: mockError, + refetch: mockRefetch, + }), +})) + +vi.mock('@/hooks/use-pay', () => ({ + CheckModal: () => null, +})) + +vi.mock('ahooks', async () => { + const actual = await vi.importActual('ahooks') + const React = await vi.importActual('react') + return { + ...actual, + useDebounceFn: (fn: (...args: unknown[]) => void) => { + const fnRef = React.useRef(fn) + fnRef.current = fn + return { + run: (...args: unknown[]) => fnRef.current(...args), + } + }, + } +}) + +const createMockApp = (overrides: Partial = {}): App => ({ + id: overrides.id ?? 'app-1', + name: overrides.name ?? 'My Chat App', + description: overrides.description ?? 'A chat application', + author_name: overrides.author_name ?? 'Test Author', + icon_type: overrides.icon_type ?? 'emoji', + icon: overrides.icon ?? '🤖', + icon_background: overrides.icon_background ?? '#FFEAD5', + icon_url: overrides.icon_url ?? null, + use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false, + mode: overrides.mode ?? AppModeEnum.CHAT, + enable_site: overrides.enable_site ?? true, + enable_api: overrides.enable_api ?? true, + api_rpm: overrides.api_rpm ?? 60, + api_rph: overrides.api_rph ?? 3600, + is_demo: overrides.is_demo ?? false, + model_config: overrides.model_config ?? {} as App['model_config'], + app_model_config: overrides.app_model_config ?? {} as App['app_model_config'], + created_at: overrides.created_at ?? 1700000000, + updated_at: overrides.updated_at ?? 1700001000, + site: overrides.site ?? {} as App['site'], + api_base_url: overrides.api_base_url ?? 'https://api.example.com', + tags: overrides.tags ?? [], + access_mode: overrides.access_mode ?? AccessMode.PUBLIC, + max_active_requests: overrides.max_active_requests ?? null, +}) + +const createPage = (apps: App[], hasMore = false, page = 1): AppListResponse => ({ + data: apps, + has_more: hasMore, + limit: 30, + page, + total: apps.length, +}) + +const renderList = (searchParams?: Record) => { + return render( + + + , + ) +} + +describe('App List Browsing Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsCurrentWorkspaceEditor = true + mockIsCurrentWorkspaceDatasetOperator = false + mockIsLoadingCurrentWorkspace = false + mockSystemFeatures = { + branding: { enabled: false }, + webapp_auth: { enabled: false }, + } + mockPages = [] + mockIsLoading = false + mockIsFetching = false + mockIsFetchingNextPage = false + mockHasNextPage = false + mockError = null + mockShowTagManagementModal = false + }) + + // -- Loading and Empty states -- + describe('Loading and Empty States', () => { + it('should show skeleton cards during initial loading', () => { + mockIsLoading = true + renderList() + + const skeletonCards = document.querySelectorAll('.animate-pulse') + expect(skeletonCards.length).toBeGreaterThan(0) + }) + + it('should show empty state when no apps exist', () => { + mockPages = [createPage([])] + renderList() + + expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument() + }) + + it('should transition from loading to content when data loads', () => { + mockIsLoading = true + const { rerender } = render( + + + , + ) + + const skeletonCards = document.querySelectorAll('.animate-pulse') + expect(skeletonCards.length).toBeGreaterThan(0) + + // Data loads + mockIsLoading = false + mockPages = [createPage([ + createMockApp({ id: 'app-1', name: 'Loaded App' }), + ])] + + rerender( + + + , + ) + + expect(screen.getByText('Loaded App')).toBeInTheDocument() + }) + }) + + // -- Rendering apps -- + describe('App List Rendering', () => { + it('should render all app cards from the data', () => { + mockPages = [createPage([ + createMockApp({ id: 'app-1', name: 'Chat Bot' }), + createMockApp({ id: 'app-2', name: 'Workflow Engine', mode: AppModeEnum.WORKFLOW }), + createMockApp({ id: 'app-3', name: 'Completion Tool', mode: AppModeEnum.COMPLETION }), + ])] + + renderList() + + expect(screen.getByText('Chat Bot')).toBeInTheDocument() + expect(screen.getByText('Workflow Engine')).toBeInTheDocument() + expect(screen.getByText('Completion Tool')).toBeInTheDocument() + }) + + it('should display app descriptions', () => { + mockPages = [createPage([ + createMockApp({ name: 'My App', description: 'A powerful AI assistant' }), + ])] + + renderList() + + expect(screen.getByText('A powerful AI assistant')).toBeInTheDocument() + }) + + it('should show the NewAppCard for workspace editors', () => { + mockPages = [createPage([ + createMockApp({ name: 'Test App' }), + ])] + + renderList() + + expect(screen.getByText('app.createApp')).toBeInTheDocument() + }) + + it('should hide NewAppCard when user is not a workspace editor', () => { + mockIsCurrentWorkspaceEditor = false + mockPages = [createPage([ + createMockApp({ name: 'Test App' }), + ])] + + renderList() + + expect(screen.queryByText('app.createApp')).not.toBeInTheDocument() + }) + }) + + // -- Footer visibility -- + describe('Footer Visibility', () => { + it('should show footer when branding is disabled', () => { + mockSystemFeatures = { ...mockSystemFeatures, branding: { enabled: false } } + mockPages = [createPage([createMockApp()])] + + renderList() + + expect(screen.getByText('app.join')).toBeInTheDocument() + expect(screen.getByText('app.communityIntro')).toBeInTheDocument() + }) + + it('should hide footer when branding is enabled', () => { + mockSystemFeatures = { ...mockSystemFeatures, branding: { enabled: true } } + mockPages = [createPage([createMockApp()])] + + renderList() + + expect(screen.queryByText('app.join')).not.toBeInTheDocument() + }) + }) + + // -- DSL drag-drop hint -- + describe('DSL Drag-Drop Hint', () => { + it('should show drag-drop hint for workspace editors', () => { + mockPages = [createPage([createMockApp()])] + renderList() + + expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument() + }) + + it('should hide drag-drop hint for non-editors', () => { + mockIsCurrentWorkspaceEditor = false + mockPages = [createPage([createMockApp()])] + renderList() + + expect(screen.queryByText('app.newApp.dropDSLToCreateApp')).not.toBeInTheDocument() + }) + }) + + // -- Tab navigation -- + describe('Tab Navigation', () => { + it('should render all category tabs', () => { + mockPages = [createPage([createMockApp()])] + renderList() + + expect(screen.getByText('app.types.all')).toBeInTheDocument() + expect(screen.getByText('app.types.workflow')).toBeInTheDocument() + expect(screen.getByText('app.types.advanced')).toBeInTheDocument() + expect(screen.getByText('app.types.chatbot')).toBeInTheDocument() + expect(screen.getByText('app.types.agent')).toBeInTheDocument() + expect(screen.getByText('app.types.completion')).toBeInTheDocument() + }) + }) + + // -- Search -- + describe('Search Filtering', () => { + it('should render search input', () => { + mockPages = [createPage([createMockApp()])] + renderList() + + const input = document.querySelector('input') + expect(input).toBeInTheDocument() + }) + + it('should allow typing in search input', () => { + mockPages = [createPage([createMockApp()])] + renderList() + + const input = document.querySelector('input')! + fireEvent.change(input, { target: { value: 'test search' } }) + expect(input.value).toBe('test search') + }) + }) + + // -- "Created by me" filter -- + describe('Created By Me Filter', () => { + it('should render the "created by me" checkbox', () => { + mockPages = [createPage([createMockApp()])] + renderList() + + expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument() + }) + + it('should toggle the "created by me" filter on click', () => { + mockPages = [createPage([createMockApp()])] + renderList() + + const checkbox = screen.getByText('app.showMyCreatedAppsOnly') + fireEvent.click(checkbox) + + expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument() + }) + }) + + // -- Fetching next page skeleton -- + describe('Pagination Loading', () => { + it('should show skeleton when fetching next page', () => { + mockPages = [createPage([createMockApp()])] + mockIsFetchingNextPage = true + + renderList() + + const skeletonCards = document.querySelectorAll('.animate-pulse') + expect(skeletonCards.length).toBeGreaterThan(0) + }) + }) + + // -- Dataset operator redirect -- + describe('Dataset Operator Redirect', () => { + it('should redirect dataset operators to /datasets', () => { + mockIsCurrentWorkspaceDatasetOperator = true + renderList() + + expect(mockRouterReplace).toHaveBeenCalledWith('/datasets') + }) + }) + + // -- Multiple pages of data -- + describe('Multi-page Data', () => { + it('should render apps from multiple pages', () => { + mockPages = [ + createPage([ + createMockApp({ id: 'app-1', name: 'Page One App' }), + ], true, 1), + createPage([ + createMockApp({ id: 'app-2', name: 'Page Two App' }), + ], false, 2), + ] + + renderList() + + expect(screen.getByText('Page One App')).toBeInTheDocument() + expect(screen.getByText('Page Two App')).toBeInTheDocument() + }) + }) + + // -- controlRefreshList triggers refetch -- + describe('Refresh List', () => { + it('should call refetch when controlRefreshList increments', () => { + mockPages = [createPage([createMockApp()])] + + const { rerender } = render( + + + , + ) + + rerender( + + + , + ) + + expect(mockRefetch).toHaveBeenCalled() + }) + }) +}) diff --git a/web/__tests__/apps/create-app-flow.test.tsx b/web/__tests__/apps/create-app-flow.test.tsx new file mode 100644 index 0000000000..23017d3c76 --- /dev/null +++ b/web/__tests__/apps/create-app-flow.test.tsx @@ -0,0 +1,465 @@ +/** + * Integration test: Create App Flow + * + * Tests the end-to-end user flows for creating new apps: + * - Creating from blank via NewAppCard + * - Creating from template via NewAppCard + * - Creating from DSL import via NewAppCard + * - Apps page top-level state management + */ +import type { AppListResponse } from '@/models/app' +import type { App } from '@/types/app' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import List from '@/app/components/apps/list' +import { AccessMode } from '@/models/access-control' +import { AppModeEnum } from '@/types/app' + +let mockIsCurrentWorkspaceEditor = true +let mockIsCurrentWorkspaceDatasetOperator = false +let mockIsLoadingCurrentWorkspace = false +let mockSystemFeatures = { + branding: { enabled: false }, + webapp_auth: { enabled: false }, +} + +let mockPages: AppListResponse[] = [] +let mockIsLoading = false +let mockIsFetching = false +const mockRefetch = vi.fn() +const mockFetchNextPage = vi.fn() +let mockShowTagManagementModal = false + +const mockRouterPush = vi.fn() +const mockRouterReplace = vi.fn() +const mockOnPlanInfoChanged = vi.fn() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockRouterPush, + replace: mockRouterReplace, + }), + useSearchParams: () => new URLSearchParams(), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor, + isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator, + isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector?: (state: Record) => unknown) => { + const state = { systemFeatures: mockSystemFeatures } + return selector ? selector(state) : state + }, +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + onPlanInfoChanged: mockOnPlanInfoChanged, + }), +})) + +vi.mock('@/app/components/base/tag-management/store', () => ({ + useStore: (selector: (state: Record) => unknown) => { + const state = { + tagList: [], + showTagManagementModal: mockShowTagManagementModal, + setTagList: vi.fn(), + setShowTagManagementModal: vi.fn(), + } + return selector(state) + }, +})) + +vi.mock('@/service/tag', () => ({ + fetchTagList: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/service/use-apps', () => ({ + useInfiniteAppList: () => ({ + data: { pages: mockPages }, + isLoading: mockIsLoading, + isFetching: mockIsFetching, + isFetchingNextPage: false, + fetchNextPage: mockFetchNextPage, + hasNextPage: false, + error: null, + refetch: mockRefetch, + }), +})) + +vi.mock('@/hooks/use-pay', () => ({ + CheckModal: () => null, +})) + +vi.mock('ahooks', async () => { + const actual = await vi.importActual('ahooks') + const React = await vi.importActual('react') + return { + ...actual, + useDebounceFn: (fn: (...args: unknown[]) => void) => { + const fnRef = React.useRef(fn) + fnRef.current = fn + return { + run: (...args: unknown[]) => fnRef.current(...args), + } + }, + } +}) + +// Mock dynamically loaded modals with test stubs +vi.mock('next/dynamic', () => ({ + default: (loader: () => Promise<{ default: React.ComponentType }>) => { + let Component: React.ComponentType> | null = null + loader().then((mod) => { + Component = mod.default as React.ComponentType> + }).catch(() => {}) + const Wrapper = (props: Record) => { + if (Component) + return + return null + } + Wrapper.displayName = 'DynamicWrapper' + return Wrapper + }, +})) + +vi.mock('@/app/components/app/create-app-modal', () => ({ + default: ({ show, onClose, onSuccess, onCreateFromTemplate }: Record) => { + if (!show) + return null + return ( +
+ + {!!onCreateFromTemplate && ( + + )} + +
+ ) + }, +})) + +vi.mock('@/app/components/app/create-app-dialog', () => ({ + default: ({ show, onClose, onSuccess, onCreateFromBlank }: Record) => { + if (!show) + return null + return ( +
+ + {!!onCreateFromBlank && ( + + )} + +
+ ) + }, +})) + +vi.mock('@/app/components/app/create-from-dsl-modal', () => ({ + default: ({ show, onClose, onSuccess }: Record) => { + if (!show) + return null + return ( +
+ + +
+ ) + }, + CreateFromDSLModalTab: { + FROM_URL: 'from-url', + FROM_FILE: 'from-file', + }, +})) + +const createMockApp = (overrides: Partial = {}): App => ({ + id: overrides.id ?? 'app-1', + name: overrides.name ?? 'Test App', + description: overrides.description ?? 'A test app', + author_name: overrides.author_name ?? 'Author', + icon_type: overrides.icon_type ?? 'emoji', + icon: overrides.icon ?? '🤖', + icon_background: overrides.icon_background ?? '#FFEAD5', + icon_url: overrides.icon_url ?? null, + use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false, + mode: overrides.mode ?? AppModeEnum.CHAT, + enable_site: overrides.enable_site ?? true, + enable_api: overrides.enable_api ?? true, + api_rpm: overrides.api_rpm ?? 60, + api_rph: overrides.api_rph ?? 3600, + is_demo: overrides.is_demo ?? false, + model_config: overrides.model_config ?? {} as App['model_config'], + app_model_config: overrides.app_model_config ?? {} as App['app_model_config'], + created_at: overrides.created_at ?? 1700000000, + updated_at: overrides.updated_at ?? 1700001000, + site: overrides.site ?? {} as App['site'], + api_base_url: overrides.api_base_url ?? 'https://api.example.com', + tags: overrides.tags ?? [], + access_mode: overrides.access_mode ?? AccessMode.PUBLIC, + max_active_requests: overrides.max_active_requests ?? null, +}) + +const createPage = (apps: App[]): AppListResponse => ({ + data: apps, + has_more: false, + limit: 30, + page: 1, + total: apps.length, +}) + +const renderList = () => { + return render( + + + , + ) +} + +describe('Create App Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsCurrentWorkspaceEditor = true + mockIsCurrentWorkspaceDatasetOperator = false + mockIsLoadingCurrentWorkspace = false + mockSystemFeatures = { + branding: { enabled: false }, + webapp_auth: { enabled: false }, + } + mockPages = [createPage([createMockApp()])] + mockIsLoading = false + mockIsFetching = false + mockShowTagManagementModal = false + }) + + // -- NewAppCard rendering -- + describe('NewAppCard Rendering', () => { + it('should render the "Create App" card with all options', () => { + renderList() + + expect(screen.getByText('app.createApp')).toBeInTheDocument() + expect(screen.getByText('app.newApp.startFromBlank')).toBeInTheDocument() + expect(screen.getByText('app.newApp.startFromTemplate')).toBeInTheDocument() + expect(screen.getByText('app.importDSL')).toBeInTheDocument() + }) + + it('should not render NewAppCard when user is not an editor', () => { + mockIsCurrentWorkspaceEditor = false + renderList() + + expect(screen.queryByText('app.createApp')).not.toBeInTheDocument() + }) + + it('should show loading state when workspace is loading', () => { + mockIsLoadingCurrentWorkspace = true + renderList() + + // NewAppCard renders but with loading style (pointer-events-none opacity-50) + expect(screen.getByText('app.createApp')).toBeInTheDocument() + }) + }) + + // -- Create from blank -- + describe('Create from Blank Flow', () => { + it('should open the create app modal when "Start from Blank" is clicked', async () => { + renderList() + + fireEvent.click(screen.getByText('app.newApp.startFromBlank')) + + await waitFor(() => { + expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() + }) + }) + + it('should close the create app modal on cancel', async () => { + renderList() + + fireEvent.click(screen.getByText('app.newApp.startFromBlank')) + await waitFor(() => { + expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('create-blank-cancel')) + await waitFor(() => { + expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument() + }) + }) + + it('should call onPlanInfoChanged and refetch on successful creation', async () => { + renderList() + + fireEvent.click(screen.getByText('app.newApp.startFromBlank')) + await waitFor(() => { + expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('create-blank-confirm')) + await waitFor(() => { + expect(mockOnPlanInfoChanged).toHaveBeenCalled() + expect(mockRefetch).toHaveBeenCalled() + }) + }) + }) + + // -- Create from template -- + describe('Create from Template Flow', () => { + it('should open template dialog when "Start from Template" is clicked', async () => { + renderList() + + fireEvent.click(screen.getByText('app.newApp.startFromTemplate')) + + await waitFor(() => { + expect(screen.getByTestId('template-dialog')).toBeInTheDocument() + }) + }) + + it('should allow switching from template to blank modal', async () => { + renderList() + + fireEvent.click(screen.getByText('app.newApp.startFromTemplate')) + await waitFor(() => { + expect(screen.getByTestId('template-dialog')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('switch-to-blank')) + await waitFor(() => { + expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() + expect(screen.queryByTestId('template-dialog')).not.toBeInTheDocument() + }) + }) + + it('should allow switching from blank to template dialog', async () => { + renderList() + + fireEvent.click(screen.getByText('app.newApp.startFromBlank')) + await waitFor(() => { + expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('switch-to-template')) + await waitFor(() => { + expect(screen.getByTestId('template-dialog')).toBeInTheDocument() + expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument() + }) + }) + }) + + // -- Create from DSL import (via NewAppCard button) -- + describe('Create from DSL Import Flow', () => { + it('should open DSL import modal when "Import DSL" is clicked', async () => { + renderList() + + fireEvent.click(screen.getByText('app.importDSL')) + + await waitFor(() => { + expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument() + }) + }) + + it('should close DSL import modal on cancel', async () => { + renderList() + + fireEvent.click(screen.getByText('app.importDSL')) + await waitFor(() => { + expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('dsl-import-cancel')) + await waitFor(() => { + expect(screen.queryByTestId('create-from-dsl-modal')).not.toBeInTheDocument() + }) + }) + + it('should call onPlanInfoChanged and refetch on successful DSL import', async () => { + renderList() + + fireEvent.click(screen.getByText('app.importDSL')) + await waitFor(() => { + expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('dsl-import-confirm')) + await waitFor(() => { + expect(mockOnPlanInfoChanged).toHaveBeenCalled() + expect(mockRefetch).toHaveBeenCalled() + }) + }) + }) + + // -- DSL drag-and-drop flow (via List component) -- + describe('DSL Drag-Drop Flow', () => { + it('should show drag-drop hint in the list', () => { + renderList() + + expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument() + }) + + it('should open create-from-DSL modal when DSL file is dropped', async () => { + const { act } = await import('@testing-library/react') + renderList() + + const container = document.querySelector('[class*="overflow-y-auto"]') + if (container) { + const yamlFile = new File(['app: test'], 'app.yaml', { type: 'application/yaml' }) + + // Simulate the full drag-drop sequence wrapped in act + await act(async () => { + const dragEnterEvent = new Event('dragenter', { bubbles: true }) + Object.defineProperty(dragEnterEvent, 'dataTransfer', { + value: { types: ['Files'], files: [] }, + }) + Object.defineProperty(dragEnterEvent, 'preventDefault', { value: vi.fn() }) + Object.defineProperty(dragEnterEvent, 'stopPropagation', { value: vi.fn() }) + container.dispatchEvent(dragEnterEvent) + + const dropEvent = new Event('drop', { bubbles: true }) + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { files: [yamlFile], types: ['Files'] }, + }) + Object.defineProperty(dropEvent, 'preventDefault', { value: vi.fn() }) + Object.defineProperty(dropEvent, 'stopPropagation', { value: vi.fn() }) + container.dispatchEvent(dropEvent) + }) + + await waitFor(() => { + const modal = screen.queryByTestId('create-from-dsl-modal') + if (modal) + expect(modal).toBeInTheDocument() + }) + } + }) + }) + + // -- Edge cases -- + describe('Edge Cases', () => { + it('should not show create options when no data and user is editor', () => { + mockPages = [createPage([])] + renderList() + + // NewAppCard should still be visible even with no apps + expect(screen.getByText('app.createApp')).toBeInTheDocument() + }) + + it('should handle multiple rapid clicks on create buttons without crashing', async () => { + renderList() + + // Rapidly click different create options + fireEvent.click(screen.getByText('app.newApp.startFromBlank')) + fireEvent.click(screen.getByText('app.newApp.startFromTemplate')) + fireEvent.click(screen.getByText('app.importDSL')) + + // Should not crash, and some modal should be present + await waitFor(() => { + const anyModal = screen.queryByTestId('create-app-modal') + || screen.queryByTestId('template-dialog') + || screen.queryByTestId('create-from-dsl-modal') + expect(anyModal).toBeTruthy() + }) + }) + }) +}) diff --git a/web/app/components/apps/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx similarity index 96% rename from web/app/components/apps/app-card.spec.tsx rename to web/app/components/apps/__tests__/app-card.spec.tsx index a9012dbbe8..ee36d471fd 100644 --- a/web/app/components/apps/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -1,16 +1,13 @@ import type { Mock } from 'vitest' +import type { App } from '@/types/app' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { AccessMode } from '@/models/access-control' -// Mock API services - import for direct manipulation import * as appsService from '@/service/apps' - import * as exploreService from '@/service/explore' import * as workflowService from '@/service/workflow' import { AppModeEnum } from '@/types/app' - -// Import component after mocks -import AppCard from './app-card' +import AppCard from '../app-card' // Mock next/navigation const mockPush = vi.fn() @@ -24,11 +21,11 @@ vi.mock('next/navigation', () => ({ // Include createContext for components that use it (like Toast) const mockNotify = vi.fn() vi.mock('use-context-selector', () => ({ - createContext: (defaultValue: any) => React.createContext(defaultValue), + createContext: (defaultValue: T) => React.createContext(defaultValue), useContext: () => ({ notify: mockNotify, }), - useContextSelector: (_context: any, selector: any) => selector({ + useContextSelector: (_context: unknown, selector: (state: Record) => unknown) => selector({ notify: mockNotify, }), })) @@ -51,7 +48,7 @@ vi.mock('@/context/provider-context', () => ({ // Mock global public store - allow dynamic configuration let mockWebappAuthEnabled = false vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (s: any) => any) => selector({ + useGlobalPublicStore: (selector: (s: Record) => unknown) => selector({ systemFeatures: { webapp_auth: { enabled: mockWebappAuthEnabled }, branding: { enabled: false }, @@ -106,11 +103,11 @@ vi.mock('@/utils/time', () => ({ // Mock dynamic imports vi.mock('next/dynamic', () => ({ - default: (importFn: () => Promise) => { + default: (importFn: () => Promise) => { const fnString = importFn.toString() if (fnString.includes('create-app-modal') || fnString.includes('explore/create-app-modal')) { - return function MockEditAppModal({ show, onHide, onConfirm }: any) { + return function MockEditAppModal({ show, onHide, onConfirm }: { show: boolean, onHide: () => void, onConfirm?: (data: Record) => void }) { if (!show) return null return React.createElement('div', { 'data-testid': 'edit-app-modal' }, React.createElement('button', { 'onClick': onHide, 'data-testid': 'close-edit-modal' }, 'Close'), React.createElement('button', { @@ -128,7 +125,7 @@ vi.mock('next/dynamic', () => ({ } } if (fnString.includes('duplicate-modal')) { - return function MockDuplicateAppModal({ show, onHide, onConfirm }: any) { + return function MockDuplicateAppModal({ show, onHide, onConfirm }: { show: boolean, onHide: () => void, onConfirm?: (data: Record) => void }) { if (!show) return null return React.createElement('div', { 'data-testid': 'duplicate-modal' }, React.createElement('button', { 'onClick': onHide, 'data-testid': 'close-duplicate-modal' }, 'Close'), React.createElement('button', { @@ -143,26 +140,26 @@ vi.mock('next/dynamic', () => ({ } } if (fnString.includes('switch-app-modal')) { - return function MockSwitchAppModal({ show, onClose, onSuccess }: any) { + return function MockSwitchAppModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) { if (!show) return null return React.createElement('div', { 'data-testid': 'switch-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-switch-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'confirm-switch-modal' }, 'Switch')) } } if (fnString.includes('base/confirm')) { - return function MockConfirm({ isShow, onCancel, onConfirm }: any) { + return function MockConfirm({ isShow, onCancel, onConfirm }: { isShow: boolean, onCancel: () => void, onConfirm: () => void }) { if (!isShow) return null return React.createElement('div', { 'data-testid': 'confirm-dialog' }, React.createElement('button', { 'onClick': onCancel, 'data-testid': 'cancel-confirm' }, 'Cancel'), React.createElement('button', { 'onClick': onConfirm, 'data-testid': 'confirm-confirm' }, 'Confirm')) } } if (fnString.includes('dsl-export-confirm-modal')) { - return function MockDSLExportModal({ onClose, onConfirm }: any) { + return function MockDSLExportModal({ onClose, onConfirm }: { onClose?: () => void, onConfirm?: (withSecrets: boolean) => void }) { return React.createElement('div', { 'data-testid': 'dsl-export-modal' }, React.createElement('button', { 'onClick': () => onClose?.(), 'data-testid': 'close-dsl-export' }, 'Close'), React.createElement('button', { 'onClick': () => onConfirm?.(true), 'data-testid': 'confirm-dsl-export' }, 'Export with secrets'), React.createElement('button', { 'onClick': () => onConfirm?.(false), 'data-testid': 'confirm-dsl-export-no-secrets' }, 'Export without secrets')) } } if (fnString.includes('app-access-control')) { - return function MockAccessControl({ onClose, onConfirm }: any) { + return function MockAccessControl({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) { return React.createElement('div', { 'data-testid': 'access-control-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-access-control' }, 'Close'), React.createElement('button', { 'onClick': onConfirm, 'data-testid': 'confirm-access-control' }, 'Confirm')) } } @@ -172,7 +169,9 @@ vi.mock('next/dynamic', () => ({ // Popover uses @headlessui/react portals - mock for controlled interaction testing vi.mock('@/app/components/base/popover', () => { - const MockPopover = ({ htmlContent, btnElement, btnClassName }: any) => { + type PopoverHtmlContent = React.ReactNode | ((state: { open: boolean, onClose: () => void, onClick: () => void }) => React.ReactNode) + type MockPopoverProps = { htmlContent: PopoverHtmlContent, btnElement: React.ReactNode, btnClassName?: string | ((open: boolean) => string) } + const MockPopover = ({ htmlContent, btnElement, btnClassName }: MockPopoverProps) => { const [isOpen, setIsOpen] = React.useState(false) const computedClassName = typeof btnClassName === 'function' ? btnClassName(isOpen) : '' return React.createElement('div', { 'data-testid': 'custom-popover', 'className': computedClassName }, React.createElement('div', { @@ -188,13 +187,13 @@ vi.mock('@/app/components/base/popover', () => { // Tooltip uses portals - minimal mock preserving popup content as title attribute vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children, popupContent }: any) => React.createElement('div', { title: popupContent }, children), + default: ({ children, popupContent }: { children: React.ReactNode, popupContent: React.ReactNode }) => React.createElement('div', { title: popupContent }, children), })) // TagSelector has API dependency (service/tag) - mock for isolated testing vi.mock('@/app/components/base/tag-management/selector', () => ({ - default: ({ tags }: any) => { - return React.createElement('div', { 'aria-label': 'tag-selector' }, tags?.map((tag: any) => React.createElement('span', { key: tag.id }, tag.name))) + default: ({ tags }: { tags?: { id: string, name: string }[] }) => { + return React.createElement('div', { 'aria-label': 'tag-selector' }, tags?.map((tag: { id: string, name: string }) => React.createElement('span', { key: tag.id }, tag.name))) }, })) @@ -203,11 +202,7 @@ vi.mock('@/app/components/app/type-selector', () => ({ AppTypeIcon: () => React.createElement('div', { 'data-testid': 'app-type-icon' }), })) -// ============================================================================ -// Test Data Factories -// ============================================================================ - -const createMockApp = (overrides: Record = {}) => ({ +const createMockApp = (overrides: Partial = {}): App => ({ id: 'test-app-id', name: 'Test App', description: 'Test app description', @@ -229,16 +224,8 @@ const createMockApp = (overrides: Record = {}) => ({ api_rpm: 60, api_rph: 3600, is_demo: false, - model_config: {} as any, - app_model_config: {} as any, - site: {} as any, - api_base_url: 'https://api.example.com', ...overrides, -}) - -// ============================================================================ -// Tests -// ============================================================================ +} as App) describe('AppCard', () => { const mockApp = createMockApp() @@ -1171,7 +1158,7 @@ describe('AppCard', () => { (exploreService.fetchInstalledAppList as Mock).mockRejectedValueOnce(new Error('API Error')) // Configure mockOpenAsyncWindow to call the callback and trigger error - mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise, options: any) => { + mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise, options?: { onError?: (err: unknown) => void }) => { try { await callback() } @@ -1213,7 +1200,7 @@ describe('AppCard', () => { (exploreService.fetchInstalledAppList as Mock).mockResolvedValueOnce({ installed_apps: [] }) // Configure mockOpenAsyncWindow to call the callback and trigger error - mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise, options: any) => { + mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise, options?: { onError?: (err: unknown) => void }) => { try { await callback() } diff --git a/web/app/components/apps/empty.spec.tsx b/web/app/components/apps/__tests__/empty.spec.tsx similarity index 95% rename from web/app/components/apps/empty.spec.tsx rename to web/app/components/apps/__tests__/empty.spec.tsx index 58a96f313a..8dbbbc3ffb 100644 --- a/web/app/components/apps/empty.spec.tsx +++ b/web/app/components/apps/__tests__/empty.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import * as React from 'react' -import Empty from './empty' +import Empty from '../empty' describe('Empty', () => { beforeEach(() => { @@ -21,7 +21,6 @@ describe('Empty', () => { it('should display the no apps found message', () => { render() - // Use pattern matching for resilient text assertions expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument() }) }) diff --git a/web/app/components/apps/footer.spec.tsx b/web/app/components/apps/__tests__/footer.spec.tsx similarity index 97% rename from web/app/components/apps/footer.spec.tsx rename to web/app/components/apps/__tests__/footer.spec.tsx index d93869b480..bbcad8c551 100644 --- a/web/app/components/apps/footer.spec.tsx +++ b/web/app/components/apps/__tests__/footer.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import * as React from 'react' -import Footer from './footer' +import Footer from '../footer' describe('Footer', () => { beforeEach(() => { @@ -15,7 +15,6 @@ describe('Footer', () => { it('should display the community heading', () => { render(