feat(tests): add integration tests for explore app list, installed apps, and sidebar lifecycle flows (#32248)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
Coding On Star 2026-02-12 10:29:03 +08:00 committed by GitHub
parent b65678bd4c
commit 3fd1eea4d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1186 additions and 550 deletions

View File

@ -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<typeof import('nuqs')>()
return {
...actual,
useQueryState: () => [mockTabValue, mockSetTab],
}
})
vi.mock('ahooks', async () => {
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
const React = await vi.importActual<typeof import('react')>('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 (
<div data-testid="create-app-modal">
<button
data-testid="confirm-create"
onClick={() => props.onConfirm({
name: 'New App',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#fff',
description: 'desc',
})}
>
confirm
</button>
<button data-testid="hide-create" onClick={props.onHide}>hide</button>
</div>
)
},
}))
vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({
default: ({ onConfirm, onCancel }: { onConfirm: () => void, onCancel: () => void }) => (
<div data-testid="dsl-confirm-modal">
<button data-testid="dsl-confirm" onClick={onConfirm}>confirm</button>
<button data-testid="dsl-cancel" onClick={onCancel}>cancel</button>
</div>
),
}))
const createApp = (overrides: Partial<App> = {}): 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) => (
<ExploreContext.Provider value={createContextValue(hasEditPermission)}>
<AppList onSuccess={onSuccess} />
</ExploreContext.Provider>
)
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)
})
})
})

View File

@ -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 }) => (
<div data-testid="text-generation-app">
Text Generation
{isWorkflow && ' (Workflow)'}
</div>
),
}))
vi.mock('@/app/components/base/chat/chat-with-history', () => ({
default: ({ installedAppInfo }: { installedAppInfo?: InstalledAppModel }) => (
<div data-testid="chat-with-history">
Chat -
{' '}
{installedAppInfo?.app.name}
</div>
),
}))
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<string, Mock>) => 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(<InstalledApp id="installed-app-1" />)
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(<InstalledApp id="installed-app-1" />)
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(<InstalledApp id="installed-app-1" />)
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(<InstalledApp id="installed-app-1" />)
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(<InstalledApp id="installed-app-1" />)
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(<InstalledApp id="installed-app-1" />)
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(<InstalledApp id="nonexistent" />)
expect(screen.getByText(/404/)).toBeInTheDocument()
})
it('should show 403 when user has no permission', () => {
const app = createInstalledApp()
setupDefaultMocks(app, { userAccess: { data: { result: false } } })
render(<InstalledApp id="installed-app-1" />)
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(<InstalledApp id="installed-app-1" />)
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)
})
})
})
})

View File

@ -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> = {}): 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(
<ExploreContext.Provider value={createContextValue(installedApps)}>
<SideBar controlUpdateInstalledApps={0} />
</ExploreContext.Provider>,
)
}
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()
})
})
})

View File

@ -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')
})

View File

@ -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((
<Explore>
<ContextReader />
</Explore>
))
// 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((
<Explore>
<div>child</div>
</Explore>
))
// 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((
<Explore>
<div>child</div>
</Explore>
))
// Assert
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/datasets')
})

View File

@ -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 }) => <div data-testid="app-type-icon">{type}</div>,
}))
const createApp = (overrides?: Partial<App>): 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<AppCardProps>) => {
const mergedProps: AppCardProps = {
app: createApp(),
canCreate: false,
onCreate,
isExplore: false,
...props,
}
return render(<AppCard {...mergedProps} />)
}
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()
})
})
})

View File

@ -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) => <div data-testid="app-type-icon">{type}</div>,
}))
const createApp = (overrides?: Partial<App>): 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<AppCardProps>) => {
const mergedProps: AppCardProps = {
app: createApp(),
canCreate: false,
onCreate,
isExplore: false,
...props,
}
return render(<AppCard {...mergedProps} />)
}
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()
})
})

View File

@ -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()

View File

@ -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<string, string> = {
'banner.viewMore': 'View More',
}
return translations[key] || key
},
}),
}))
const createMockBanner = (overrides: Partial<Banner> = {}): Banner => ({
id: 'banner-1',
status: 'enabled',
@ -40,14 +29,11 @@ const createMockBanner = (overrides: Partial<Banner> = {}): 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<ResizeObserver['observe']>) {
@ -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)
})

View File

@ -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(<Banner />)
// 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(<Banner />)
// Trigger resize event
act(() => {
window.dispatchEvent(new Event('resize'))
})
@ -303,12 +300,10 @@ describe('Banner', () => {
render(<Banner />)
// 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(<Banner />)
// 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(<Banner />)
// Trigger resize to create timer
act(() => {
window.dispatchEvent(new Event('resize'))
})
@ -462,10 +450,8 @@ describe('Banner', () => {
const { rerender } = render(<Banner />)
// Re-render with same props
rerender(<Banner />)
// Component should still be present (memo doesn't break rendering)
expect(screen.getByTestId('carousel')).toBeInTheDocument()
})
})

View File

@ -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(
<IndicatorButton
index={1}
@ -237,7 +234,6 @@ describe('IndicatorButton', () => {
/>,
)
// 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(
<IndicatorButton
index={1}
@ -273,7 +267,6 @@ describe('IndicatorButton', () => {
)
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(
<IndicatorButton
index={1}
@ -368,7 +356,6 @@ describe('IndicatorButton', () => {
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()
})
})

View File

@ -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<string, string | undefined> = {}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
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<CreateAppModalProps['onConfirm']>[0]
const setup = (overrides: Partial<CreateAppModalProps> = {}) => {
const setup = async (overrides: Partial<CreateAppModalProps> = {}) => {
const onConfirm = vi.fn<(payload: ConfirmPayload) => Promise<void>>().mockResolvedValue(undefined)
const onHide = vi.fn()
@ -109,7 +78,9 @@ const setup = (overrides: Partial<CreateAppModalProps> = {}) => {
...overrides,
}
render(<CreateAppModal {...props} />)
await act(async () => {
render(<CreateAppModal {...props} />)
})
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()

View File

@ -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(<InstalledApp id="installed-app-123" />)
// 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(<InstalledApp id="installed-app-123" />)
// Error should take precedence over loading
expect(screen.getByText(/Some error/)).toBeInTheDocument()
})
@ -640,7 +634,6 @@ describe('InstalledApp', () => {
})
render(<InstalledApp id="installed-app-123" />)
// 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(<InstalledApp id="nonexistent-app" />)
// 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(<InstalledApp id="nonexistent-app" />)
// Loading should take precedence over 404
const svg = container.querySelector('svg.spin-animation')
expect(svg).toBeInTheDocument()
expect(screen.queryByText(/404/)).not.toBeInTheDocument()

View File

@ -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()
})

View File

@ -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()
})
})
})

View File

@ -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(<AppNavItem {...baseProps} />)
// Assert
expect(screen.getByText('My App')).toBeInTheDocument()
expect(screen.getByTestId('item-operation-trigger')).toBeInTheDocument()
})
it('should hide name on mobile', () => {
// Arrange
render(<AppNavItem {...baseProps} isMobile />)
// 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(<AppNavItem {...baseProps} />)
// 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(<AppNavItem {...baseProps} />)
// 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(<AppNavItem {...baseProps} uninstallable />)
// Act
fireEvent.click(screen.getByTestId('item-operation-trigger'))
// Assert
expect(screen.queryByText('explore.sidebar.action.delete')).not.toBeInTheDocument()
})
})

View File

@ -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(<NoApps />)
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(<NoApps />)
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(<NoApps />)
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(<NoApps />)
const bgDiv = container.querySelector('[class*="bg-contain"]')
expect(bgDiv).toBeInTheDocument()
expect(bgDiv?.className).toContain('dark')
})
})
})

View File

@ -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<string, string> = {
'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 }) => (
<div data-testid="app-component" data-app-id={appId} data-mode={appDetail?.mode}>
App Component
@ -38,7 +26,7 @@ vi.mock('./app', () => ({
),
}))
vi.mock('./preview', () => ({
vi.mock('../preview', () => ({
default: ({ appId, appDetail }: { appId: string, appDetail: TryAppInfo }) => (
<div data-testid="preview-component" data-app-id={appId} data-mode={appDetail?.mode}>
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"]')

View File

@ -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<string, string> = {
'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(<Tab value={TypeEnum.TRY} onChange={mockOnChange} />)
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(<Tab value={TypeEnum.DETAIL} onChange={mockOnChange} />)
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(<Tab value={TypeEnum.TRY} onChange={mockOnChange} />)
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(<Tab value={TypeEnum.DETAIL} onChange={mockOnChange} />)
fireEvent.click(screen.getByText('Try'))
fireEvent.click(screen.getByText('explore.tryApp.tabHeader.try'))
expect(mockOnChange).toHaveBeenCalledWith(TypeEnum.TRY)
})

View File

@ -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<string, string> = {
'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', () => {

View File

@ -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')
})

View File

@ -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<string, string> = {
'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()
}
})
})

View File

@ -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 }) => (
<div data-testid="chat-component" data-app-id={appId} data-mode={appDetail.mode} className={className}>
Chat Component
@ -21,7 +15,7 @@ vi.mock('./chat', () => ({
),
}))
vi.mock('./text-generation', () => ({
vi.mock('../text-generation', () => ({
default: ({
appId,
className,

View File

@ -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<string, string> = {
'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()
})
})

View File

@ -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(<BasicAppPreview appId="test-app-id" />)
// Should still render (with default model config)
await waitFor(() => {
expect(mockUseGetTryAppDataSets).toHaveBeenCalled()
})

View File

@ -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()

View File

@ -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 }) => (
<div data-testid="basic-app-preview" data-app-id={appId}>
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 }) => (
<div data-testid="flow-app-preview" data-app-id={appId} className={className}>
FlowAppPreview