dify/web/app/components/base/image-uploader/__tests__/chat-image-uploader.spec.tsx

315 lines
11 KiB
TypeScript

import type { useLocalFileUploader } from '../hooks'
import type { ImageFile, VisionSettings } from '@/types/app'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Resolution, TransferMethod } from '@/types/app'
import ChatImageUploader from '../chat-image-uploader'
type LocalUploaderArgs = Parameters<typeof useLocalFileUploader>[0]
const mocks = vi.hoisted(() => ({
hookArgs: undefined as LocalUploaderArgs | undefined,
handleLocalFileUpload: vi.fn<(file: File) => void>(),
}))
vi.mock('../hooks', () => ({
useLocalFileUploader: (args: LocalUploaderArgs) => {
mocks.hookArgs = args
return {
disabled: args.disabled ?? false,
handleLocalFileUpload: mocks.handleLocalFileUpload,
}
},
}))
const createSettings = (overrides: Partial<VisionSettings> = {}): VisionSettings => ({
enabled: true,
number_limits: 5,
detail: Resolution.high,
transfer_methods: [TransferMethod.local_file],
image_file_size_limit: 10,
...overrides,
})
const queryFileInput = () => {
return screen.queryByTestId('local-file-input') as HTMLInputElement | null
}
const getFileInput = () => {
const input = queryFileInput()
if (!input)
throw new Error('Expected file input to exist')
return input
}
describe('ChatImageUploader', () => {
const defaultOnUpload = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mocks.hookArgs = undefined
mocks.handleLocalFileUpload.mockImplementation((file) => {
mocks.hookArgs?.onUpload({
type: TransferMethod.local_file,
_id: 'local-upload-id',
fileId: '',
progress: 0,
url: 'data:image/png;base64,mock',
file,
} as ImageFile)
})
})
describe('Rendering', () => {
it('should render UploadOnlyFromLocal when only local_file transfer method', () => {
const settings = createSettings({
transfer_methods: [TransferMethod.local_file],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
expect(queryFileInput()).toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('should render UploaderButton when remote_url is a transfer method', () => {
const settings = createSettings({
transfer_methods: [TransferMethod.remote_url],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render UploaderButton when both transfer methods are present', () => {
const settings = createSettings({
transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass limit from image_file_size_limit to uploader hook', () => {
const settings = createSettings({
transfer_methods: [TransferMethod.local_file],
image_file_size_limit: 20,
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
expect(mocks.hookArgs?.limit).toBe(20)
})
it('should convert string image_file_size_limit to number', () => {
const settings = createSettings({
transfer_methods: [TransferMethod.local_file],
image_file_size_limit: '15',
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
expect(mocks.hookArgs?.limit).toBe(15)
})
it('should pass disabled prop in local-only mode', () => {
const settings = createSettings({
transfer_methods: [TransferMethod.local_file],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} disabled />)
expect(mocks.hookArgs?.disabled).toBe(true)
expect(getFileInput()).toBeDisabled()
})
it('should pass disabled prop in button mode', () => {
const settings = createSettings({
transfer_methods: [TransferMethod.remote_url],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} disabled />)
expect(screen.getByRole('button')).toBeDisabled()
})
})
describe('User Interactions', () => {
it('should call onUpload when a local file is uploaded', async () => {
const user = userEvent.setup()
const onUpload = vi.fn()
const settings = createSettings({
transfer_methods: [TransferMethod.local_file],
})
render(<ChatImageUploader settings={settings} onUpload={onUpload} />)
const input = getFileInput()
const file = new File(['hello'], 'demo.png', { type: 'image/png' })
await user.upload(input, file)
expect(mocks.handleLocalFileUpload).toHaveBeenCalledWith(file)
expect(onUpload).toHaveBeenCalledWith(expect.objectContaining({
type: TransferMethod.local_file,
}))
})
it('should open popover when uploader trigger is clicked', async () => {
const user = userEvent.setup()
const settings = createSettings({
transfer_methods: [TransferMethod.remote_url],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
await user.click(screen.getByRole('button'))
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should call onUpload when a remote image link is submitted', async () => {
const user = userEvent.setup()
const onUpload = vi.fn()
const settings = createSettings({
transfer_methods: [TransferMethod.remote_url],
})
render(<ChatImageUploader settings={settings} onUpload={onUpload} />)
await user.click(screen.getByRole('button'))
await user.type(screen.getByTestId('image-link-input'), 'https://example.com/image.png')
await user.click(screen.getByRole('button', { name: 'common.operation.ok' }))
expect(onUpload).toHaveBeenCalledWith(expect.objectContaining({
type: TransferMethod.remote_url,
url: 'https://example.com/image.png',
progress: 0,
}))
})
it('should not open popover when uploader trigger is disabled', async () => {
const user = userEvent.setup()
const settings = createSettings({
transfer_methods: [TransferMethod.remote_url],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} disabled />)
await user.click(screen.getByRole('button'))
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should keep popover closed when trigger wrapper is clicked while disabled', async () => {
const user = userEvent.setup()
const settings = createSettings({
transfer_methods: [TransferMethod.remote_url],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} disabled />)
const button = screen.getByRole('button')
const triggerWrapper = button.parentElement
if (!triggerWrapper)
throw new Error('Expected trigger wrapper to exist')
await user.click(triggerWrapper)
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should show OR separator and local uploader when both methods are available', async () => {
const user = userEvent.setup()
const settings = createSettings({
transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
await user.click(screen.getByRole('button'))
expect(screen.getByText(/OR/i)).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(queryFileInput()).toBeInTheDocument()
})
it('should close popover when local upload calls closePopover in mixed mode', async () => {
const user = userEvent.setup()
const settings = createSettings({
transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
})
mocks.handleLocalFileUpload.mockImplementation((file) => {
mocks.hookArgs?.onUpload({
type: TransferMethod.local_file,
_id: 'mixed-local-upload-id',
fileId: '',
progress: 0,
url: 'data:image/png;base64,mixed',
file,
} as ImageFile)
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
await user.click(screen.getByRole('button'))
expect(screen.getByRole('textbox')).toBeInTheDocument()
const localInput = getFileInput()
const file = new File(['hello'], 'mixed.png', { type: 'image/png' })
await user.upload(localInput, file)
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should toggle local-upload hover style in mixed transfer mode', async () => {
const user = userEvent.setup()
const settings = createSettings({
transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
await user.click(screen.getByRole('button'))
const uploadFromComputer = screen.getByText('common.imageUploader.uploadFromComputer')
expect(uploadFromComputer).not.toHaveClass('bg-primary-50')
const localInput = getFileInput()
const hoverWrapper = localInput.parentElement
if (!hoverWrapper)
throw new Error('Expected local uploader wrapper to exist')
fireEvent.mouseEnter(hoverWrapper)
expect(uploadFromComputer).toHaveClass('bg-primary-50')
fireEvent.mouseLeave(hoverWrapper)
expect(uploadFromComputer).not.toHaveClass('bg-primary-50')
})
it('should not show OR separator or local uploader when only remote_url method', async () => {
const user = userEvent.setup()
const settings = createSettings({
transfer_methods: [TransferMethod.remote_url],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
await user.click(screen.getByRole('button'))
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.queryByText(/OR/i)).not.toBeInTheDocument()
expect(queryFileInput()).not.toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should render UploaderButton for all transfer method', () => {
const settings = createSettings({
transfer_methods: [TransferMethod.all],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render UploaderButton when transfer_methods is empty', () => {
const settings = createSettings({
transfer_methods: [],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
})