From 3fd1eea4d7c8d58a7a77f1cb2fab60df9b167a38 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Thu, 12 Feb 2026 10:29:03 +0800 Subject: [PATCH] feat(tests): add integration tests for explore app list, installed apps, and sidebar lifecycle flows (#32248) Co-authored-by: CodingOnStar --- .../explore/explore-app-list-flow.test.tsx | 273 ++++++++++++++++++ .../explore/installed-app-flow.test.tsx | 260 +++++++++++++++++ .../explore/sidebar-lifecycle-flow.test.tsx | 225 +++++++++++++++ .../explore/{ => __tests__}/category.spec.tsx | 17 +- .../explore/{ => __tests__}/index.spec.tsx | 13 +- .../explore/app-card/__tests__/index.spec.tsx | 140 +++++++++ .../explore/app-card/index.spec.tsx | 87 ------ .../app-list/{ => __tests__}/index.spec.tsx | 24 +- .../{ => __tests__}/banner-item.spec.tsx | 30 +- .../banner/{ => __tests__}/banner.spec.tsx | 18 +- .../{ => __tests__}/indicator-button.spec.tsx | 18 +- .../{ => __tests__}/index.spec.tsx | 210 ++++++-------- .../{ => __tests__}/index.spec.tsx | 11 +- .../{ => __tests__}/index.spec.tsx | 24 +- .../sidebar/{ => __tests__}/index.spec.tsx | 105 +++++-- .../{ => __tests__}/index.spec.tsx | 18 +- .../sidebar/no-apps/__tests__/index.spec.tsx | 63 ++++ .../try-app/{ => __tests__}/index.spec.tsx | 44 +-- .../try-app/{ => __tests__}/tab.spec.tsx | 26 +- .../app-info/{ => __tests__}/index.spec.tsx | 45 +-- .../use-get-requirements.spec.ts | 3 +- .../try-app/app/{ => __tests__}/chat.spec.tsx | 27 +- .../app/{ => __tests__}/index.spec.tsx | 12 +- .../{ => __tests__}/text-generation.spec.tsx | 24 +- .../basic-app-preview.spec.tsx | 11 +- .../{ => __tests__}/flow-app-preview.spec.tsx | 2 +- .../preview/{ => __tests__}/index.spec.tsx | 6 +- 27 files changed, 1186 insertions(+), 550 deletions(-) create mode 100644 web/__tests__/explore/explore-app-list-flow.test.tsx create mode 100644 web/__tests__/explore/installed-app-flow.test.tsx create mode 100644 web/__tests__/explore/sidebar-lifecycle-flow.test.tsx rename web/app/components/explore/{ => __tests__}/category.spec.tsx (84%) rename web/app/components/explore/{ => __tests__}/index.spec.tsx (91%) create mode 100644 web/app/components/explore/app-card/__tests__/index.spec.tsx delete mode 100644 web/app/components/explore/app-card/index.spec.tsx rename web/app/components/explore/app-list/{ => __tests__}/index.spec.tsx (94%) rename web/app/components/explore/banner/{ => __tests__}/banner-item.spec.tsx (91%) rename web/app/components/explore/banner/{ => __tests__}/banner.spec.tsx (94%) rename web/app/components/explore/banner/{ => __tests__}/indicator-button.spec.tsx (92%) rename web/app/components/explore/create-app-modal/{ => __tests__}/index.spec.tsx (74%) rename web/app/components/explore/installed-app/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/explore/item-operation/{ => __tests__}/index.spec.tsx (83%) rename web/app/components/explore/sidebar/{ => __tests__}/index.spec.tsx (62%) rename web/app/components/explore/sidebar/app-nav-item/{ => __tests__}/index.spec.tsx (83%) create mode 100644 web/app/components/explore/sidebar/no-apps/__tests__/index.spec.tsx rename web/app/components/explore/try-app/{ => __tests__}/index.spec.tsx (89%) rename web/app/components/explore/try-app/{ => __tests__}/tab.spec.tsx (65%) rename web/app/components/explore/try-app/app-info/{ => __tests__}/index.spec.tsx (86%) rename web/app/components/explore/try-app/app-info/{ => __tests__}/use-get-requirements.spec.ts (99%) rename web/app/components/explore/try-app/app/{ => __tests__}/chat.spec.tsx (89%) rename web/app/components/explore/try-app/app/{ => __tests__}/index.spec.tsx (96%) rename web/app/components/explore/try-app/app/{ => __tests__}/text-generation.spec.tsx (92%) rename web/app/components/explore/try-app/preview/{ => __tests__}/basic-app-preview.spec.tsx (98%) rename web/app/components/explore/try-app/preview/{ => __tests__}/flow-app-preview.spec.tsx (99%) rename web/app/components/explore/try-app/preview/{ => __tests__}/index.spec.tsx (97%) diff --git a/web/__tests__/explore/explore-app-list-flow.test.tsx b/web/__tests__/explore/explore-app-list-flow.test.tsx new file mode 100644 index 0000000000..1a54135420 --- /dev/null +++ b/web/__tests__/explore/explore-app-list-flow.test.tsx @@ -0,0 +1,273 @@ +/** + * Integration test: Explore App List Flow + * + * Tests the end-to-end user flow of browsing, filtering, searching, + * and adding apps to workspace from the explore page. + */ +import type { Mock } from 'vitest' +import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' +import type { App } from '@/models/explore' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import AppList from '@/app/components/explore/app-list' +import ExploreContext from '@/context/explore-context' +import { fetchAppDetail } from '@/service/explore' +import { AppModeEnum } from '@/types/app' + +const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}' +let mockTabValue = allCategoriesEn +const mockSetTab = vi.fn() +let mockExploreData: { categories: string[], allList: App[] } | undefined +let mockIsLoading = false +const mockHandleImportDSL = vi.fn() +const mockHandleImportDSLConfirm = vi.fn() + +vi.mock('nuqs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useQueryState: () => [mockTabValue, mockSetTab], + } +}) + +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: () => setTimeout(() => fnRef.current(), 0), + } + }, + } +}) + +vi.mock('@/service/use-explore', () => ({ + useExploreAppList: () => ({ + data: mockExploreData, + isLoading: mockIsLoading, + isError: false, + }), +})) + +vi.mock('@/service/explore', () => ({ + fetchAppDetail: vi.fn(), + fetchAppList: vi.fn(), +})) + +vi.mock('@/hooks/use-import-dsl', () => ({ + useImportDSL: () => ({ + handleImportDSL: mockHandleImportDSL, + handleImportDSLConfirm: mockHandleImportDSLConfirm, + versions: ['v1'], + isFetching: false, + }), +})) + +vi.mock('@/app/components/explore/create-app-modal', () => ({ + default: (props: CreateAppModalProps) => { + if (!props.show) + return null + return ( +
+ + +
+ ) + }, +})) + +vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({ + default: ({ onConfirm, onCancel }: { onConfirm: () => void, onCancel: () => void }) => ( +
+ + +
+ ), +})) + +const createApp = (overrides: Partial = {}): App => ({ + app: { + id: overrides.app?.id ?? 'app-id', + mode: overrides.app?.mode ?? AppModeEnum.CHAT, + icon_type: overrides.app?.icon_type ?? 'emoji', + icon: overrides.app?.icon ?? '๐Ÿ˜€', + icon_background: overrides.app?.icon_background ?? '#fff', + icon_url: overrides.app?.icon_url ?? '', + name: overrides.app?.name ?? 'Alpha', + description: overrides.app?.description ?? 'Alpha description', + use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false, + }, + can_trial: true, + app_id: overrides.app_id ?? 'app-1', + description: overrides.description ?? 'Alpha description', + copyright: overrides.copyright ?? '', + privacy_policy: overrides.privacy_policy ?? null, + custom_disclaimer: overrides.custom_disclaimer ?? null, + category: overrides.category ?? 'Writing', + position: overrides.position ?? 1, + is_listed: overrides.is_listed ?? true, + install_count: overrides.install_count ?? 0, + installed: overrides.installed ?? false, + editable: overrides.editable ?? false, + is_agent: overrides.is_agent ?? false, +}) + +const createContextValue = (hasEditPermission = true) => ({ + controlUpdateInstalledApps: 0, + setControlUpdateInstalledApps: vi.fn(), + hasEditPermission, + installedApps: [] as never[], + setInstalledApps: vi.fn(), + isFetchingInstalledApps: false, + setIsFetchingInstalledApps: vi.fn(), + isShowTryAppPanel: false, + setShowTryAppPanel: vi.fn(), +}) + +const wrapWithContext = (hasEditPermission = true, onSuccess?: () => void) => ( + + + +) + +const renderWithContext = (hasEditPermission = true, onSuccess?: () => void) => { + return render(wrapWithContext(hasEditPermission, onSuccess)) +} + +describe('Explore App List Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTabValue = allCategoriesEn + mockIsLoading = false + mockExploreData = { + categories: ['Writing', 'Translate', 'Programming'], + allList: [ + createApp({ app_id: 'app-1', app: { ...createApp().app, name: 'Writer Bot' }, category: 'Writing' }), + createApp({ app_id: 'app-2', app: { ...createApp().app, id: 'app-id-2', name: 'Translator' }, category: 'Translate' }), + createApp({ app_id: 'app-3', app: { ...createApp().app, id: 'app-id-3', name: 'Code Helper' }, category: 'Programming' }), + ], + } + }) + + describe('Browse and Filter Flow', () => { + it('should display all apps when no category filter is applied', () => { + renderWithContext() + + expect(screen.getByText('Writer Bot')).toBeInTheDocument() + expect(screen.getByText('Translator')).toBeInTheDocument() + expect(screen.getByText('Code Helper')).toBeInTheDocument() + }) + + it('should filter apps by selected category', () => { + mockTabValue = 'Writing' + renderWithContext() + + expect(screen.getByText('Writer Bot')).toBeInTheDocument() + expect(screen.queryByText('Translator')).not.toBeInTheDocument() + expect(screen.queryByText('Code Helper')).not.toBeInTheDocument() + }) + + it('should filter apps by search keyword', async () => { + renderWithContext() + + const input = screen.getByPlaceholderText('common.operation.search') + fireEvent.change(input, { target: { value: 'trans' } }) + + await waitFor(() => { + expect(screen.getByText('Translator')).toBeInTheDocument() + expect(screen.queryByText('Writer Bot')).not.toBeInTheDocument() + expect(screen.queryByText('Code Helper')).not.toBeInTheDocument() + }) + }) + }) + + describe('Add to Workspace Flow', () => { + it('should complete the full add-to-workspace flow with DSL confirmation', async () => { + // Step 1: User clicks "Add to Workspace" on an app card + const onSuccess = vi.fn() + ;(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content' }) + mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => { + options.onPending?.() + }) + mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => { + options.onSuccess?.() + }) + + renderWithContext(true, onSuccess) + + // Step 2: Click add to workspace button - opens create modal + fireEvent.click(screen.getAllByText('explore.appCard.addToWorkspace')[0]) + + // Step 3: Confirm creation in modal + fireEvent.click(await screen.findByTestId('confirm-create')) + + // Step 4: API fetches app detail + await waitFor(() => { + expect(fetchAppDetail).toHaveBeenCalledWith('app-id') + }) + + // Step 5: DSL import triggers pending confirmation + expect(mockHandleImportDSL).toHaveBeenCalledTimes(1) + + // Step 6: DSL confirm modal appears and user confirms + expect(await screen.findByTestId('dsl-confirm-modal')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('dsl-confirm')) + + // Step 7: Flow completes successfully + await waitFor(() => { + expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1) + expect(onSuccess).toHaveBeenCalledTimes(1) + }) + }) + }) + + describe('Loading and Empty States', () => { + it('should transition from loading to content', () => { + // Step 1: Loading state + mockIsLoading = true + mockExploreData = undefined + const { rerender } = render(wrapWithContext()) + + expect(screen.getByRole('status')).toBeInTheDocument() + + // Step 2: Data loads + mockIsLoading = false + mockExploreData = { + categories: ['Writing'], + allList: [createApp()], + } + rerender(wrapWithContext()) + + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(screen.getByText('Alpha')).toBeInTheDocument() + }) + }) + + describe('Permission-Based Behavior', () => { + it('should hide add-to-workspace button when user has no edit permission', () => { + renderWithContext(false) + + expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument() + }) + + it('should show add-to-workspace button when user has edit permission', () => { + renderWithContext(true) + + expect(screen.getAllByText('explore.appCard.addToWorkspace').length).toBeGreaterThan(0) + }) + }) +}) diff --git a/web/__tests__/explore/installed-app-flow.test.tsx b/web/__tests__/explore/installed-app-flow.test.tsx new file mode 100644 index 0000000000..69dcb116aa --- /dev/null +++ b/web/__tests__/explore/installed-app-flow.test.tsx @@ -0,0 +1,260 @@ +/** + * Integration test: Installed App Flow + * + * Tests the end-to-end user flow of installed apps: sidebar navigation, + * mode-based routing (Chat / Completion / Workflow), and lifecycle + * operations (pin/unpin, delete). + */ +import type { Mock } from 'vitest' +import type { InstalledApp as InstalledAppModel } from '@/models/explore' +import { render, screen, waitFor } from '@testing-library/react' +import { useContext } from 'use-context-selector' +import InstalledApp from '@/app/components/explore/installed-app' +import { useWebAppStore } from '@/context/web-app-context' +import { AccessMode } from '@/models/access-control' +import { useGetUserCanAccessApp } from '@/service/access-control' +import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore' +import { AppModeEnum } from '@/types/app' + +// Mock external dependencies +vi.mock('use-context-selector', () => ({ + useContext: vi.fn(), + createContext: vi.fn(() => ({})), +})) + +vi.mock('@/context/web-app-context', () => ({ + useWebAppStore: vi.fn(), +})) + +vi.mock('@/service/access-control', () => ({ + useGetUserCanAccessApp: vi.fn(), +})) + +vi.mock('@/service/use-explore', () => ({ + useGetInstalledAppAccessModeByAppId: vi.fn(), + useGetInstalledAppParams: vi.fn(), + useGetInstalledAppMeta: vi.fn(), +})) + +vi.mock('@/app/components/share/text-generation', () => ({ + default: ({ isWorkflow }: { isWorkflow?: boolean }) => ( +
+ Text Generation + {isWorkflow && ' (Workflow)'} +
+ ), +})) + +vi.mock('@/app/components/base/chat/chat-with-history', () => ({ + default: ({ installedAppInfo }: { installedAppInfo?: InstalledAppModel }) => ( +
+ Chat - + {' '} + {installedAppInfo?.app.name} +
+ ), +})) + +describe('Installed App Flow', () => { + const mockUpdateAppInfo = vi.fn() + const mockUpdateWebAppAccessMode = vi.fn() + const mockUpdateAppParams = vi.fn() + const mockUpdateWebAppMeta = vi.fn() + const mockUpdateUserCanAccessApp = vi.fn() + + const createInstalledApp = (mode: AppModeEnum = AppModeEnum.CHAT): InstalledAppModel => ({ + id: 'installed-app-1', + app: { + id: 'real-app-id', + name: 'Integration Test App', + mode, + icon_type: 'emoji', + icon: '๐Ÿงช', + icon_background: '#FFFFFF', + icon_url: '', + description: 'Test app for integration', + use_icon_as_answer_icon: false, + }, + uninstallable: true, + is_pinned: false, + }) + + const mockAppParams = { + user_input_form: [], + file_upload: { image: { enabled: false, number_limits: 0, transfer_methods: [] } }, + system_parameters: {}, + } + + type MockOverrides = { + context?: { installedApps?: InstalledAppModel[], isFetchingInstalledApps?: boolean } + accessMode?: { isFetching?: boolean, data?: unknown, error?: unknown } + params?: { isFetching?: boolean, data?: unknown, error?: unknown } + meta?: { isFetching?: boolean, data?: unknown, error?: unknown } + userAccess?: { data?: unknown, error?: unknown } + } + + const setupDefaultMocks = (app?: InstalledAppModel, overrides: MockOverrides = {}) => { + ;(useContext as Mock).mockReturnValue({ + installedApps: app ? [app] : [], + isFetchingInstalledApps: false, + ...overrides.context, + }) + + ;(useWebAppStore as unknown as Mock).mockImplementation((selector: (state: Record) => unknown) => { + return selector({ + updateAppInfo: mockUpdateAppInfo, + updateWebAppAccessMode: mockUpdateWebAppAccessMode, + updateAppParams: mockUpdateAppParams, + updateWebAppMeta: mockUpdateWebAppMeta, + updateUserCanAccessApp: mockUpdateUserCanAccessApp, + }) + }) + + ;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({ + isFetching: false, + data: { accessMode: AccessMode.PUBLIC }, + error: null, + ...overrides.accessMode, + }) + + ;(useGetInstalledAppParams as Mock).mockReturnValue({ + isFetching: false, + data: mockAppParams, + error: null, + ...overrides.params, + }) + + ;(useGetInstalledAppMeta as Mock).mockReturnValue({ + isFetching: false, + data: { tool_icons: {} }, + error: null, + ...overrides.meta, + }) + + ;(useGetUserCanAccessApp as Mock).mockReturnValue({ + data: { result: true }, + error: null, + ...overrides.userAccess, + }) + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Mode-Based Routing', () => { + it.each([ + [AppModeEnum.CHAT, 'chat-with-history'], + [AppModeEnum.ADVANCED_CHAT, 'chat-with-history'], + [AppModeEnum.AGENT_CHAT, 'chat-with-history'], + ])('should render ChatWithHistory for %s mode', (mode, testId) => { + const app = createInstalledApp(mode) + setupDefaultMocks(app) + + render() + + expect(screen.getByTestId(testId)).toBeInTheDocument() + expect(screen.getByText(/Integration Test App/)).toBeInTheDocument() + }) + + it('should render TextGenerationApp for COMPLETION mode', () => { + const app = createInstalledApp(AppModeEnum.COMPLETION) + setupDefaultMocks(app) + + render() + + expect(screen.getByTestId('text-generation-app')).toBeInTheDocument() + expect(screen.getByText('Text Generation')).toBeInTheDocument() + expect(screen.queryByText(/Workflow/)).not.toBeInTheDocument() + }) + + it('should render TextGenerationApp with workflow flag for WORKFLOW mode', () => { + const app = createInstalledApp(AppModeEnum.WORKFLOW) + setupDefaultMocks(app) + + render() + + expect(screen.getByTestId('text-generation-app')).toBeInTheDocument() + expect(screen.getByText(/Workflow/)).toBeInTheDocument() + }) + }) + + describe('Data Loading Flow', () => { + it('should show loading spinner when params are being fetched', () => { + const app = createInstalledApp() + setupDefaultMocks(app, { params: { isFetching: true, data: null } }) + + const { container } = render() + + expect(container.querySelector('svg.spin-animation')).toBeInTheDocument() + expect(screen.queryByTestId('chat-with-history')).not.toBeInTheDocument() + }) + + it('should render content when all data is available', () => { + const app = createInstalledApp() + setupDefaultMocks(app) + + render() + + expect(screen.getByTestId('chat-with-history')).toBeInTheDocument() + }) + }) + + describe('Error Handling Flow', () => { + it('should show error state when API fails', () => { + const app = createInstalledApp() + setupDefaultMocks(app, { params: { data: null, error: new Error('Network error') } }) + + render() + + expect(screen.getByText(/Network error/)).toBeInTheDocument() + }) + + it('should show 404 when app is not found', () => { + setupDefaultMocks(undefined, { + accessMode: { data: null }, + params: { data: null }, + meta: { data: null }, + userAccess: { data: null }, + }) + + render() + + expect(screen.getByText(/404/)).toBeInTheDocument() + }) + + it('should show 403 when user has no permission', () => { + const app = createInstalledApp() + setupDefaultMocks(app, { userAccess: { data: { result: false } } }) + + render() + + expect(screen.getByText(/403/)).toBeInTheDocument() + }) + }) + + describe('State Synchronization', () => { + it('should update all stores when app data is loaded', async () => { + const app = createInstalledApp() + setupDefaultMocks(app) + + render() + + await waitFor(() => { + expect(mockUpdateAppInfo).toHaveBeenCalledWith( + expect.objectContaining({ + app_id: 'installed-app-1', + site: expect.objectContaining({ + title: 'Integration Test App', + icon: '๐Ÿงช', + }), + }), + ) + expect(mockUpdateAppParams).toHaveBeenCalledWith(mockAppParams) + expect(mockUpdateWebAppMeta).toHaveBeenCalledWith({ tool_icons: {} }) + expect(mockUpdateWebAppAccessMode).toHaveBeenCalledWith(AccessMode.PUBLIC) + expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(true) + }) + }) + }) +}) diff --git a/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx b/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx new file mode 100644 index 0000000000..bf4821ced4 --- /dev/null +++ b/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx @@ -0,0 +1,225 @@ +import type { IExplore } from '@/context/explore-context' +/** + * Integration test: Sidebar Lifecycle Flow + * + * Tests the sidebar interactions for installed apps lifecycle: + * navigation, pin/unpin ordering, delete confirmation, and + * fold/unfold behavior. + */ +import type { InstalledApp } from '@/models/explore' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import Toast from '@/app/components/base/toast' +import SideBar from '@/app/components/explore/sidebar' +import ExploreContext from '@/context/explore-context' +import { MediaType } from '@/hooks/use-breakpoints' +import { AppModeEnum } from '@/types/app' + +let mockMediaType: string = MediaType.pc +const mockSegments = ['apps'] +const mockPush = vi.fn() +const mockRefetch = vi.fn() +const mockUninstall = vi.fn() +const mockUpdatePinStatus = vi.fn() +let mockInstalledApps: InstalledApp[] = [] + +vi.mock('next/navigation', () => ({ + useSelectedLayoutSegments: () => mockSegments, + useRouter: () => ({ + push: mockPush, + }), +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + default: () => mockMediaType, + MediaType: { + mobile: 'mobile', + tablet: 'tablet', + pc: 'pc', + }, +})) + +vi.mock('@/service/use-explore', () => ({ + useGetInstalledApps: () => ({ + isFetching: false, + data: { installed_apps: mockInstalledApps }, + refetch: mockRefetch, + }), + useUninstallApp: () => ({ + mutateAsync: mockUninstall, + }), + useUpdateAppPinStatus: () => ({ + mutateAsync: mockUpdatePinStatus, + }), +})) + +const createInstalledApp = (overrides: Partial = {}): InstalledApp => ({ + id: overrides.id ?? 'app-1', + uninstallable: overrides.uninstallable ?? false, + is_pinned: overrides.is_pinned ?? false, + app: { + id: overrides.app?.id ?? 'app-basic-id', + mode: overrides.app?.mode ?? AppModeEnum.CHAT, + icon_type: overrides.app?.icon_type ?? 'emoji', + icon: overrides.app?.icon ?? '๐Ÿค–', + icon_background: overrides.app?.icon_background ?? '#fff', + icon_url: overrides.app?.icon_url ?? '', + name: overrides.app?.name ?? 'App One', + description: overrides.app?.description ?? 'desc', + use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false, + }, +}) + +const createContextValue = (installedApps: InstalledApp[] = []): IExplore => ({ + controlUpdateInstalledApps: 0, + setControlUpdateInstalledApps: vi.fn(), + hasEditPermission: true, + installedApps, + setInstalledApps: vi.fn(), + isFetchingInstalledApps: false, + setIsFetchingInstalledApps: vi.fn(), + isShowTryAppPanel: false, + setShowTryAppPanel: vi.fn(), +}) + +const renderSidebar = (installedApps: InstalledApp[] = []) => { + return render( + + + , + ) +} + +describe('Sidebar Lifecycle Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockMediaType = MediaType.pc + mockInstalledApps = [] + vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) + }) + + describe('Pin / Unpin / Delete Flow', () => { + it('should complete pin โ†’ unpin cycle for an app', async () => { + mockUpdatePinStatus.mockResolvedValue(undefined) + + // Step 1: Start with an unpinned app and pin it + const unpinnedApp = createInstalledApp({ is_pinned: false }) + mockInstalledApps = [unpinnedApp] + const { unmount } = renderSidebar(mockInstalledApps) + + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.pin')) + + await waitFor(() => { + expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: true }) + expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + })) + }) + + // Step 2: Simulate refetch returning pinned state, then unpin + unmount() + vi.clearAllMocks() + mockUpdatePinStatus.mockResolvedValue(undefined) + + const pinnedApp = createInstalledApp({ is_pinned: true }) + mockInstalledApps = [pinnedApp] + renderSidebar(mockInstalledApps) + + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.unpin')) + + await waitFor(() => { + expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: false }) + expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + })) + }) + }) + + it('should complete the delete flow with confirmation', async () => { + const app = createInstalledApp() + mockInstalledApps = [app] + mockUninstall.mockResolvedValue(undefined) + + renderSidebar(mockInstalledApps) + + // Step 1: Open operation menu and click delete + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) + + // Step 2: Confirm dialog appears + expect(await screen.findByText('explore.sidebar.delete.title')).toBeInTheDocument() + + // Step 3: Confirm deletion + fireEvent.click(screen.getByText('common.operation.confirm')) + + // Step 4: Uninstall API called and success toast shown + await waitFor(() => { + expect(mockUninstall).toHaveBeenCalledWith('app-1') + expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + message: 'common.api.remove', + })) + }) + }) + + it('should cancel deletion when user clicks cancel', async () => { + const app = createInstalledApp() + mockInstalledApps = [app] + + renderSidebar(mockInstalledApps) + + // Open delete flow + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) + + // Cancel the deletion + fireEvent.click(await screen.findByText('common.operation.cancel')) + + // Uninstall should not be called + expect(mockUninstall).not.toHaveBeenCalled() + }) + }) + + describe('Multi-App Ordering', () => { + it('should display pinned apps before unpinned apps with divider', () => { + mockInstalledApps = [ + createInstalledApp({ id: 'pinned-1', is_pinned: true, app: { ...createInstalledApp().app, name: 'Pinned App' } }), + createInstalledApp({ id: 'unpinned-1', is_pinned: false, app: { ...createInstalledApp().app, name: 'Regular App' } }), + ] + + const { container } = renderSidebar(mockInstalledApps) + + // Both apps are rendered + const pinnedApp = screen.getByText('Pinned App') + const regularApp = screen.getByText('Regular App') + expect(pinnedApp).toBeInTheDocument() + expect(regularApp).toBeInTheDocument() + + // Pinned app appears before unpinned app in the DOM + const pinnedItem = pinnedApp.closest('[class*="rounded-lg"]')! + const regularItem = regularApp.closest('[class*="rounded-lg"]')! + expect(pinnedItem.compareDocumentPosition(regularItem) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + + // Divider is rendered between pinned and unpinned sections + const divider = container.querySelector('[class*="bg-divider-regular"]') + expect(divider).toBeInTheDocument() + }) + }) + + describe('Empty State', () => { + it('should show NoApps component when no apps are installed on desktop', () => { + mockMediaType = MediaType.pc + renderSidebar([]) + + expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument() + }) + + it('should hide NoApps on mobile', () => { + mockMediaType = MediaType.mobile + renderSidebar([]) + + expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/explore/category.spec.tsx b/web/app/components/explore/__tests__/category.spec.tsx similarity index 84% rename from web/app/components/explore/category.spec.tsx rename to web/app/components/explore/__tests__/category.spec.tsx index a84b17c844..33349204d0 100644 --- a/web/app/components/explore/category.spec.tsx +++ b/web/app/components/explore/__tests__/category.spec.tsx @@ -1,6 +1,6 @@ import type { AppCategory } from '@/models/explore' import { fireEvent, render, screen } from '@testing-library/react' -import Category from './category' +import Category from '../category' describe('Category', () => { const allCategoriesEn = 'Recommended' @@ -19,59 +19,44 @@ describe('Category', () => { } } - // Rendering: basic categories and all-categories button. describe('Rendering', () => { it('should render all categories item and translated categories', () => { - // Arrange renderComponent() - // Assert expect(screen.getByText('explore.apps.allCategories')).toBeInTheDocument() expect(screen.getByText('explore.category.Writing')).toBeInTheDocument() }) it('should not render allCategoriesEn again inside the category list', () => { - // Arrange renderComponent() - // Assert const recommendedItems = screen.getAllByText('explore.apps.allCategories') expect(recommendedItems).toHaveLength(1) }) }) - // Props: clicking items triggers onChange. describe('Props', () => { it('should call onChange with category value when category item is clicked', () => { - // Arrange const { props } = renderComponent() - // Act fireEvent.click(screen.getByText('explore.category.Writing')) - // Assert expect(props.onChange).toHaveBeenCalledWith('Writing') }) it('should call onChange with allCategoriesEn when all categories is clicked', () => { - // Arrange const { props } = renderComponent({ value: 'Writing' }) - // Act fireEvent.click(screen.getByText('explore.apps.allCategories')) - // Assert expect(props.onChange).toHaveBeenCalledWith(allCategoriesEn) }) }) - // Edge cases: handle values not in the list. describe('Edge Cases', () => { it('should treat unknown value as all categories selection', () => { - // Arrange renderComponent({ value: 'Unknown' }) - // Assert const allCategoriesItem = screen.getByText('explore.apps.allCategories') expect(allCategoriesItem.className).toContain('bg-components-main-nav-nav-button-bg-active') }) diff --git a/web/app/components/explore/index.spec.tsx b/web/app/components/explore/__tests__/index.spec.tsx similarity index 91% rename from web/app/components/explore/index.spec.tsx rename to web/app/components/explore/__tests__/index.spec.tsx index e64c0c365a..b7ba9eccd2 100644 --- a/web/app/components/explore/index.spec.tsx +++ b/web/app/components/explore/__tests__/index.spec.tsx @@ -6,7 +6,7 @@ import ExploreContext from '@/context/explore-context' import { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' import { useMembers } from '@/service/use-common' -import Explore from './index' +import Explore from '../index' const mockReplace = vi.fn() const mockPush = vi.fn() @@ -65,10 +65,8 @@ describe('Explore', () => { vi.clearAllMocks() }) - // Rendering: provides ExploreContext and children. describe('Rendering', () => { it('should render children and provide edit permission from members role', async () => { - // Arrange ; (useAppContext as Mock).mockReturnValue({ userProfile: { id: 'user-1' }, isCurrentWorkspaceDatasetOperator: false, @@ -79,57 +77,48 @@ describe('Explore', () => { }, }) - // Act render(( )) - // Assert await waitFor(() => { expect(screen.getByText('edit-yes')).toBeInTheDocument() }) }) }) - // Effects: set document title and redirect dataset operators. describe('Effects', () => { it('should set document title on render', () => { - // Arrange ; (useAppContext as Mock).mockReturnValue({ userProfile: { id: 'user-1' }, isCurrentWorkspaceDatasetOperator: false, }); (useMembers as Mock).mockReturnValue({ data: { accounts: [] } }) - // Act render((
child
)) - // Assert expect(useDocumentTitle).toHaveBeenCalledWith('common.menus.explore') }) it('should redirect dataset operators to /datasets', async () => { - // Arrange ; (useAppContext as Mock).mockReturnValue({ userProfile: { id: 'user-1' }, isCurrentWorkspaceDatasetOperator: true, }); (useMembers as Mock).mockReturnValue({ data: { accounts: [] } }) - // Act render((
child
)) - // Assert await waitFor(() => { expect(mockReplace).toHaveBeenCalledWith('/datasets') }) diff --git a/web/app/components/explore/app-card/__tests__/index.spec.tsx b/web/app/components/explore/app-card/__tests__/index.spec.tsx new file mode 100644 index 0000000000..f5bb5e9615 --- /dev/null +++ b/web/app/components/explore/app-card/__tests__/index.spec.tsx @@ -0,0 +1,140 @@ +import type { AppCardProps } from '../index' +import type { App } from '@/models/explore' +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { AppModeEnum } from '@/types/app' +import AppCard from '../index' + +vi.mock('../../../app/type-selector', () => ({ + AppTypeIcon: ({ type }: { type: string }) =>
{type}
, +})) + +const createApp = (overrides?: Partial): App => ({ + can_trial: true, + app_id: 'app-id', + description: 'App description', + copyright: '2024', + privacy_policy: null, + custom_disclaimer: null, + category: 'Assistant', + position: 1, + is_listed: true, + install_count: 0, + installed: false, + editable: true, + is_agent: false, + ...overrides, + app: { + id: 'id-1', + mode: AppModeEnum.CHAT, + icon_type: null, + icon: '๐Ÿค–', + icon_background: '#fff', + icon_url: '', + name: 'Sample App', + description: 'App description', + use_icon_as_answer_icon: false, + ...overrides?.app, + }, +}) + +describe('AppCard', () => { + const onCreate = vi.fn() + + const renderComponent = (props?: Partial) => { + const mergedProps: AppCardProps = { + app: createApp(), + canCreate: false, + onCreate, + isExplore: false, + ...props, + } + return render() + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render app name and description', () => { + renderComponent() + + expect(screen.getByText('Sample App')).toBeInTheDocument() + expect(screen.getByText('App description')).toBeInTheDocument() + }) + + it.each([ + [AppModeEnum.CHAT, 'APP.TYPES.CHATBOT'], + [AppModeEnum.ADVANCED_CHAT, 'APP.TYPES.ADVANCED'], + [AppModeEnum.AGENT_CHAT, 'APP.TYPES.AGENT'], + [AppModeEnum.WORKFLOW, 'APP.TYPES.WORKFLOW'], + [AppModeEnum.COMPLETION, 'APP.TYPES.COMPLETION'], + ])('should render correct mode label for %s mode', (mode, label) => { + renderComponent({ app: createApp({ app: { ...createApp().app, mode } }) }) + + expect(screen.getByText(label)).toBeInTheDocument() + expect(screen.getByTestId('app-type-icon')).toHaveTextContent(mode) + }) + + it('should render description in a truncatable container', () => { + renderComponent({ app: createApp({ description: 'Very long description text' }) }) + + const descWrapper = screen.getByText('Very long description text') + expect(descWrapper).toHaveClass('line-clamp-4') + }) + }) + + describe('User Interactions', () => { + it('should show create button in explore mode and trigger action', () => { + renderComponent({ + app: createApp({ app: { ...createApp().app, mode: AppModeEnum.WORKFLOW } }), + canCreate: true, + isExplore: true, + }) + + const button = screen.getByText('explore.appCard.addToWorkspace') + expect(button).toBeInTheDocument() + fireEvent.click(button) + expect(onCreate).toHaveBeenCalledTimes(1) + }) + + it('should render try button in explore mode', () => { + renderComponent({ canCreate: true, isExplore: true }) + + expect(screen.getByText('explore.appCard.try')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should hide action buttons when not in explore mode', () => { + renderComponent({ canCreate: true, isExplore: false }) + + expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument() + expect(screen.queryByText('explore.appCard.try')).not.toBeInTheDocument() + }) + + it('should hide create button when canCreate is false', () => { + renderComponent({ canCreate: false, isExplore: true }) + + expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should truncate long app name with title attribute', () => { + const longName = 'A Very Long Application Name That Should Be Truncated' + renderComponent({ app: createApp({ app: { ...createApp().app, name: longName } }) }) + + const nameElement = screen.getByText(longName) + expect(nameElement).toHaveAttribute('title', longName) + expect(nameElement).toHaveClass('truncate') + }) + + it('should render with empty description', () => { + renderComponent({ app: createApp({ description: '' }) }) + + expect(screen.getByText('Sample App')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/explore/app-card/index.spec.tsx b/web/app/components/explore/app-card/index.spec.tsx deleted file mode 100644 index 152eab92a9..0000000000 --- a/web/app/components/explore/app-card/index.spec.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { AppCardProps } from './index' -import type { App } from '@/models/explore' -import { fireEvent, render, screen } from '@testing-library/react' -import * as React from 'react' -import { AppModeEnum } from '@/types/app' -import AppCard from './index' - -vi.mock('../../app/type-selector', () => ({ - AppTypeIcon: ({ type }: any) =>
{type}
, -})) - -const createApp = (overrides?: Partial): App => ({ - can_trial: true, - app_id: 'app-id', - description: 'App description', - copyright: '2024', - privacy_policy: null, - custom_disclaimer: null, - category: 'Assistant', - position: 1, - is_listed: true, - install_count: 0, - installed: false, - editable: true, - is_agent: false, - ...overrides, - app: { - id: 'id-1', - mode: AppModeEnum.CHAT, - icon_type: null, - icon: '๐Ÿค–', - icon_background: '#fff', - icon_url: '', - name: 'Sample App', - description: 'App description', - use_icon_as_answer_icon: false, - ...overrides?.app, - }, -}) - -describe('AppCard', () => { - const onCreate = vi.fn() - - const renderComponent = (props?: Partial) => { - const mergedProps: AppCardProps = { - app: createApp(), - canCreate: false, - onCreate, - isExplore: false, - ...props, - } - return render() - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should render app info with correct mode label when mode is CHAT', () => { - renderComponent({ app: createApp({ app: { ...createApp().app, mode: AppModeEnum.CHAT } }) }) - - expect(screen.getByText('Sample App')).toBeInTheDocument() - expect(screen.getByText('App description')).toBeInTheDocument() - expect(screen.getByText('APP.TYPES.CHATBOT')).toBeInTheDocument() - expect(screen.getByTestId('app-type-icon')).toHaveTextContent(AppModeEnum.CHAT) - }) - - it('should show create button in explore mode and trigger action', () => { - renderComponent({ - app: createApp({ app: { ...createApp().app, mode: AppModeEnum.WORKFLOW } }), - canCreate: true, - isExplore: true, - }) - - const button = screen.getByText('explore.appCard.addToWorkspace') - expect(button).toBeInTheDocument() - fireEvent.click(button) - expect(onCreate).toHaveBeenCalledTimes(1) - expect(screen.getByText('APP.TYPES.WORKFLOW')).toBeInTheDocument() - }) - - it('should hide create button when not allowed', () => { - renderComponent({ canCreate: false, isExplore: true }) - - expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument() - }) -}) diff --git a/web/app/components/explore/app-list/index.spec.tsx b/web/app/components/explore/app-list/__tests__/index.spec.tsx similarity index 94% rename from web/app/components/explore/app-list/index.spec.tsx rename to web/app/components/explore/app-list/__tests__/index.spec.tsx index a87d5a2363..cb83fd3147 100644 --- a/web/app/components/explore/app-list/index.spec.tsx +++ b/web/app/components/explore/app-list/__tests__/index.spec.tsx @@ -5,7 +5,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import ExploreContext from '@/context/explore-context' import { fetchAppDetail } from '@/service/explore' import { AppModeEnum } from '@/types/app' -import AppList from './index' +import AppList from '../index' const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}' let mockTabValue = allCategoriesEn @@ -150,70 +150,55 @@ describe('AppList', () => { mockIsError = false }) - // Rendering: show loading when categories are not ready. describe('Rendering', () => { it('should render loading when the query is loading', () => { - // Arrange mockExploreData = undefined mockIsLoading = true - // Act renderWithContext() - // Assert expect(screen.getByRole('status')).toBeInTheDocument() }) it('should render app cards when data is available', () => { - // Arrange mockExploreData = { categories: ['Writing', 'Translate'], allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })], } - // Act renderWithContext() - // Assert expect(screen.getByText('Alpha')).toBeInTheDocument() expect(screen.getByText('Beta')).toBeInTheDocument() }) }) - // Props: category selection filters the list. describe('Props', () => { it('should filter apps by selected category', () => { - // Arrange mockTabValue = 'Writing' mockExploreData = { categories: ['Writing', 'Translate'], allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })], } - // Act renderWithContext() - // Assert expect(screen.getByText('Alpha')).toBeInTheDocument() expect(screen.queryByText('Beta')).not.toBeInTheDocument() }) }) - // User interactions: search and create flow. describe('User Interactions', () => { it('should filter apps by search keywords', async () => { - // Arrange mockExploreData = { categories: ['Writing'], allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })], } renderWithContext() - // Act const input = screen.getByPlaceholderText('common.operation.search') fireEvent.change(input, { target: { value: 'gam' } }) - // Assert await waitFor(() => { expect(screen.queryByText('Alpha')).not.toBeInTheDocument() expect(screen.getByText('Gamma')).toBeInTheDocument() @@ -221,7 +206,6 @@ describe('AppList', () => { }) it('should handle create flow and confirm DSL when pending', async () => { - // Arrange const onSuccess = vi.fn() mockExploreData = { categories: ['Writing'], @@ -235,12 +219,10 @@ describe('AppList', () => { options.onSuccess?.() }) - // Act renderWithContext(true, onSuccess) fireEvent.click(screen.getByText('explore.appCard.addToWorkspace')) fireEvent.click(await screen.findByTestId('confirm-create')) - // Assert await waitFor(() => { expect(fetchAppDetail).toHaveBeenCalledWith('app-basic-id') }) @@ -255,17 +237,14 @@ describe('AppList', () => { }) }) - // Edge cases: handle clearing search keywords. describe('Edge Cases', () => { it('should reset search results when clear icon is clicked', async () => { - // Arrange mockExploreData = { categories: ['Writing'], allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })], } renderWithContext() - // Act const input = screen.getByPlaceholderText('common.operation.search') fireEvent.change(input, { target: { value: 'gam' } }) await waitFor(() => { @@ -274,7 +253,6 @@ describe('AppList', () => { fireEvent.click(screen.getByTestId('input-clear')) - // Assert await waitFor(() => { expect(screen.getByText('Alpha')).toBeInTheDocument() expect(screen.getByText('Gamma')).toBeInTheDocument() diff --git a/web/app/components/explore/banner/banner-item.spec.tsx b/web/app/components/explore/banner/__tests__/banner-item.spec.tsx similarity index 91% rename from web/app/components/explore/banner/banner-item.spec.tsx rename to web/app/components/explore/banner/__tests__/banner-item.spec.tsx index c890c08dc5..de35814e8e 100644 --- a/web/app/components/explore/banner/banner-item.spec.tsx +++ b/web/app/components/explore/banner/__tests__/banner-item.spec.tsx @@ -1,7 +1,7 @@ import type { Banner } from '@/models/app' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { BannerItem } from './banner-item' +import { BannerItem } from '../banner-item' const mockScrollTo = vi.fn() const mockSlideNodes = vi.fn() @@ -16,17 +16,6 @@ vi.mock('@/app/components/base/carousel', () => ({ }), })) -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - 'banner.viewMore': 'View More', - } - return translations[key] || key - }, - }), -})) - const createMockBanner = (overrides: Partial = {}): Banner => ({ id: 'banner-1', status: 'enabled', @@ -40,14 +29,11 @@ const createMockBanner = (overrides: Partial = {}): Banner => ({ ...overrides, } as Banner) -// Mock ResizeObserver methods declared at module level and initialized const mockResizeObserverObserve = vi.fn() const mockResizeObserverDisconnect = vi.fn() -// Create mock class outside of describe block for proper hoisting class MockResizeObserver { constructor(_callback: ResizeObserverCallback) { - // Store callback if needed } observe(...args: Parameters) { @@ -59,7 +45,6 @@ class MockResizeObserver { } unobserve() { - // No-op } } @@ -72,7 +57,6 @@ describe('BannerItem', () => { vi.stubGlobal('ResizeObserver', MockResizeObserver) - // Mock window.innerWidth for responsive tests Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, @@ -147,7 +131,7 @@ describe('BannerItem', () => { />, ) - expect(screen.getByText('View More')).toBeInTheDocument() + expect(screen.getByText('explore.banner.viewMore')).toBeInTheDocument() }) }) @@ -257,7 +241,6 @@ describe('BannerItem', () => { />, ) - // Component should render without issues expect(screen.getByText('Test Banner Title')).toBeInTheDocument() }) @@ -271,7 +254,6 @@ describe('BannerItem', () => { />, ) - // Component should render with isPaused expect(screen.getByText('Test Banner Title')).toBeInTheDocument() }) }) @@ -320,7 +302,6 @@ describe('BannerItem', () => { }) it('sets maxWidth when window width is below breakpoint', () => { - // Set window width below RESPONSIVE_BREAKPOINT (1200) Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, @@ -335,12 +316,10 @@ describe('BannerItem', () => { />, ) - // Component should render and apply responsive styles expect(screen.getByText('Test Banner Title')).toBeInTheDocument() }) it('applies responsive styles when below breakpoint', () => { - // Set window width below RESPONSIVE_BREAKPOINT (1200) Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, @@ -355,8 +334,7 @@ describe('BannerItem', () => { />, ) - // The component should render even with responsive mode - expect(screen.getByText('View More')).toBeInTheDocument() + expect(screen.getByText('explore.banner.viewMore')).toBeInTheDocument() }) }) @@ -432,8 +410,6 @@ describe('BannerItem', () => { />, ) - // With selectedIndex=0 and 3 slides, nextIndex should be 1 - // The second indicator button should show the "next slide" state const buttons = screen.getAllByRole('button') expect(buttons).toHaveLength(3) }) diff --git a/web/app/components/explore/banner/banner.spec.tsx b/web/app/components/explore/banner/__tests__/banner.spec.tsx similarity index 94% rename from web/app/components/explore/banner/banner.spec.tsx rename to web/app/components/explore/banner/__tests__/banner.spec.tsx index de719c3936..d6d0aa44a8 100644 --- a/web/app/components/explore/banner/banner.spec.tsx +++ b/web/app/components/explore/banner/__tests__/banner.spec.tsx @@ -3,7 +3,7 @@ import type { Banner as BannerType } from '@/models/app' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import Banner from './banner' +import Banner from '../banner' const mockUseGetBanners = vi.fn() @@ -53,7 +53,7 @@ vi.mock('@/app/components/base/carousel', () => ({ }), })) -vi.mock('./banner-item', () => ({ +vi.mock('../banner-item', () => ({ BannerItem: ({ banner, autoplayDelay, isPaused }: { banner: BannerType autoplayDelay: number @@ -105,7 +105,6 @@ describe('Banner', () => { render() - // Loading component renders a spinner const loadingWrapper = document.querySelector('[style*="min-height"]') expect(loadingWrapper).toBeInTheDocument() }) @@ -266,7 +265,6 @@ describe('Banner', () => { const carousel = screen.getByTestId('carousel') - // Enter and then leave fireEvent.mouseEnter(carousel) fireEvent.mouseLeave(carousel) @@ -285,7 +283,6 @@ describe('Banner', () => { render() - // Trigger resize event act(() => { window.dispatchEvent(new Event('resize')) }) @@ -303,12 +300,10 @@ describe('Banner', () => { render() - // Trigger resize event act(() => { window.dispatchEvent(new Event('resize')) }) - // Wait for debounce delay (50ms) act(() => { vi.advanceTimersByTime(50) }) @@ -326,31 +321,25 @@ describe('Banner', () => { render() - // Trigger first resize event act(() => { window.dispatchEvent(new Event('resize')) }) - // Wait partial time act(() => { vi.advanceTimersByTime(30) }) - // Trigger second resize event act(() => { window.dispatchEvent(new Event('resize')) }) - // Wait another 30ms (total 60ms from second resize but only 30ms after) act(() => { vi.advanceTimersByTime(30) }) - // Should still be paused (debounce resets) let bannerItem = screen.getByTestId('banner-item') expect(bannerItem).toHaveAttribute('data-is-paused', 'true') - // Wait remaining time act(() => { vi.advanceTimersByTime(20) }) @@ -388,7 +377,6 @@ describe('Banner', () => { const { unmount } = render() - // Trigger resize to create timer act(() => { window.dispatchEvent(new Event('resize')) }) @@ -462,10 +450,8 @@ describe('Banner', () => { const { rerender } = render() - // Re-render with same props rerender() - // Component should still be present (memo doesn't break rendering) expect(screen.getByTestId('carousel')).toBeInTheDocument() }) }) diff --git a/web/app/components/explore/banner/indicator-button.spec.tsx b/web/app/components/explore/banner/__tests__/indicator-button.spec.tsx similarity index 92% rename from web/app/components/explore/banner/indicator-button.spec.tsx rename to web/app/components/explore/banner/__tests__/indicator-button.spec.tsx index 545f4e2f9a..4c391e7b5e 100644 --- a/web/app/components/explore/banner/indicator-button.spec.tsx +++ b/web/app/components/explore/banner/__tests__/indicator-button.spec.tsx @@ -1,7 +1,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { IndicatorButton } from './indicator-button' +import { IndicatorButton } from '../indicator-button' describe('IndicatorButton', () => { beforeEach(() => { @@ -164,7 +164,6 @@ describe('IndicatorButton', () => { />, ) - // Check for conic-gradient style which indicates progress indicator const progressIndicator = container.querySelector('[style*="conic-gradient"]') expect(progressIndicator).not.toBeInTheDocument() }) @@ -221,10 +220,8 @@ describe('IndicatorButton', () => { />, ) - // Initially no progress indicator expect(container.querySelector('[style*="conic-gradient"]')).not.toBeInTheDocument() - // Rerender with isNextSlide=true rerender( { />, ) - // Now progress indicator should be visible expect(container.querySelector('[style*="conic-gradient"]')).toBeInTheDocument() }) @@ -255,11 +251,9 @@ describe('IndicatorButton', () => { />, ) - // Progress indicator should be present const progressIndicator = container.querySelector('[style*="conic-gradient"]') expect(progressIndicator).toBeInTheDocument() - // Rerender with new resetKey - this should reset the progress animation rerender( { ) const newProgressIndicator = container.querySelector('[style*="conic-gradient"]') - // The progress indicator should still be present after reset expect(newProgressIndicator).toBeInTheDocument() }) @@ -293,8 +286,6 @@ describe('IndicatorButton', () => { />, ) - // The component should still render but animation should be paused - // requestAnimationFrame might still be called for polling but progress won't update expect(screen.getByRole('button')).toBeInTheDocument() mockRequestAnimationFrame.mockRestore() }) @@ -315,7 +306,6 @@ describe('IndicatorButton', () => { />, ) - // Trigger animation frame act(() => { vi.advanceTimersToNextTimer() }) @@ -342,12 +332,10 @@ describe('IndicatorButton', () => { />, ) - // Trigger animation frame act(() => { vi.advanceTimersToNextTimer() }) - // Change isNextSlide to false - this should cancel the animation frame rerender( { const mockOnClick = vi.fn() const mockRequestAnimationFrame = vi.spyOn(window, 'requestAnimationFrame') - // Mock document.hidden to be true Object.defineProperty(document, 'hidden', { writable: true, configurable: true, @@ -387,10 +374,8 @@ describe('IndicatorButton', () => { />, ) - // Component should still render expect(screen.getByRole('button')).toBeInTheDocument() - // Reset document.hidden Object.defineProperty(document, 'hidden', { writable: true, configurable: true, @@ -415,7 +400,6 @@ describe('IndicatorButton', () => { />, ) - // Progress indicator should be visible (animation running) expect(container.querySelector('[style*="conic-gradient"]')).toBeInTheDocument() }) }) diff --git a/web/app/components/explore/create-app-modal/index.spec.tsx b/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx similarity index 74% rename from web/app/components/explore/create-app-modal/index.spec.tsx rename to web/app/components/explore/create-app-modal/__tests__/index.spec.tsx index 65ec0e6096..62353fb3c1 100644 --- a/web/app/components/explore/create-app-modal/index.spec.tsx +++ b/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx @@ -1,43 +1,12 @@ -import type { CreateAppModalProps } from './index' +import type { CreateAppModalProps } from '../index' import type { UsagePlanInfo } from '@/app/components/billing/type' import { act, fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { createMockPlan, createMockPlanTotal, createMockPlanUsage } from '@/__mocks__/provider-context' import { Plan } from '@/app/components/billing/type' import { AppModeEnum } from '@/types/app' -import CreateAppModal from './index' +import CreateAppModal from '../index' -let mockTranslationOverrides: Record = {} - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: Record) => { - const override = mockTranslationOverrides[key] - if (override !== undefined) - return override - if (options?.returnObjects) - return [`${key}-feature-1`, `${key}-feature-2`] - if (options) { - const { ns, ...rest } = options - const prefix = ns ? `${ns}.` : '' - const suffix = Object.keys(rest).length > 0 ? `:${JSON.stringify(rest)}` : '' - return `${prefix}${key}${suffix}` - } - return key - }, - i18n: { - language: 'en', - changeLanguage: vi.fn(), - }, - }), - Trans: ({ children }: { children?: React.ReactNode }) => children, - initReactI18next: { - type: '3rdParty', - init: vi.fn(), - }, -})) - -// Avoid heavy emoji dataset initialization during unit tests. vi.mock('emoji-mart', () => ({ init: vi.fn(), SearchIndex: { search: vi.fn().mockResolvedValue([]) }, @@ -87,7 +56,7 @@ vi.mock('@/context/provider-context', () => ({ type ConfirmPayload = Parameters[0] -const setup = (overrides: Partial = {}) => { +const setup = async (overrides: Partial = {}) => { const onConfirm = vi.fn<(payload: ConfirmPayload) => Promise>().mockResolvedValue(undefined) const onHide = vi.fn() @@ -109,7 +78,9 @@ const setup = (overrides: Partial = {}) => { ...overrides, } - render() + await act(async () => { + render() + }) return { onConfirm, onHide } } @@ -125,25 +96,23 @@ const getAppIconTrigger = (): HTMLElement => { describe('CreateAppModal', () => { beforeEach(() => { vi.clearAllMocks() - mockTranslationOverrides = {} mockEnableBilling = false mockPlanType = Plan.team mockUsagePlanInfo = createPlanInfo(1) mockTotalPlanInfo = createPlanInfo(10) }) - // The title and form sections vary based on the modal mode (create vs edit). describe('Rendering', () => { - it('should render create title and actions when creating', () => { - setup({ appName: 'My App', isEditModal: false }) + it('should render create title and actions when creating', async () => { + await setup({ appName: 'My App', isEditModal: false }) expect(screen.getByText('explore.appCustomize.title:{"name":"My App"}')).toBeInTheDocument() expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument() }) - it('should render edit-only fields when editing a chat app', () => { - setup({ isEditModal: true, appMode: AppModeEnum.CHAT, max_active_requests: 5 }) + it('should render edit-only fields when editing a chat app', async () => { + await setup({ isEditModal: true, appMode: AppModeEnum.CHAT, max_active_requests: 5 }) expect(screen.getByText('app.editAppTitle')).toBeInTheDocument() expect(screen.getByRole('button', { name: /common\.operation\.save/ })).toBeInTheDocument() @@ -151,65 +120,57 @@ describe('CreateAppModal', () => { expect((screen.getByRole('spinbutton') as HTMLInputElement).value).toBe('5') }) - it.each([AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT])('should render answer icon switch when editing %s app', (mode) => { - setup({ isEditModal: true, appMode: mode }) + it.each([AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT])('should render answer icon switch when editing %s app', async (mode) => { + await setup({ isEditModal: true, appMode: mode }) expect(screen.getByRole('switch')).toBeInTheDocument() }) - it('should not render answer icon switch when editing a non-chat app', () => { - setup({ isEditModal: true, appMode: AppModeEnum.COMPLETION }) + it('should not render answer icon switch when editing a non-chat app', async () => { + await setup({ isEditModal: true, appMode: AppModeEnum.COMPLETION }) expect(screen.queryByRole('switch')).not.toBeInTheDocument() }) - it('should not render modal content when hidden', () => { - setup({ show: false }) + it('should not render modal content when hidden', async () => { + await setup({ show: false }) expect(screen.queryByRole('button', { name: /common\.operation\.create/ })).not.toBeInTheDocument() }) }) - // Disabled states prevent submission and reflect parent-driven props. describe('Props', () => { - it('should disable confirm action when confirmDisabled is true', () => { - setup({ confirmDisabled: true }) + it('should disable confirm action when confirmDisabled is true', async () => { + await setup({ confirmDisabled: true }) expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled() }) - it('should disable confirm action when appName is empty', () => { - setup({ appName: ' ' }) + it('should disable confirm action when appName is empty', async () => { + await setup({ appName: ' ' }) expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled() }) }) - // Defensive coverage for falsy input values and translation edge cases. describe('Edge Cases', () => { - it('should default description to empty string when appDescription is empty', () => { - setup({ appDescription: '' }) + it('should default description to empty string when appDescription is empty', async () => { + await setup({ appDescription: '' }) expect((screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder') as HTMLTextAreaElement).value).toBe('') }) - it('should fall back to empty placeholders when translations return empty string', () => { - mockTranslationOverrides = { - 'newApp.appNamePlaceholder': '', - 'newApp.appDescriptionPlaceholder': '', - } + it('should render i18n key placeholders when translations are available', async () => { + await setup() - setup() - - expect((screen.getByDisplayValue('Test App') as HTMLInputElement).placeholder).toBe('') - expect((screen.getByDisplayValue('Test description') as HTMLTextAreaElement).placeholder).toBe('') + expect((screen.getByDisplayValue('Test App') as HTMLInputElement).placeholder).toBe('app.newApp.appNamePlaceholder') + expect((screen.getByDisplayValue('Test description') as HTMLTextAreaElement).placeholder).toBe('app.newApp.appDescriptionPlaceholder') }) }) - // The modal should close from user-initiated cancellation actions. describe('User Interactions', () => { - it('should call onHide when cancel button is clicked', () => { - const { onConfirm, onHide } = setup() + it('should call onHide when cancel button is clicked', async () => { + const { onConfirm, onHide } = await setup() fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) @@ -217,16 +178,16 @@ describe('CreateAppModal', () => { expect(onConfirm).not.toHaveBeenCalled() }) - it('should call onHide when pressing Escape while visible', () => { - const { onHide } = setup() + it('should call onHide when pressing Escape while visible', async () => { + const { onHide } = await setup() fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 }) expect(onHide).toHaveBeenCalledTimes(1) }) - it('should not call onHide when pressing Escape while hidden', () => { - const { onHide } = setup({ show: false }) + it('should not call onHide when pressing Escape while hidden', async () => { + const { onHide } = await setup({ show: false }) fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 }) @@ -234,34 +195,32 @@ describe('CreateAppModal', () => { }) }) - // When billing limits are reached, the modal blocks app creation and shows quota guidance. describe('Quota Gating', () => { - it('should show AppsFull and disable create when apps quota is reached', () => { + it('should show AppsFull and disable create when apps quota is reached', async () => { mockEnableBilling = true mockPlanType = Plan.team mockUsagePlanInfo = createPlanInfo(10) mockTotalPlanInfo = createPlanInfo(10) - setup({ isEditModal: false }) + await setup({ isEditModal: false }) expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument() expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled() }) - it('should allow saving when apps quota is reached in edit mode', () => { + it('should allow saving when apps quota is reached in edit mode', async () => { mockEnableBilling = true mockPlanType = Plan.team mockUsagePlanInfo = createPlanInfo(10) mockTotalPlanInfo = createPlanInfo(10) - setup({ isEditModal: true }) + await setup({ isEditModal: true }) expect(screen.queryByText('billing.apps.fullTip2')).not.toBeInTheDocument() expect(screen.getByRole('button', { name: /common\.operation\.save/ })).toBeEnabled() }) }) - // Shortcut handlers are important for power users and must respect gating rules. describe('Keyboard Shortcuts', () => { beforeEach(() => { vi.useFakeTimers() @@ -274,11 +233,11 @@ describe('CreateAppModal', () => { it.each([ ['meta+enter', { metaKey: true }], ['ctrl+enter', { ctrlKey: true }], - ])('should submit when %s is pressed while visible', (_, modifier) => { - const { onConfirm, onHide } = setup() + ])('should submit when %s is pressed while visible', async (_, modifier) => { + const { onConfirm, onHide } = await setup() fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, ...modifier }) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -286,11 +245,11 @@ describe('CreateAppModal', () => { expect(onHide).toHaveBeenCalledTimes(1) }) - it('should not submit when modal is hidden', () => { - const { onConfirm, onHide } = setup({ show: false }) + it('should not submit when modal is hidden', async () => { + const { onConfirm, onHide } = await setup({ show: false }) fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true }) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -298,16 +257,16 @@ describe('CreateAppModal', () => { expect(onHide).not.toHaveBeenCalled() }) - it('should not submit when apps quota is reached in create mode', () => { + it('should not submit when apps quota is reached in create mode', async () => { mockEnableBilling = true mockPlanType = Plan.team mockUsagePlanInfo = createPlanInfo(10) mockTotalPlanInfo = createPlanInfo(10) - const { onConfirm, onHide } = setup({ isEditModal: false }) + const { onConfirm, onHide } = await setup({ isEditModal: false }) fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true }) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -315,16 +274,16 @@ describe('CreateAppModal', () => { expect(onHide).not.toHaveBeenCalled() }) - it('should submit when apps quota is reached in edit mode', () => { + it('should submit when apps quota is reached in edit mode', async () => { mockEnableBilling = true mockPlanType = Plan.team mockUsagePlanInfo = createPlanInfo(10) mockTotalPlanInfo = createPlanInfo(10) - const { onConfirm, onHide } = setup({ isEditModal: true }) + const { onConfirm, onHide } = await setup({ isEditModal: true }) fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true }) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -332,11 +291,11 @@ describe('CreateAppModal', () => { expect(onHide).toHaveBeenCalledTimes(1) }) - it('should not submit when name is empty', () => { - const { onConfirm, onHide } = setup({ appName: ' ' }) + it('should not submit when name is empty', async () => { + const { onConfirm, onHide } = await setup({ appName: ' ' }) fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true }) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -345,10 +304,9 @@ describe('CreateAppModal', () => { }) }) - // The app icon picker is a key user flow for customizing metadata. describe('App Icon Picker', () => { - it('should open and close the picker when cancel is clicked', () => { - setup({ + it('should open and close the picker when cancel is clicked', async () => { + await setup({ appIconType: 'image', appIcon: 'file-123', appIconUrl: 'https://example.com/icon.png', @@ -363,10 +321,10 @@ describe('CreateAppModal', () => { expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument() }) - it('should update icon payload when selecting emoji and confirming', () => { + it('should update icon payload when selecting emoji and confirming', async () => { vi.useFakeTimers() try { - const { onConfirm } = setup({ + const { onConfirm } = await setup({ appIconType: 'image', appIcon: 'file-123', appIconUrl: 'https://example.com/icon.png', @@ -374,7 +332,6 @@ describe('CreateAppModal', () => { fireEvent.click(getAppIconTrigger()) - // Find the emoji grid by locating the category label, then find the clickable emoji wrapper const categoryLabel = screen.getByText('people') const emojiGrid = categoryLabel.nextElementSibling const clickableEmojiWrapper = emojiGrid?.firstElementChild @@ -385,7 +342,7 @@ describe('CreateAppModal', () => { fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.ok' })) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -402,19 +359,17 @@ describe('CreateAppModal', () => { } }) - it('should reset emoji icon to initial props when picker is cancelled', () => { + it('should reset emoji icon to initial props when picker is cancelled', async () => { vi.useFakeTimers() try { - const { onConfirm } = setup({ + const { onConfirm } = await setup({ appIconType: 'emoji', appIcon: '๐Ÿค–', appIconBackground: '#FFEAD5', }) - // Open picker, select a new emoji, and confirm fireEvent.click(getAppIconTrigger()) - // Find the emoji grid by locating the category label, then find the clickable emoji wrapper const categoryLabel = screen.getByText('people') const emojiGrid = categoryLabel.nextElementSibling const clickableEmojiWrapper = emojiGrid?.firstElementChild @@ -426,15 +381,13 @@ describe('CreateAppModal', () => { expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument() - // Open picker again and cancel - should reset to initial props fireEvent.click(getAppIconTrigger()) fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.cancel' })) expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument() - // Submit and verify the payload uses the original icon (cancel reverts to props) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -452,7 +405,6 @@ describe('CreateAppModal', () => { }) }) - // Submitting uses a debounced handler and builds a payload from current form state. describe('Submitting', () => { beforeEach(() => { vi.useFakeTimers() @@ -462,8 +414,8 @@ describe('CreateAppModal', () => { vi.useRealTimers() }) - it('should call onConfirm with emoji payload and hide when create is clicked', () => { - const { onConfirm, onHide } = setup({ + it('should call onConfirm with emoji payload and hide when create is clicked', async () => { + const { onConfirm, onHide } = await setup({ appName: 'My App', appDescription: 'My description', appIconType: 'emoji', @@ -472,7 +424,7 @@ describe('CreateAppModal', () => { }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -491,12 +443,12 @@ describe('CreateAppModal', () => { expect(payload).not.toHaveProperty('max_active_requests') }) - it('should include updated description when textarea is changed before submitting', () => { - const { onConfirm } = setup({ appDescription: 'Old description' }) + it('should include updated description when textarea is changed before submitting', async () => { + const { onConfirm } = await setup({ appDescription: 'Old description' }) fireEvent.change(screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder'), { target: { value: 'Updated description' } }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -504,8 +456,8 @@ describe('CreateAppModal', () => { expect(onConfirm.mock.calls[0][0]).toMatchObject({ description: 'Updated description' }) }) - it('should omit icon_background when submitting with image icon', () => { - const { onConfirm } = setup({ + it('should omit icon_background when submitting with image icon', async () => { + const { onConfirm } = await setup({ appIconType: 'image', appIcon: 'file-123', appIconUrl: 'https://example.com/icon.png', @@ -513,7 +465,7 @@ describe('CreateAppModal', () => { }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -525,8 +477,8 @@ describe('CreateAppModal', () => { expect(payload.icon_background).toBeUndefined() }) - it('should include max_active_requests and updated answer icon when saving', () => { - const { onConfirm } = setup({ + it('should include max_active_requests and updated answer icon when saving', async () => { + const { onConfirm } = await setup({ isEditModal: true, appMode: AppModeEnum.CHAT, appUseIconAsAnswerIcon: false, @@ -537,7 +489,7 @@ describe('CreateAppModal', () => { fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '12' } }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ })) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -548,11 +500,11 @@ describe('CreateAppModal', () => { }) }) - it('should omit max_active_requests when input is empty', () => { - const { onConfirm } = setup({ isEditModal: true, max_active_requests: null }) + it('should omit max_active_requests when input is empty', async () => { + const { onConfirm } = await setup({ isEditModal: true, max_active_requests: null }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ })) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -560,12 +512,12 @@ describe('CreateAppModal', () => { expect(payload.max_active_requests).toBeUndefined() }) - it('should omit max_active_requests when input is not a number', () => { - const { onConfirm } = setup({ isEditModal: true, max_active_requests: null }) + it('should omit max_active_requests when input is not a number', async () => { + const { onConfirm } = await setup({ isEditModal: true, max_active_requests: null }) fireEvent.change(screen.getByRole('spinbutton'), { target: { value: 'abc' } }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ })) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -573,18 +525,18 @@ describe('CreateAppModal', () => { expect(payload.max_active_requests).toBeUndefined() }) - it('should show toast error and not submit when name becomes empty before debounced submit runs', () => { - const { onConfirm, onHide } = setup({ appName: 'My App' }) + it('should show toast error and not submit when name becomes empty before debounced submit runs', async () => { + const { onConfirm, onHide } = await setup({ appName: 'My App' }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), { target: { value: ' ' } }) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) expect(screen.getByText('explore.appCustomize.nameRequired')).toBeInTheDocument() - act(() => { + await act(async () => { vi.advanceTimersByTime(6000) }) expect(screen.queryByText('explore.appCustomize.nameRequired')).not.toBeInTheDocument() diff --git a/web/app/components/explore/installed-app/index.spec.tsx b/web/app/components/explore/installed-app/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/explore/installed-app/index.spec.tsx rename to web/app/components/explore/installed-app/__tests__/index.spec.tsx index 6d2bcb526a..eca7b3139d 100644 --- a/web/app/components/explore/installed-app/index.spec.tsx +++ b/web/app/components/explore/installed-app/__tests__/index.spec.tsx @@ -8,9 +8,8 @@ import { AccessMode } from '@/models/access-control' import { useGetUserCanAccessApp } from '@/service/access-control' import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore' import { AppModeEnum } from '@/types/app' -import InstalledApp from './index' +import InstalledApp from '../index' -// Mock external dependencies BEFORE imports vi.mock('use-context-selector', () => ({ useContext: vi.fn(), createContext: vi.fn(() => ({})), @@ -119,13 +118,11 @@ describe('InstalledApp', () => { beforeEach(() => { vi.clearAllMocks() - // Mock useContext ;(useContext as Mock).mockReturnValue({ installedApps: [mockInstalledApp], isFetchingInstalledApps: false, }) - // Mock useWebAppStore ;(useWebAppStore as unknown as Mock).mockImplementation(( selector: (state: { updateAppInfo: Mock @@ -145,7 +142,6 @@ describe('InstalledApp', () => { return selector(state) }) - // Mock service hooks with default success states ;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({ isFetching: false, data: mockWebAppAccessMode, @@ -565,7 +561,6 @@ describe('InstalledApp', () => { }) render() - // Should find and render the correct app expect(screen.getByText(/Chat With History/i)).toBeInTheDocument() expect(screen.getByText(/installed-app-123/)).toBeInTheDocument() }) @@ -624,7 +619,6 @@ describe('InstalledApp', () => { }) render() - // Error should take precedence over loading expect(screen.getByText(/Some error/)).toBeInTheDocument() }) @@ -640,7 +634,6 @@ describe('InstalledApp', () => { }) render() - // Error should take precedence over permission expect(screen.getByText(/Params error/)).toBeInTheDocument() expect(screen.queryByText(/403/)).not.toBeInTheDocument() }) @@ -656,7 +649,6 @@ describe('InstalledApp', () => { }) render() - // Permission should take precedence over 404 expect(screen.getByText(/403/)).toBeInTheDocument() expect(screen.queryByText(/404/)).not.toBeInTheDocument() }) @@ -673,7 +665,6 @@ describe('InstalledApp', () => { }) const { container } = render() - // Loading should take precedence over 404 const svg = container.querySelector('svg.spin-animation') expect(svg).toBeInTheDocument() expect(screen.queryByText(/404/)).not.toBeInTheDocument() diff --git a/web/app/components/explore/item-operation/index.spec.tsx b/web/app/components/explore/item-operation/__tests__/index.spec.tsx similarity index 83% rename from web/app/components/explore/item-operation/index.spec.tsx rename to web/app/components/explore/item-operation/__tests__/index.spec.tsx index 9084e5564e..f7f9b44a84 100644 --- a/web/app/components/explore/item-operation/index.spec.tsx +++ b/web/app/components/explore/item-operation/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import ItemOperation from './index' +import ItemOperation from '../index' describe('ItemOperation', () => { beforeEach(() => { @@ -20,87 +20,65 @@ describe('ItemOperation', () => { } } - // Rendering: menu items show after opening. describe('Rendering', () => { it('should render pin and delete actions when menu is open', async () => { - // Arrange renderComponent() - // Act fireEvent.click(screen.getByTestId('item-operation-trigger')) - // Assert expect(await screen.findByText('explore.sidebar.action.pin')).toBeInTheDocument() expect(screen.getByText('explore.sidebar.action.delete')).toBeInTheDocument() }) }) - // Props: render optional rename action and pinned label text. describe('Props', () => { it('should render rename action when isShowRenameConversation is true', async () => { - // Arrange renderComponent({ isShowRenameConversation: true }) - // Act fireEvent.click(screen.getByTestId('item-operation-trigger')) - // Assert expect(await screen.findByText('explore.sidebar.action.rename')).toBeInTheDocument() }) it('should render unpin label when isPinned is true', async () => { - // Arrange renderComponent({ isPinned: true }) - // Act fireEvent.click(screen.getByTestId('item-operation-trigger')) - // Assert expect(await screen.findByText('explore.sidebar.action.unpin')).toBeInTheDocument() }) }) - // User interactions: clicking action items triggers callbacks. describe('User Interactions', () => { it('should call togglePin when clicking pin action', async () => { - // Arrange const { props } = renderComponent() - // Act fireEvent.click(screen.getByTestId('item-operation-trigger')) fireEvent.click(await screen.findByText('explore.sidebar.action.pin')) - // Assert expect(props.togglePin).toHaveBeenCalledTimes(1) }) it('should call onDelete when clicking delete action', async () => { - // Arrange const { props } = renderComponent() - // Act fireEvent.click(screen.getByTestId('item-operation-trigger')) fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) - // Assert expect(props.onDelete).toHaveBeenCalledTimes(1) }) }) - // Edge cases: menu closes after mouse leave when no hovering state remains. describe('Edge Cases', () => { it('should close the menu when mouse leaves the panel and item is not hovering', async () => { - // Arrange renderComponent() fireEvent.click(screen.getByTestId('item-operation-trigger')) const pinText = await screen.findByText('explore.sidebar.action.pin') const menu = pinText.closest('div')?.parentElement as HTMLElement - // Act fireEvent.mouseEnter(menu) fireEvent.mouseLeave(menu) - // Assert await waitFor(() => { expect(screen.queryByText('explore.sidebar.action.pin')).not.toBeInTheDocument() }) diff --git a/web/app/components/explore/sidebar/index.spec.tsx b/web/app/components/explore/sidebar/__tests__/index.spec.tsx similarity index 62% rename from web/app/components/explore/sidebar/index.spec.tsx rename to web/app/components/explore/sidebar/__tests__/index.spec.tsx index e06cefd40b..2fcc48fc56 100644 --- a/web/app/components/explore/sidebar/index.spec.tsx +++ b/web/app/components/explore/sidebar/__tests__/index.spec.tsx @@ -5,7 +5,7 @@ import Toast from '@/app/components/base/toast' import ExploreContext from '@/context/explore-context' import { MediaType } from '@/hooks/use-breakpoints' import { AppModeEnum } from '@/types/app' -import SideBar from './index' +import SideBar from '../index' const mockSegments = ['apps'] const mockPush = vi.fn() @@ -14,6 +14,7 @@ const mockUninstall = vi.fn() const mockUpdatePinStatus = vi.fn() let mockIsFetching = false let mockInstalledApps: InstalledApp[] = [] +let mockMediaType: string = MediaType.pc vi.mock('next/navigation', () => ({ useSelectedLayoutSegments: () => mockSegments, @@ -23,7 +24,7 @@ vi.mock('next/navigation', () => ({ })) vi.mock('@/hooks/use-breakpoints', () => ({ - default: () => MediaType.pc, + default: () => mockMediaType, MediaType: { mobile: 'mobile', tablet: 'tablet', @@ -85,53 +86,73 @@ describe('SideBar', () => { vi.clearAllMocks() mockIsFetching = false mockInstalledApps = [] + mockMediaType = MediaType.pc vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) }) - // Rendering: show discovery and workspace section. describe('Rendering', () => { - it('should render workspace items when installed apps exist', () => { - // Arrange - mockInstalledApps = [createInstalledApp()] + it('should render discovery link', () => { + renderWithContext() - // Act + expect(screen.getByText('explore.sidebar.title')).toBeInTheDocument() + }) + + it('should render workspace items when installed apps exist', () => { + mockInstalledApps = [createInstalledApp()] renderWithContext(mockInstalledApps) - // Assert - expect(screen.getByText('explore.sidebar.title')).toBeInTheDocument() expect(screen.getByText('explore.sidebar.webApps')).toBeInTheDocument() expect(screen.getByText('My App')).toBeInTheDocument() }) - }) - // Effects: refresh and sync installed apps state. - describe('Effects', () => { - it('should refetch installed apps on mount', () => { - // Arrange - mockInstalledApps = [createInstalledApp()] + it('should render NoApps component when no installed apps on desktop', () => { + renderWithContext([]) - // Act + expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument() + }) + + it('should render multiple installed apps', () => { + mockInstalledApps = [ + createInstalledApp({ id: 'app-1', app: { ...createInstalledApp().app, name: 'Alpha' } }), + createInstalledApp({ id: 'app-2', app: { ...createInstalledApp().app, name: 'Beta' } }), + ] + renderWithContext(mockInstalledApps) + + expect(screen.getByText('Alpha')).toBeInTheDocument() + expect(screen.getByText('Beta')).toBeInTheDocument() + }) + + it('should render divider between pinned and unpinned apps', () => { + mockInstalledApps = [ + createInstalledApp({ id: 'app-1', is_pinned: true, app: { ...createInstalledApp().app, name: 'Pinned' } }), + createInstalledApp({ id: 'app-2', is_pinned: false, app: { ...createInstalledApp().app, name: 'Unpinned' } }), + ] + const { container } = renderWithContext(mockInstalledApps) + + const dividers = container.querySelectorAll('[class*="divider"], hr') + expect(dividers.length).toBeGreaterThan(0) + }) + }) + + describe('Effects', () => { + it('should refetch installed apps on mount', () => { + mockInstalledApps = [createInstalledApp()] renderWithContext(mockInstalledApps) - // Assert expect(mockRefetch).toHaveBeenCalledTimes(1) }) }) - // User interactions: delete and pin flows. describe('User Interactions', () => { it('should uninstall app and show toast when delete is confirmed', async () => { - // Arrange mockInstalledApps = [createInstalledApp()] mockUninstall.mockResolvedValue(undefined) renderWithContext(mockInstalledApps) - // Act fireEvent.click(screen.getByTestId('item-operation-trigger')) fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) fireEvent.click(await screen.findByText('common.operation.confirm')) - // Assert await waitFor(() => { expect(mockUninstall).toHaveBeenCalledWith('app-123') expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ @@ -142,16 +163,13 @@ describe('SideBar', () => { }) it('should update pin status and show toast when pin is clicked', async () => { - // Arrange mockInstalledApps = [createInstalledApp({ is_pinned: false })] mockUpdatePinStatus.mockResolvedValue(undefined) renderWithContext(mockInstalledApps) - // Act fireEvent.click(screen.getByTestId('item-operation-trigger')) fireEvent.click(await screen.findByText('explore.sidebar.action.pin')) - // Assert await waitFor(() => { expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-123', isPinned: true }) expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ @@ -160,5 +178,44 @@ describe('SideBar', () => { })) }) }) + + it('should unpin an already pinned app', async () => { + mockInstalledApps = [createInstalledApp({ is_pinned: true })] + mockUpdatePinStatus.mockResolvedValue(undefined) + renderWithContext(mockInstalledApps) + + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.unpin')) + + await waitFor(() => { + expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-123', isPinned: false }) + }) + }) + + it('should open and close confirm dialog for delete', async () => { + mockInstalledApps = [createInstalledApp()] + renderWithContext(mockInstalledApps) + + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) + + expect(await screen.findByText('explore.sidebar.delete.title')).toBeInTheDocument() + + fireEvent.click(screen.getByText('common.operation.cancel')) + + await waitFor(() => { + expect(mockUninstall).not.toHaveBeenCalled() + }) + }) + }) + + describe('Edge Cases', () => { + it('should hide NoApps and app names on mobile', () => { + mockMediaType = MediaType.mobile + renderWithContext([]) + + expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument() + expect(screen.queryByText('explore.sidebar.webApps')).not.toBeInTheDocument() + }) }) }) diff --git a/web/app/components/explore/sidebar/app-nav-item/index.spec.tsx b/web/app/components/explore/sidebar/app-nav-item/__tests__/index.spec.tsx similarity index 83% rename from web/app/components/explore/sidebar/app-nav-item/index.spec.tsx rename to web/app/components/explore/sidebar/app-nav-item/__tests__/index.spec.tsx index 542ecf33c2..299c181c98 100644 --- a/web/app/components/explore/sidebar/app-nav-item/index.spec.tsx +++ b/web/app/components/explore/sidebar/app-nav-item/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' -import AppNavItem from './index' +import AppNavItem from '../index' const mockPush = vi.fn() @@ -37,62 +37,46 @@ describe('AppNavItem', () => { vi.clearAllMocks() }) - // Rendering: display app name for desktop and hide for mobile. describe('Rendering', () => { it('should render name and item operation on desktop', () => { - // Arrange render() - // Assert expect(screen.getByText('My App')).toBeInTheDocument() expect(screen.getByTestId('item-operation-trigger')).toBeInTheDocument() }) it('should hide name on mobile', () => { - // Arrange render() - // Assert expect(screen.queryByText('My App')).not.toBeInTheDocument() }) }) - // User interactions: navigation and delete flow. describe('User Interactions', () => { it('should navigate to installed app when item is clicked', () => { - // Arrange render() - // Act fireEvent.click(screen.getByText('My App')) - // Assert expect(mockPush).toHaveBeenCalledWith('/explore/installed/app-123') }) it('should call onDelete with app id when delete action is clicked', async () => { - // Arrange render() - // Act fireEvent.click(screen.getByTestId('item-operation-trigger')) fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) - // Assert expect(baseProps.onDelete).toHaveBeenCalledWith('app-123') }) }) - // Edge cases: hide delete when uninstallable or selected. describe('Edge Cases', () => { it('should not render delete action when app is uninstallable', () => { - // Arrange render() - // Act fireEvent.click(screen.getByTestId('item-operation-trigger')) - // Assert expect(screen.queryByText('explore.sidebar.action.delete')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/explore/sidebar/no-apps/__tests__/index.spec.tsx b/web/app/components/explore/sidebar/no-apps/__tests__/index.spec.tsx new file mode 100644 index 0000000000..d4c37b8be5 --- /dev/null +++ b/web/app/components/explore/sidebar/no-apps/__tests__/index.spec.tsx @@ -0,0 +1,63 @@ +import { render, screen } from '@testing-library/react' +import { Theme } from '@/types/app' +import NoApps from '../index' + +let mockTheme = Theme.light + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.dify.ai${path}`, +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: mockTheme }), +})) + +describe('NoApps', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTheme = Theme.light + }) + + describe('Rendering', () => { + it('should render title, description and learn-more link', () => { + render() + + expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument() + expect(screen.getByText('explore.sidebar.noApps.description')).toBeInTheDocument() + expect(screen.getByText('explore.sidebar.noApps.learnMore')).toBeInTheDocument() + }) + + it('should render learn-more as external link with correct href', () => { + render() + + const link = screen.getByText('explore.sidebar.noApps.learnMore') + expect(link.tagName).toBe('A') + expect(link).toHaveAttribute('href', 'https://docs.dify.ai/use-dify/publish/README') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + }) + + describe('Theme', () => { + it('should apply light theme background class in light mode', () => { + mockTheme = Theme.light + + const { container } = render() + const bgDiv = container.querySelector('[class*="bg-contain"]') + + expect(bgDiv).toBeInTheDocument() + expect(bgDiv?.className).toContain('light') + expect(bgDiv?.className).not.toContain('dark') + }) + + it('should apply dark theme background class in dark mode', () => { + mockTheme = Theme.dark + + const { container } = render() + const bgDiv = container.querySelector('[class*="bg-contain"]') + + expect(bgDiv).toBeInTheDocument() + expect(bgDiv?.className).toContain('dark') + }) + }) +}) diff --git a/web/app/components/explore/try-app/index.spec.tsx b/web/app/components/explore/try-app/__tests__/index.spec.tsx similarity index 89% rename from web/app/components/explore/try-app/index.spec.tsx rename to web/app/components/explore/try-app/__tests__/index.spec.tsx index dc057b4d9f..44a413bbad 100644 --- a/web/app/components/explore/try-app/index.spec.tsx +++ b/web/app/components/explore/try-app/__tests__/index.spec.tsx @@ -1,20 +1,8 @@ import type { TryAppInfo } from '@/service/try-app' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import TryApp from './index' -import { TypeEnum } from './tab' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - 'tryApp.tabHeader.try': 'Try', - 'tryApp.tabHeader.detail': 'Detail', - } - return translations[key] || key - }, - }), -})) +import TryApp from '../index' +import { TypeEnum } from '../tab' vi.mock('@/config', async (importOriginal) => { const actual = await importOriginal() as object @@ -30,7 +18,7 @@ vi.mock('@/service/use-try-app', () => ({ useGetTryAppInfo: (...args: unknown[]) => mockUseGetTryAppInfo(...args), })) -vi.mock('./app', () => ({ +vi.mock('../app', () => ({ default: ({ appId, appDetail }: { appId: string, appDetail: TryAppInfo }) => (
App Component @@ -38,7 +26,7 @@ vi.mock('./app', () => ({ ), })) -vi.mock('./preview', () => ({ +vi.mock('../preview', () => ({ default: ({ appId, appDetail }: { appId: string, appDetail: TryAppInfo }) => (
Preview Component @@ -46,7 +34,7 @@ vi.mock('./preview', () => ({ ), })) -vi.mock('./app-info', () => ({ +vi.mock('../app-info', () => ({ default: ({ appId, appDetail, @@ -141,8 +129,8 @@ describe('TryApp (main index.tsx)', () => { ) await waitFor(() => { - expect(screen.getByText('Try')).toBeInTheDocument() - expect(screen.getByText('Detail')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument() }) }) @@ -185,7 +173,6 @@ describe('TryApp (main index.tsx)', () => { ) await waitFor(() => { - // Find the close button (the one with RiCloseLine icon) const buttons = document.body.querySelectorAll('button') expect(buttons.length).toBeGreaterThan(0) }) @@ -203,10 +190,10 @@ describe('TryApp (main index.tsx)', () => { ) await waitFor(() => { - expect(screen.getByText('Detail')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument() }) - fireEvent.click(screen.getByText('Detail')) + fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail')) await waitFor(() => { expect(document.body.querySelector('[data-testid="preview-component"]')).toBeInTheDocument() @@ -224,18 +211,16 @@ describe('TryApp (main index.tsx)', () => { ) await waitFor(() => { - expect(screen.getByText('Detail')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument() }) - // First switch to Detail - fireEvent.click(screen.getByText('Detail')) + fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail')) await waitFor(() => { expect(document.body.querySelector('[data-testid="preview-component"]')).toBeInTheDocument() }) - // Then switch back to Try - fireEvent.click(screen.getByText('Try')) + fireEvent.click(screen.getByText('explore.tryApp.tabHeader.try')) await waitFor(() => { expect(document.body.querySelector('[data-testid="app-component"]')).toBeInTheDocument() @@ -256,7 +241,6 @@ describe('TryApp (main index.tsx)', () => { ) await waitFor(() => { - // Find the button with close icon const buttons = document.body.querySelectorAll('button') const closeButton = Array.from(buttons).find(btn => btn.querySelector('svg') || btn.className.includes('rounded-[10px]'), @@ -368,10 +352,10 @@ describe('TryApp (main index.tsx)', () => { ) await waitFor(() => { - expect(screen.getByText('Detail')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument() }) - fireEvent.click(screen.getByText('Detail')) + fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail')) await waitFor(() => { const previewComponent = document.body.querySelector('[data-testid="preview-component"]') diff --git a/web/app/components/explore/try-app/tab.spec.tsx b/web/app/components/explore/try-app/__tests__/tab.spec.tsx similarity index 65% rename from web/app/components/explore/try-app/tab.spec.tsx rename to web/app/components/explore/try-app/__tests__/tab.spec.tsx index af64a93f43..9a7f04b81d 100644 --- a/web/app/components/explore/try-app/tab.spec.tsx +++ b/web/app/components/explore/try-app/__tests__/tab.spec.tsx @@ -1,18 +1,6 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import Tab, { TypeEnum } from './tab' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - 'tryApp.tabHeader.try': 'Try', - 'tryApp.tabHeader.detail': 'Detail', - } - return translations[key] || key - }, - }), -})) +import Tab, { TypeEnum } from '../tab' vi.mock('@/config', async (importOriginal) => { const actual = await importOriginal() as object @@ -31,23 +19,23 @@ describe('Tab', () => { const mockOnChange = vi.fn() render() - expect(screen.getByText('Try')).toBeInTheDocument() - expect(screen.getByText('Detail')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument() }) it('renders tab with DETAIL value selected', () => { const mockOnChange = vi.fn() render() - expect(screen.getByText('Try')).toBeInTheDocument() - expect(screen.getByText('Detail')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument() }) it('calls onChange when clicking a tab', () => { const mockOnChange = vi.fn() render() - fireEvent.click(screen.getByText('Detail')) + fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail')) expect(mockOnChange).toHaveBeenCalledWith(TypeEnum.DETAIL) }) @@ -55,7 +43,7 @@ describe('Tab', () => { const mockOnChange = vi.fn() render() - fireEvent.click(screen.getByText('Try')) + fireEvent.click(screen.getByText('explore.tryApp.tabHeader.try')) expect(mockOnChange).toHaveBeenCalledWith(TypeEnum.TRY) }) diff --git a/web/app/components/explore/try-app/app-info/index.spec.tsx b/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx similarity index 86% rename from web/app/components/explore/try-app/app-info/index.spec.tsx rename to web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx index cfae862a72..a49e9379f0 100644 --- a/web/app/components/explore/try-app/app-info/index.spec.tsx +++ b/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx @@ -1,29 +1,11 @@ import type { TryAppInfo } from '@/service/try-app' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import AppInfo from './index' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - 'types.advanced': 'Advanced', - 'types.chatbot': 'Chatbot', - 'types.agent': 'Agent', - 'types.workflow': 'Workflow', - 'types.completion': 'Completion', - 'tryApp.createFromSampleApp': 'Create from Sample', - 'tryApp.category': 'Category', - 'tryApp.requirements': 'Requirements', - } - return translations[key] || key - }, - }), -})) +import AppInfo from '../index' const mockUseGetRequirements = vi.fn() -vi.mock('./use-get-requirements', () => ({ +vi.mock('../use-get-requirements', () => ({ default: (...args: unknown[]) => mockUseGetRequirements(...args), })) @@ -118,7 +100,7 @@ describe('AppInfo', () => { />, ) - expect(screen.getByText('ADVANCED')).toBeInTheDocument() + expect(screen.getByText('APP.TYPES.ADVANCED')).toBeInTheDocument() }) it('displays CHATBOT for chat mode', () => { @@ -133,7 +115,7 @@ describe('AppInfo', () => { />, ) - expect(screen.getByText('CHATBOT')).toBeInTheDocument() + expect(screen.getByText('APP.TYPES.CHATBOT')).toBeInTheDocument() }) it('displays AGENT for agent-chat mode', () => { @@ -148,7 +130,7 @@ describe('AppInfo', () => { />, ) - expect(screen.getByText('AGENT')).toBeInTheDocument() + expect(screen.getByText('APP.TYPES.AGENT')).toBeInTheDocument() }) it('displays WORKFLOW for workflow mode', () => { @@ -163,7 +145,7 @@ describe('AppInfo', () => { />, ) - expect(screen.getByText('WORKFLOW')).toBeInTheDocument() + expect(screen.getByText('APP.TYPES.WORKFLOW')).toBeInTheDocument() }) it('displays COMPLETION for completion mode', () => { @@ -178,7 +160,7 @@ describe('AppInfo', () => { />, ) - expect(screen.getByText('COMPLETION')).toBeInTheDocument() + expect(screen.getByText('APP.TYPES.COMPLETION')).toBeInTheDocument() }) }) @@ -214,7 +196,6 @@ describe('AppInfo', () => { />, ) - // Check that there's no element with the description class that has empty content const descriptionElements = container.querySelectorAll('.system-sm-regular.mt-\\[14px\\]') expect(descriptionElements.length).toBe(0) }) @@ -233,7 +214,7 @@ describe('AppInfo', () => { />, ) - expect(screen.getByText('Create from Sample')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.createFromSampleApp')).toBeInTheDocument() }) it('calls onCreate when button is clicked', () => { @@ -248,7 +229,7 @@ describe('AppInfo', () => { />, ) - fireEvent.click(screen.getByText('Create from Sample')) + fireEvent.click(screen.getByText('explore.tryApp.createFromSampleApp')) expect(mockOnCreate).toHaveBeenCalledTimes(1) }) }) @@ -267,7 +248,7 @@ describe('AppInfo', () => { />, ) - expect(screen.getByText('Category')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.category')).toBeInTheDocument() expect(screen.getByText('AI Assistant')).toBeInTheDocument() }) @@ -283,7 +264,7 @@ describe('AppInfo', () => { />, ) - expect(screen.queryByText('Category')).not.toBeInTheDocument() + expect(screen.queryByText('explore.tryApp.category')).not.toBeInTheDocument() }) }) @@ -307,7 +288,7 @@ describe('AppInfo', () => { />, ) - expect(screen.getByText('Requirements')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.requirements')).toBeInTheDocument() expect(screen.getByText('OpenAI GPT-4')).toBeInTheDocument() expect(screen.getByText('Google Search')).toBeInTheDocument() }) @@ -328,7 +309,7 @@ describe('AppInfo', () => { />, ) - expect(screen.queryByText('Requirements')).not.toBeInTheDocument() + expect(screen.queryByText('explore.tryApp.requirements')).not.toBeInTheDocument() }) it('renders requirement icons with correct background image', () => { diff --git a/web/app/components/explore/try-app/app-info/use-get-requirements.spec.ts b/web/app/components/explore/try-app/app-info/__tests__/use-get-requirements.spec.ts similarity index 99% rename from web/app/components/explore/try-app/app-info/use-get-requirements.spec.ts rename to web/app/components/explore/try-app/app-info/__tests__/use-get-requirements.spec.ts index c8af6121d1..99f38b4310 100644 --- a/web/app/components/explore/try-app/app-info/use-get-requirements.spec.ts +++ b/web/app/components/explore/try-app/app-info/__tests__/use-get-requirements.spec.ts @@ -1,7 +1,7 @@ import type { TryAppInfo } from '@/service/try-app' import { renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import useGetRequirements from './use-get-requirements' +import useGetRequirements from '../use-get-requirements' const mockUseGetTryAppFlowPreview = vi.fn() @@ -165,7 +165,6 @@ describe('useGetRequirements', () => { useGetRequirements({ appDetail, appId: 'test-app-id' }), ) - // Only model provider should be included, no disabled tools expect(result.current.requirements).toHaveLength(1) expect(result.current.requirements[0].name).toBe('openai') }) diff --git a/web/app/components/explore/try-app/app/chat.spec.tsx b/web/app/components/explore/try-app/app/__tests__/chat.spec.tsx similarity index 89% rename from web/app/components/explore/try-app/app/chat.spec.tsx rename to web/app/components/explore/try-app/app/__tests__/chat.spec.tsx index ebd430c4e8..6335678a19 100644 --- a/web/app/components/explore/try-app/app/chat.spec.tsx +++ b/web/app/components/explore/try-app/app/__tests__/chat.spec.tsx @@ -1,19 +1,7 @@ import type { TryAppInfo } from '@/service/try-app' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import TryApp from './chat' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - 'chat.resetChat': 'Reset Chat', - 'tryApp.tryInfo': 'This is try mode info', - } - return translations[key] || key - }, - }), -})) +import TryApp from '../chat' const mockRemoveConversationIdInfo = vi.fn() const mockHandleNewConversation = vi.fn() @@ -31,7 +19,7 @@ vi.mock('@/hooks/use-breakpoints', () => ({ }, })) -vi.mock('../../../base/chat/embedded-chatbot/theme/theme-context', () => ({ +vi.mock('../../../../base/chat/embedded-chatbot/theme/theme-context', () => ({ useThemeContext: () => ({ primaryColor: '#1890ff', }), @@ -146,7 +134,7 @@ describe('TryApp (chat.tsx)', () => { />, ) - expect(screen.getByText('This is try mode info')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tryInfo')).toBeInTheDocument() }) it('applies className prop', () => { @@ -160,7 +148,6 @@ describe('TryApp (chat.tsx)', () => { />, ) - // The component wraps with EmbeddedChatbotContext.Provider, first child is the div with className const innerDiv = container.querySelector('.custom-class') expect(innerDiv).toBeInTheDocument() }) @@ -185,7 +172,6 @@ describe('TryApp (chat.tsx)', () => { />, ) - // Reset button should not be present expect(screen.queryByRole('button')).not.toBeInTheDocument() }) @@ -207,7 +193,6 @@ describe('TryApp (chat.tsx)', () => { />, ) - // Should have a button (the reset button) expect(screen.getByRole('button')).toBeInTheDocument() }) @@ -313,14 +298,12 @@ describe('TryApp (chat.tsx)', () => { />, ) - // Find and click the hide button on the alert - const alertElement = screen.getByText('This is try mode info').closest('[class*="alert"]')?.parentElement + const alertElement = screen.getByText('explore.tryApp.tryInfo').closest('[class*="alert"]')?.parentElement const hideButton = alertElement?.querySelector('button, [role="button"], svg') if (hideButton) { fireEvent.click(hideButton) - // After hiding, the alert should not be visible - expect(screen.queryByText('This is try mode info')).not.toBeInTheDocument() + expect(screen.queryByText('explore.tryApp.tryInfo')).not.toBeInTheDocument() } }) }) diff --git a/web/app/components/explore/try-app/app/index.spec.tsx b/web/app/components/explore/try-app/app/__tests__/index.spec.tsx similarity index 96% rename from web/app/components/explore/try-app/app/index.spec.tsx rename to web/app/components/explore/try-app/app/__tests__/index.spec.tsx index 927365a648..1c244e547d 100644 --- a/web/app/components/explore/try-app/app/index.spec.tsx +++ b/web/app/components/explore/try-app/app/__tests__/index.spec.tsx @@ -1,19 +1,13 @@ import type { TryAppInfo } from '@/service/try-app' import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import TryApp from './index' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import TryApp from '../index' vi.mock('@/hooks/use-document-title', () => ({ default: vi.fn(), })) -vi.mock('./chat', () => ({ +vi.mock('../chat', () => ({ default: ({ appId, appDetail, className }: { appId: string, appDetail: TryAppInfo, className: string }) => (
Chat Component @@ -21,7 +15,7 @@ vi.mock('./chat', () => ({ ), })) -vi.mock('./text-generation', () => ({ +vi.mock('../text-generation', () => ({ default: ({ appId, className, diff --git a/web/app/components/explore/try-app/app/text-generation.spec.tsx b/web/app/components/explore/try-app/app/__tests__/text-generation.spec.tsx similarity index 92% rename from web/app/components/explore/try-app/app/text-generation.spec.tsx rename to web/app/components/explore/try-app/app/__tests__/text-generation.spec.tsx index cbeafc5132..ddc3eb72a8 100644 --- a/web/app/components/explore/try-app/app/text-generation.spec.tsx +++ b/web/app/components/explore/try-app/app/__tests__/text-generation.spec.tsx @@ -1,18 +1,7 @@ import type { AppData } from '@/models/share' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import TextGeneration from './text-generation' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - 'tryApp.tryInfo': 'This is a try app notice', - } - return translations[key] || key - }, - }), -})) +import TextGeneration from '../text-generation' const mockUpdateAppInfo = vi.fn() const mockUpdateAppParams = vi.fn() @@ -156,7 +145,6 @@ describe('TextGeneration', () => { ) await waitFor(() => { - // Multiple elements may have the title (header and RunOnce mock) const titles = screen.getAllByText('Test App Title') expect(titles.length).toBeGreaterThan(0) }) @@ -275,7 +263,6 @@ describe('TextGeneration', () => { fireEvent.click(screen.getByTestId('send-button')) - // The send should work without errors expect(screen.getByTestId('result-component')).toBeInTheDocument() }) }) @@ -298,7 +285,7 @@ describe('TextGeneration', () => { fireEvent.click(screen.getByTestId('complete-button')) await waitFor(() => { - expect(screen.getByText('This is a try app notice')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tryInfo')).toBeInTheDocument() }) }) }) @@ -384,7 +371,6 @@ describe('TextGeneration', () => { fireEvent.click(screen.getByTestId('run-start-button')) - // Result panel should remain visible expect(screen.getByTestId('result-component')).toBeInTheDocument() }) }) @@ -404,10 +390,8 @@ describe('TextGeneration', () => { expect(screen.getByTestId('inputs-change-button')).toBeInTheDocument() }) - // Trigger input change which should call setInputs callback fireEvent.click(screen.getByTestId('inputs-change-button')) - // The component should handle the input change without errors expect(screen.getByTestId('run-once')).toBeInTheDocument() }) }) @@ -425,7 +409,6 @@ describe('TextGeneration', () => { ) await waitFor(() => { - // Mobile toggle panel should be rendered const togglePanel = container.querySelector('.cursor-grab') expect(togglePanel).toBeInTheDocument() }) @@ -447,13 +430,11 @@ describe('TextGeneration', () => { expect(togglePanel).toBeInTheDocument() }) - // Click to show result panel const toggleParent = container.querySelector('.cursor-grab')?.parentElement if (toggleParent) { fireEvent.click(toggleParent) } - // Click again to hide result panel await waitFor(() => { const newToggleParent = container.querySelector('.cursor-grab')?.parentElement if (newToggleParent) { @@ -461,7 +442,6 @@ describe('TextGeneration', () => { } }) - // Component should handle both show and hide without errors expect(screen.getByTestId('result-component')).toBeInTheDocument() }) }) diff --git a/web/app/components/explore/try-app/preview/basic-app-preview.spec.tsx b/web/app/components/explore/try-app/preview/__tests__/basic-app-preview.spec.tsx similarity index 98% rename from web/app/components/explore/try-app/preview/basic-app-preview.spec.tsx rename to web/app/components/explore/try-app/preview/__tests__/basic-app-preview.spec.tsx index bf86d3f02f..1cd7b7c281 100644 --- a/web/app/components/explore/try-app/preview/basic-app-preview.spec.tsx +++ b/web/app/components/explore/try-app/preview/__tests__/basic-app-preview.spec.tsx @@ -1,12 +1,6 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import BasicAppPreview from './basic-app-preview' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import BasicAppPreview from '../basic-app-preview' const mockUseGetTryAppInfo = vi.fn() const mockUseAllToolProviders = vi.fn() @@ -22,7 +16,7 @@ vi.mock('@/service/use-tools', () => ({ useAllToolProviders: () => mockUseAllToolProviders(), })) -vi.mock('../../../header/account-setting/model-provider-page/hooks', () => ({ +vi.mock('../../../../header/account-setting/model-provider-page/hooks', () => ({ useTextGenerationCurrentProviderAndModelAndModelList: (...args: unknown[]) => mockUseTextGenerationCurrentProviderAndModelAndModelList(...args), })) @@ -518,7 +512,6 @@ describe('BasicAppPreview', () => { render() - // Should still render (with default model config) await waitFor(() => { expect(mockUseGetTryAppDataSets).toHaveBeenCalled() }) diff --git a/web/app/components/explore/try-app/preview/flow-app-preview.spec.tsx b/web/app/components/explore/try-app/preview/__tests__/flow-app-preview.spec.tsx similarity index 99% rename from web/app/components/explore/try-app/preview/flow-app-preview.spec.tsx rename to web/app/components/explore/try-app/preview/__tests__/flow-app-preview.spec.tsx index c4e8175b82..22410a1e81 100644 --- a/web/app/components/explore/try-app/preview/flow-app-preview.spec.tsx +++ b/web/app/components/explore/try-app/preview/__tests__/flow-app-preview.spec.tsx @@ -1,6 +1,6 @@ import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import FlowAppPreview from './flow-app-preview' +import FlowAppPreview from '../flow-app-preview' const mockUseGetTryAppFlowPreview = vi.fn() diff --git a/web/app/components/explore/try-app/preview/index.spec.tsx b/web/app/components/explore/try-app/preview/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/explore/try-app/preview/index.spec.tsx rename to web/app/components/explore/try-app/preview/__tests__/index.spec.tsx index 022511efac..701253a302 100644 --- a/web/app/components/explore/try-app/preview/index.spec.tsx +++ b/web/app/components/explore/try-app/preview/__tests__/index.spec.tsx @@ -1,9 +1,9 @@ import type { TryAppInfo } from '@/service/try-app' import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import Preview from './index' +import Preview from '../index' -vi.mock('./basic-app-preview', () => ({ +vi.mock('../basic-app-preview', () => ({ default: ({ appId }: { appId: string }) => (
BasicAppPreview @@ -11,7 +11,7 @@ vi.mock('./basic-app-preview', () => ({ ), })) -vi.mock('./flow-app-preview', () => ({ +vi.mock('../flow-app-preview', () => ({ default: ({ appId, className }: { appId: string, className?: string }) => (
FlowAppPreview