e.stopPropagation()} data-testid="audio-preview-overlay">
,
document.body,
diff --git a/web/app/components/base/image-uploader/chat-image-uploader.spec.tsx b/web/app/components/base/image-uploader/chat-image-uploader.spec.tsx
new file mode 100644
index 0000000000..80ef06410d
--- /dev/null
+++ b/web/app/components/base/image-uploader/chat-image-uploader.spec.tsx
@@ -0,0 +1,244 @@
+import type { useLocalFileUploader } from './hooks'
+import type { ImageFile, VisionSettings } from '@/types/app'
+import { 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
[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 => ({
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ expect(mocks.hookArgs?.limit).toBe(15)
+ })
+
+ it('should pass disabled prop in local-only mode', () => {
+ const settings = createSettings({
+ transfer_methods: [TransferMethod.local_file],
+ })
+ render()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ await user.click(screen.getByRole('button'))
+
+ 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()
+
+ await user.click(screen.getByRole('button'))
+
+ expect(screen.getByText(/OR/i)).toBeInTheDocument()
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ expect(queryFileInput()).toBeInTheDocument()
+ })
+
+ 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()
+
+ 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()
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should render UploaderButton when transfer_methods is empty', () => {
+ const settings = createSettings({
+ transfer_methods: [],
+ })
+ render()
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/base/image-uploader/chat-image-uploader.tsx b/web/app/components/base/image-uploader/chat-image-uploader.tsx
index 44a9d5ce4f..d668247c05 100644
--- a/web/app/components/base/image-uploader/chat-image-uploader.tsx
+++ b/web/app/components/base/image-uploader/chat-image-uploader.tsx
@@ -2,8 +2,6 @@ import type { FC } from 'react'
import type { ImageFile, VisionSettings } from '@/types/app'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { Upload03 } from '@/app/components/base/icons/src/vender/line/general'
-import { ImagePlus } from '@/app/components/base/icons/src/vender/line/images'
import {
PortalToFollowElem,
PortalToFollowElemContent,
@@ -33,7 +31,7 @@ const UploadOnlyFromLocal: FC = ({
${hovering && 'bg-gray-100'}
`}
>
-
+