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
27 changed files with 1186 additions and 550 deletions

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