mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 01:18:05 +08:00
test(web): add and enhance frontend automated tests across multiple modules (#32268)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
@ -0,0 +1,171 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('useFoldAnimInto', () => {
|
||||
let mockOnClose: Mock<() => void>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
mockOnClose = vi.fn<() => void>()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
document.querySelectorAll('.install-modal, #plugin-task-trigger, .plugins-nav-button')
|
||||
.forEach(el => el.remove())
|
||||
})
|
||||
|
||||
it('should return modalClassName and functions', async () => {
|
||||
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
|
||||
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
|
||||
|
||||
expect(result.current.modalClassName).toBe('install-modal')
|
||||
expect(typeof result.current.foldIntoAnim).toBe('function')
|
||||
expect(typeof result.current.clearCountDown).toBe('function')
|
||||
expect(typeof result.current.countDownFoldIntoAnim).toBe('function')
|
||||
})
|
||||
|
||||
describe('foldIntoAnim', () => {
|
||||
it('should call onClose immediately when modal element is not found', async () => {
|
||||
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
|
||||
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.foldIntoAnim()
|
||||
})
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose when modal exists but trigger element is not found', async () => {
|
||||
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
|
||||
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
|
||||
|
||||
const modal = document.createElement('div')
|
||||
modal.className = 'install-modal'
|
||||
document.body.appendChild(modal)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.foldIntoAnim()
|
||||
})
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should animate and call onClose when both elements exist', async () => {
|
||||
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
|
||||
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
|
||||
|
||||
const modal = document.createElement('div')
|
||||
modal.className = 'install-modal'
|
||||
Object.defineProperty(modal, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 100, top: 100, width: 400, height: 300 }),
|
||||
})
|
||||
document.body.appendChild(modal)
|
||||
|
||||
// Set up trigger element with id
|
||||
const trigger = document.createElement('div')
|
||||
trigger.id = 'plugin-task-trigger'
|
||||
Object.defineProperty(trigger, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 50, top: 50, width: 40, height: 40 }),
|
||||
})
|
||||
document.body.appendChild(trigger)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.foldIntoAnim()
|
||||
})
|
||||
|
||||
// Should apply animation styles
|
||||
expect(modal.style.transition).toContain('750ms')
|
||||
expect(modal.style.transform).toContain('translate')
|
||||
expect(modal.style.transform).toContain('scale')
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should use plugins-nav-button as fallback trigger element', async () => {
|
||||
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
|
||||
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
|
||||
|
||||
const modal = document.createElement('div')
|
||||
modal.className = 'install-modal'
|
||||
Object.defineProperty(modal, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 200, top: 200, width: 500, height: 400 }),
|
||||
})
|
||||
document.body.appendChild(modal)
|
||||
|
||||
// No #plugin-task-trigger, use .plugins-nav-button fallback
|
||||
const navButton = document.createElement('div')
|
||||
navButton.className = 'plugins-nav-button'
|
||||
Object.defineProperty(navButton, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 10, top: 10, width: 30, height: 30 }),
|
||||
})
|
||||
document.body.appendChild(navButton)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.foldIntoAnim()
|
||||
})
|
||||
|
||||
expect(modal.style.transform).toContain('translate')
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearCountDown', () => {
|
||||
it('should clear the countdown timer', async () => {
|
||||
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
|
||||
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
|
||||
|
||||
// Start countdown then clear it
|
||||
await act(async () => {
|
||||
result.current.countDownFoldIntoAnim()
|
||||
})
|
||||
|
||||
result.current.clearCountDown()
|
||||
|
||||
// Advance past the countdown time — onClose should NOT be called
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(20000)
|
||||
})
|
||||
|
||||
// onClose might still be called because foldIntoAnim's inner logic
|
||||
// could fire, but the setTimeout itself should be cleared
|
||||
})
|
||||
})
|
||||
|
||||
describe('countDownFoldIntoAnim', () => {
|
||||
it('should trigger foldIntoAnim after 15 seconds', async () => {
|
||||
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
|
||||
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
|
||||
|
||||
await act(async () => {
|
||||
result.current.countDownFoldIntoAnim()
|
||||
})
|
||||
|
||||
// Advance by 15 seconds
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(15000)
|
||||
})
|
||||
|
||||
// foldIntoAnim would be called, but no modal in DOM so onClose is called directly
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not trigger before 15 seconds', async () => {
|
||||
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
|
||||
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
|
||||
|
||||
await act(async () => {
|
||||
result.current.countDownFoldIntoAnim()
|
||||
})
|
||||
|
||||
// Advance only 10 seconds
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10000)
|
||||
})
|
||||
|
||||
expect(mockOnClose).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,268 @@
|
||||
import type { Dependency, InstallStatus, Plugin } from '../../../types'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { InstallStep } from '../../../types'
|
||||
import ReadyToInstall from '../ready-to-install'
|
||||
|
||||
// Track the onInstalled callback from the Install component
|
||||
let capturedOnInstalled: ((plugins: Plugin[], installStatus: InstallStatus[]) => void) | null = null
|
||||
|
||||
vi.mock('../steps/install', () => ({
|
||||
default: ({
|
||||
allPlugins,
|
||||
onCancel,
|
||||
onStartToInstall,
|
||||
onInstalled,
|
||||
isFromMarketPlace,
|
||||
}: {
|
||||
allPlugins: Dependency[]
|
||||
onCancel: () => void
|
||||
onStartToInstall: () => void
|
||||
onInstalled: (plugins: Plugin[], installStatus: InstallStatus[]) => void
|
||||
isFromMarketPlace?: boolean
|
||||
}) => {
|
||||
capturedOnInstalled = onInstalled
|
||||
return (
|
||||
<div data-testid="install-step">
|
||||
<span data-testid="install-plugins-count">{allPlugins?.length}</span>
|
||||
<span data-testid="install-from-marketplace">{String(!!isFromMarketPlace)}</span>
|
||||
<button data-testid="install-cancel-btn" onClick={onCancel}>Cancel</button>
|
||||
<button data-testid="install-start-btn" onClick={onStartToInstall}>Start</button>
|
||||
<button
|
||||
data-testid="install-complete-btn"
|
||||
onClick={() => onInstalled(
|
||||
[{ plugin_id: 'p1', name: 'Plugin 1' } as Plugin],
|
||||
[{ success: true, isFromMarketPlace: true }],
|
||||
)}
|
||||
>
|
||||
Complete
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../steps/installed', () => ({
|
||||
default: ({
|
||||
list,
|
||||
installStatus,
|
||||
onCancel,
|
||||
}: {
|
||||
list: Plugin[]
|
||||
installStatus: InstallStatus[]
|
||||
onCancel: () => void
|
||||
}) => (
|
||||
<div data-testid="installed-step">
|
||||
<span data-testid="installed-count">{list.length}</span>
|
||||
<span data-testid="installed-status-count">{installStatus.length}</span>
|
||||
<button data-testid="installed-close-btn" onClick={onCancel}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createMockDependencies = (): Dependency[] => [
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
marketplace_plugin_unique_identifier: 'plugin-1-uid',
|
||||
},
|
||||
} as Dependency,
|
||||
{
|
||||
type: 'github',
|
||||
value: {
|
||||
repo: 'test/plugin2',
|
||||
version: 'v1.0.0',
|
||||
package: 'plugin2.zip',
|
||||
},
|
||||
} as Dependency,
|
||||
]
|
||||
|
||||
describe('ReadyToInstall', () => {
|
||||
const mockOnStepChange = vi.fn()
|
||||
const mockOnStartToInstall = vi.fn()
|
||||
const mockSetIsInstalling = vi.fn()
|
||||
const mockOnClose = vi.fn()
|
||||
|
||||
const defaultProps = {
|
||||
step: InstallStep.readyToInstall,
|
||||
onStepChange: mockOnStepChange,
|
||||
onStartToInstall: mockOnStartToInstall,
|
||||
setIsInstalling: mockSetIsInstalling,
|
||||
allPlugins: createMockDependencies(),
|
||||
onClose: mockOnClose,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedOnInstalled = null
|
||||
})
|
||||
|
||||
describe('readyToInstall step', () => {
|
||||
it('should render Install component when step is readyToInstall', () => {
|
||||
render(<ReadyToInstall {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('installed-step')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass allPlugins count to Install component', () => {
|
||||
render(<ReadyToInstall {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('install-plugins-count')).toHaveTextContent('2')
|
||||
})
|
||||
|
||||
it('should pass isFromMarketPlace to Install component', () => {
|
||||
render(<ReadyToInstall {...defaultProps} isFromMarketPlace />)
|
||||
|
||||
expect(screen.getByTestId('install-from-marketplace')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should pass onClose as onCancel to Install', () => {
|
||||
render(<ReadyToInstall {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-cancel-btn'))
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should pass onStartToInstall to Install', () => {
|
||||
render(<ReadyToInstall {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-start-btn'))
|
||||
|
||||
expect(mockOnStartToInstall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleInstalled callback', () => {
|
||||
it('should transition to installed step when Install completes', () => {
|
||||
render(<ReadyToInstall {...defaultProps} />)
|
||||
|
||||
// Trigger the onInstalled callback via the mock button
|
||||
fireEvent.click(screen.getByTestId('install-complete-btn'))
|
||||
|
||||
// Should update step to installed
|
||||
expect(mockOnStepChange).toHaveBeenCalledWith(InstallStep.installed)
|
||||
// Should set isInstalling to false
|
||||
expect(mockSetIsInstalling).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should store installed plugins and status for the Installed step', () => {
|
||||
const { rerender } = render(<ReadyToInstall {...defaultProps} />)
|
||||
|
||||
// Trigger install completion
|
||||
fireEvent.click(screen.getByTestId('install-complete-btn'))
|
||||
|
||||
// Re-render with step=installed to show Installed component
|
||||
rerender(
|
||||
<ReadyToInstall
|
||||
{...defaultProps}
|
||||
step={InstallStep.installed}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('installed-count')).toHaveTextContent('1')
|
||||
expect(screen.getByTestId('installed-status-count')).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('should pass custom plugins and status via capturedOnInstalled', () => {
|
||||
const { rerender } = render(<ReadyToInstall {...defaultProps} />)
|
||||
|
||||
// Use the captured callback directly with custom data
|
||||
expect(capturedOnInstalled).toBeTruthy()
|
||||
act(() => {
|
||||
capturedOnInstalled!(
|
||||
[
|
||||
{ plugin_id: 'p1', name: 'P1' } as Plugin,
|
||||
{ plugin_id: 'p2', name: 'P2' } as Plugin,
|
||||
],
|
||||
[
|
||||
{ success: true, isFromMarketPlace: true },
|
||||
{ success: false, isFromMarketPlace: false },
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
expect(mockOnStepChange).toHaveBeenCalledWith(InstallStep.installed)
|
||||
expect(mockSetIsInstalling).toHaveBeenCalledWith(false)
|
||||
|
||||
// Re-render at installed step
|
||||
rerender(
|
||||
<ReadyToInstall
|
||||
{...defaultProps}
|
||||
step={InstallStep.installed}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('installed-count')).toHaveTextContent('2')
|
||||
expect(screen.getByTestId('installed-status-count')).toHaveTextContent('2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('installed step', () => {
|
||||
it('should render Installed component when step is installed', () => {
|
||||
render(
|
||||
<ReadyToInstall
|
||||
{...defaultProps}
|
||||
step={InstallStep.installed}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('install-step')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass onClose to Installed component', () => {
|
||||
render(
|
||||
<ReadyToInstall
|
||||
{...defaultProps}
|
||||
step={InstallStep.installed}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('installed-close-btn'))
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render empty installed list initially', () => {
|
||||
render(
|
||||
<ReadyToInstall
|
||||
{...defaultProps}
|
||||
step={InstallStep.installed}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('installed-count')).toHaveTextContent('0')
|
||||
expect(screen.getByTestId('installed-status-count')).toHaveTextContent('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should render nothing when step is neither readyToInstall nor installed', () => {
|
||||
const { container } = render(
|
||||
<ReadyToInstall
|
||||
{...defaultProps}
|
||||
step={InstallStep.uploading}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('install-step')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('installed-step')).not.toBeInTheDocument()
|
||||
// Only the empty fragment wrapper
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should handle empty allPlugins array', () => {
|
||||
render(
|
||||
<ReadyToInstall
|
||||
{...defaultProps}
|
||||
allPlugins={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('install-plugins-count')).toHaveTextContent('0')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user