mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 17:38:04 +08:00
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:
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user