diff --git a/web/app/components/base/image-uploader/audio-preview.spec.tsx b/web/app/components/base/image-uploader/audio-preview.spec.tsx new file mode 100644 index 0000000000..72cfa7621d --- /dev/null +++ b/web/app/components/base/image-uploader/audio-preview.spec.tsx @@ -0,0 +1,114 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import AudioPreview from './audio-preview' + +describe('AudioPreview', () => { + const defaultProps = { + url: 'https://example.com/audio.mp3', + title: 'Test Audio', + onCancel: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByTestId('audio-element')).toBeInTheDocument() + }) + + it('should render audio element with controls', () => { + render() + const audio = screen.getByTestId('audio-element') + expect(audio.tagName).toBe('AUDIO') + expect(audio).toHaveAttribute('controls') + }) + + it('should render source element with correct src', () => { + render() + const source = screen.getByTestId('audio-element').querySelector('source') + expect(source).toHaveAttribute('src', 'https://example.com/audio.mp3') + expect(source).toHaveAttribute('type', 'audio/mpeg') + }) + + it('should render close button', () => { + render() + const closeBtn = screen.getByTestId('close-preview') + expect(closeBtn).toBeInTheDocument() + }) + + it('should render via portal into document.body', () => { + render() + const overlay = screen.getByTestId('audio-preview-overlay') + expect(overlay).toBeInTheDocument() + expect(overlay.parentElement).toBe(document.body) + }) + }) + + describe('Props', () => { + it('should set audio title from title prop', () => { + render() + expect(screen.getByTitle('My Song')).toBeInTheDocument() + }) + + it('should set audio source from url prop', () => { + render() + const source = screen.getByTestId('audio-element').querySelector('source') + expect(source).toHaveAttribute('src', 'https://example.com/song.mp3') + }) + + it('should set autoPlay to false', () => { + render() + const audio = screen.getByTestId('audio-element') as HTMLAudioElement + expect(audio.autoplay).toBe(false) + }) + + it('should set preload to metadata', () => { + render() + const audio = screen.getByTestId('audio-element') + expect(audio).toHaveAttribute('preload', 'metadata') + }) + }) + + describe('User Interactions', () => { + it('should call onCancel when close button is clicked', async () => { + const user = userEvent.setup() + const onCancel = vi.fn() + render() + + const closeBtn = screen.getByTestId('close-preview') + await user.click(closeBtn) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('should not call onCancel when overlay background is clicked', async () => { + const user = userEvent.setup() + const onCancel = vi.fn() + render() + + const overlay = screen.getByTestId('audio-preview-overlay') + await user.click(overlay) + + // Clicking the overlay backdrop should not trigger onCancel + expect(onCancel).not.toHaveBeenCalled() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty url', () => { + render() + const source = screen.getByTestId('audio-element').querySelector('source') + expect(source).toBeInTheDocument() + }) + + it('should handle empty title', () => { + render() + const audio = screen.getByTestId('audio-element') + expect(audio).toBeInTheDocument() + expect(audio).toHaveAttribute('title', '') + }) + }) +}) diff --git a/web/app/components/base/image-uploader/audio-preview.tsx b/web/app/components/base/image-uploader/audio-preview.tsx index ce0ea5ca0c..0126502ed7 100644 --- a/web/app/components/base/image-uploader/audio-preview.tsx +++ b/web/app/components/base/image-uploader/audio-preview.tsx @@ -1,5 +1,4 @@ import type { FC } from 'react' -import { RiCloseLine } from '@remixicon/react' import { createPortal } from 'react-dom' type AudioPreviewProps = { @@ -13,9 +12,9 @@ const AudioPreview: FC = ({ onCancel, }) => { return createPortal( -
e.stopPropagation()}> +
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'} `} > - +
)} @@ -84,7 +82,7 @@ const UploaderButton: FC = ({ disabled={disabled} className="relative flex h-8 w-8 items-center justify-center rounded-lg enabled:hover:bg-gray-100 disabled:cursor-not-allowed" > - + @@ -109,7 +107,7 @@ const UploaderButton: FC = ({ hovering && 'bg-primary-50', )} > - + {t('imageUploader.uploadFromComputer', { ns: 'common' })}
)} diff --git a/web/app/components/base/image-uploader/hooks.spec.ts b/web/app/components/base/image-uploader/hooks.spec.ts new file mode 100644 index 0000000000..1de5691690 --- /dev/null +++ b/web/app/components/base/image-uploader/hooks.spec.ts @@ -0,0 +1,774 @@ +import type { ClipboardEvent, DragEvent } from 'react' +import type { ImageFile, VisionSettings } from '@/types/app' +import { act, renderHook } from '@testing-library/react' +import { Resolution, TransferMethod } from '@/types/app' +import { useClipboardUploader, useDraggableUploader, useImageFiles, useLocalFileUploader } from './hooks' + +const mockNotify = vi.fn() +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ notify: mockNotify }), +})) + +vi.mock('next/navigation', () => ({ + useParams: () => ({ token: undefined }), +})) + +const { mockImageUpload, mockGetImageUploadErrorMessage } = vi.hoisted(() => ({ + mockImageUpload: vi.fn(), + mockGetImageUploadErrorMessage: vi.fn(() => 'Upload error'), +})) +vi.mock('./utils', () => ({ + imageUpload: mockImageUpload, + getImageUploadErrorMessage: mockGetImageUploadErrorMessage, +})) + +let fileCounter = 0 + +const createImageFile = (overrides: Partial = {}): ImageFile => ({ + type: TransferMethod.local_file, + _id: `file-${fileCounter++}`, + fileId: '', + progress: 0, + url: 'data:image/png;base64,abc', + ...overrides, +}) + +const createVisionSettings = (overrides: Partial = {}): VisionSettings => ({ + enabled: true, + number_limits: 5, + detail: Resolution.high, + transfer_methods: [TransferMethod.local_file], + image_file_size_limit: 10, + ...overrides, +}) + +describe('useImageFiles', () => { + beforeEach(() => { + vi.clearAllMocks() + fileCounter = 0 + }) + + it('should return empty files initially', () => { + const { result } = renderHook(() => useImageFiles()) + expect(result.current.files).toEqual([]) + }) + + it('should add a new file via onUpload', () => { + const { result } = renderHook(() => useImageFiles()) + const imageFile = createImageFile({ _id: 'file-1' }) + + act(() => { + result.current.onUpload(imageFile) + }) + + expect(result.current.files).toHaveLength(1) + expect(result.current.files[0]._id).toBe('file-1') + }) + + it('should update an existing file via onUpload when _id matches', () => { + const { result } = renderHook(() => useImageFiles()) + const imageFile = createImageFile({ _id: 'file-1', progress: 0 }) + + act(() => { + result.current.onUpload(imageFile) + }) + + act(() => { + result.current.onUpload({ ...imageFile, progress: 50 }) + }) + + expect(result.current.files).toHaveLength(1) + expect(result.current.files[0].progress).toBe(50) + }) + + it('should mark a file as deleted via onRemove', () => { + const { result } = renderHook(() => useImageFiles()) + const imageFile = createImageFile({ _id: 'file-1' }) + + act(() => { + result.current.onUpload(imageFile) + }) + expect(result.current.files).toHaveLength(1) + + act(() => { + result.current.onRemove('file-1') + }) + + // filteredFiles excludes deleted files + expect(result.current.files).toHaveLength(0) + }) + + it('should not modify files when onRemove is called with non-existent id', () => { + const { result } = renderHook(() => useImageFiles()) + const imageFile = createImageFile({ _id: 'file-1' }) + + act(() => { + result.current.onUpload(imageFile) + }) + + act(() => { + result.current.onRemove('non-existent') + }) + + expect(result.current.files).toHaveLength(1) + }) + + it('should set progress to -1 via onImageLinkLoadError', () => { + const { result } = renderHook(() => useImageFiles()) + const imageFile = createImageFile({ _id: 'file-1', progress: 0 }) + + act(() => { + result.current.onUpload(imageFile) + }) + + act(() => { + result.current.onImageLinkLoadError('file-1') + }) + + expect(result.current.files[0].progress).toBe(-1) + }) + + it('should not modify files when onImageLinkLoadError is called with non-existent id', () => { + const { result } = renderHook(() => useImageFiles()) + const imageFile = createImageFile({ _id: 'file-1', progress: 0 }) + + act(() => { + result.current.onUpload(imageFile) + }) + + act(() => { + result.current.onImageLinkLoadError('non-existent') + }) + + expect(result.current.files[0].progress).toBe(0) + }) + + it('should set progress to 100 via onImageLinkLoadSuccess', () => { + const { result } = renderHook(() => useImageFiles()) + const imageFile = createImageFile({ _id: 'file-1', progress: 0 }) + + act(() => { + result.current.onUpload(imageFile) + }) + + act(() => { + result.current.onImageLinkLoadSuccess('file-1') + }) + + expect(result.current.files[0].progress).toBe(100) + }) + + it('should not modify files when onImageLinkLoadSuccess is called with non-existent id', () => { + const { result } = renderHook(() => useImageFiles()) + const imageFile = createImageFile({ _id: 'file-1', progress: 50 }) + + act(() => { + result.current.onUpload(imageFile) + }) + + act(() => { + result.current.onImageLinkLoadSuccess('non-existent') + }) + + expect(result.current.files[0].progress).toBe(50) + }) + + it('should clear all files via onClear', () => { + const { result } = renderHook(() => useImageFiles()) + + act(() => { + result.current.onUpload(createImageFile({ _id: 'file-1' })) + result.current.onUpload(createImageFile({ _id: 'file-2' })) + }) + + expect(result.current.files).toHaveLength(2) + + act(() => { + result.current.onClear() + }) + + expect(result.current.files).toHaveLength(0) + }) + + describe('onReUpload', () => { + it('should call imageUpload when re-uploading an existing file', () => { + const { result } = renderHook(() => useImageFiles()) + const file = new File(['test'], 'test.png', { type: 'image/png' }) + const imageFile = createImageFile({ _id: 'file-1', file, progress: -1 }) + + act(() => { + result.current.onUpload(imageFile) + }) + + act(() => { + result.current.onReUpload('file-1') + }) + + expect(mockImageUpload).toHaveBeenCalledTimes(1) + expect(mockImageUpload).toHaveBeenCalledWith( + expect.objectContaining({ + file, + onProgressCallback: expect.any(Function), + onSuccessCallback: expect.any(Function), + onErrorCallback: expect.any(Function), + }), + false, + ) + }) + + it('should not call imageUpload when file id does not exist', () => { + const { result } = renderHook(() => useImageFiles()) + + act(() => { + result.current.onReUpload('non-existent') + }) + + expect(mockImageUpload).not.toHaveBeenCalled() + }) + + it('should update progress via onProgressCallback during re-upload', () => { + const { result } = renderHook(() => useImageFiles()) + const file = new File(['test'], 'test.png', { type: 'image/png' }) + const imageFile = createImageFile({ _id: 'file-1', file, progress: -1 }) + + act(() => { + result.current.onUpload(imageFile) + }) + + act(() => { + result.current.onReUpload('file-1') + }) + + const uploadCall = mockImageUpload.mock.calls[0][0] + + act(() => { + uploadCall.onProgressCallback(50) + }) + + expect(result.current.files[0].progress).toBe(50) + }) + + it('should update fileId and progress on success callback during re-upload', () => { + const { result } = renderHook(() => useImageFiles()) + const file = new File(['test'], 'test.png', { type: 'image/png' }) + const imageFile = createImageFile({ _id: 'file-1', file, progress: -1 }) + + act(() => { + result.current.onUpload(imageFile) + }) + + act(() => { + result.current.onReUpload('file-1') + }) + + const uploadCall = mockImageUpload.mock.calls[0][0] + + act(() => { + uploadCall.onSuccessCallback({ id: 'server-file-123' }) + }) + + expect(result.current.files[0].fileId).toBe('server-file-123') + expect(result.current.files[0].progress).toBe(100) + }) + + it('should set progress to -1 and notify on error callback during re-upload', () => { + const { result } = renderHook(() => useImageFiles()) + const file = new File(['test'], 'test.png', { type: 'image/png' }) + const imageFile = createImageFile({ _id: 'file-1', file, progress: -1 }) + + act(() => { + result.current.onUpload(imageFile) + }) + + act(() => { + result.current.onReUpload('file-1') + }) + + const uploadCall = mockImageUpload.mock.calls[0][0] + + act(() => { + uploadCall.onErrorCallback(new Error('Network error')) + }) + + expect(result.current.files[0].progress).toBe(-1) + expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Upload error' }) + }) + }) + + it('should filter out deleted files in returned files', () => { + const { result } = renderHook(() => useImageFiles()) + + act(() => { + result.current.onUpload(createImageFile({ _id: 'file-1' })) + result.current.onUpload(createImageFile({ _id: 'file-2' })) + result.current.onUpload(createImageFile({ _id: 'file-3' })) + }) + + act(() => { + result.current.onRemove('file-2') + }) + + expect(result.current.files).toHaveLength(2) + expect(result.current.files.map(f => f._id)).toEqual(['file-1', 'file-3']) + }) +}) + +describe('useLocalFileUploader', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return disabled status and handleLocalFileUpload function', () => { + const onUpload = vi.fn() + const { result } = renderHook(() => + useLocalFileUploader({ onUpload, limit: 10 }), + ) + + expect(result.current.disabled).toBe(false) + expect(result.current.handleLocalFileUpload).toBeInstanceOf(Function) + }) + + it('should not upload when disabled', () => { + const onUpload = vi.fn() + const { result } = renderHook(() => + useLocalFileUploader({ onUpload, disabled: true }), + ) + + const file = new File(['test'], 'test.png', { type: 'image/png' }) + + act(() => { + result.current.handleLocalFileUpload(file) + }) + + expect(onUpload).not.toHaveBeenCalled() + }) + + it('should reject files with disallowed extensions', () => { + const onUpload = vi.fn() + const { result } = renderHook(() => + useLocalFileUploader({ onUpload }), + ) + + const file = new File(['test'], 'test.svg', { type: 'image/svg+xml' }) + + act(() => { + result.current.handleLocalFileUpload(file) + }) + + expect(onUpload).not.toHaveBeenCalled() + }) + + it('should reject files exceeding size limit', () => { + const onUpload = vi.fn() + const { result } = renderHook(() => + useLocalFileUploader({ onUpload, limit: 1 }), // 1MB limit + ) + + // Create a file larger than 1MB + const largeContent = new Uint8Array(2 * 1024 * 1024) + const file = new File([largeContent], 'test.png', { type: 'image/png' }) + + act(() => { + result.current.handleLocalFileUpload(file) + }) + + expect(onUpload).not.toHaveBeenCalled() + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + + it('should read file and call onUpload on successful FileReader load', async () => { + const onUpload = vi.fn() + const { result } = renderHook(() => + useLocalFileUploader({ onUpload }), + ) + + const file = new File(['test'], 'test.png', { type: 'image/png' }) + + act(() => { + result.current.handleLocalFileUpload(file) + }) + + // Wait for FileReader to complete + await vi.waitFor(() => { + expect(onUpload).toHaveBeenCalled() + }) + + expect(onUpload).toHaveBeenCalledWith( + expect.objectContaining({ + type: TransferMethod.local_file, + file, + progress: 0, + }), + ) + + // imageUpload should be called after FileReader load + expect(mockImageUpload).toHaveBeenCalledTimes(1) + }) + + it('should call onUpload with progress during imageUpload', async () => { + const onUpload = vi.fn() + const { result } = renderHook(() => + useLocalFileUploader({ onUpload }), + ) + + const file = new File(['test'], 'test.png', { type: 'image/png' }) + + act(() => { + result.current.handleLocalFileUpload(file) + }) + + await vi.waitFor(() => { + expect(mockImageUpload).toHaveBeenCalled() + }) + + const uploadCall = mockImageUpload.mock.calls[0][0] + + act(() => { + uploadCall.onProgressCallback(75) + }) + + expect(onUpload).toHaveBeenCalledWith( + expect.objectContaining({ progress: 75 }), + ) + }) + + it('should call onUpload with fileId and progress 100 on upload success', async () => { + const onUpload = vi.fn() + const { result } = renderHook(() => + useLocalFileUploader({ onUpload }), + ) + + const file = new File(['test'], 'test.png', { type: 'image/png' }) + + act(() => { + result.current.handleLocalFileUpload(file) + }) + + await vi.waitFor(() => { + expect(mockImageUpload).toHaveBeenCalled() + }) + + const uploadCall = mockImageUpload.mock.calls[0][0] + + act(() => { + uploadCall.onSuccessCallback({ id: 'uploaded-id' }) + }) + + expect(onUpload).toHaveBeenCalledWith( + expect.objectContaining({ fileId: 'uploaded-id', progress: 100 }), + ) + }) + + it('should notify error and call onUpload with progress -1 on upload failure', async () => { + const onUpload = vi.fn() + const { result } = renderHook(() => + useLocalFileUploader({ onUpload }), + ) + + const file = new File(['test'], 'test.png', { type: 'image/png' }) + + act(() => { + result.current.handleLocalFileUpload(file) + }) + + await vi.waitFor(() => { + expect(mockImageUpload).toHaveBeenCalled() + }) + + const uploadCall = mockImageUpload.mock.calls[0][0] + + act(() => { + uploadCall.onErrorCallback(new Error('fail')) + }) + + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + expect(onUpload).toHaveBeenCalledWith( + expect.objectContaining({ progress: -1 }), + ) + }) +}) + +describe('useClipboardUploader', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should be disabled when visionConfig is undefined', () => { + const onUpload = vi.fn() + const { result } = renderHook(() => + useClipboardUploader({ files: [], onUpload }), + ) + + // The hook returns onPaste, and since disabled is true, pasting should not upload + expect(result.current.onPaste).toBeInstanceOf(Function) + }) + + it('should be disabled when visionConfig.enabled is false', () => { + const onUpload = vi.fn() + const settings = createVisionSettings({ enabled: false }) + const { result } = renderHook(() => + useClipboardUploader({ files: [], visionConfig: settings, onUpload }), + ) + + const file = new File(['test'], 'test.png', { type: 'image/png' }) + const mockEvent = { + clipboardData: { files: [file] }, + preventDefault: vi.fn(), + } as unknown as ClipboardEvent + act(() => { + result.current.onPaste(mockEvent) + }) + + // Paste occurs but the file should NOT be uploaded because disabled + expect(onUpload).not.toHaveBeenCalled() + }) + + it('should be disabled when local upload is not allowed', () => { + const onUpload = vi.fn() + const settings = createVisionSettings({ + transfer_methods: [TransferMethod.remote_url], + }) + renderHook(() => + useClipboardUploader({ files: [], visionConfig: settings, onUpload }), + ) + + expect(onUpload).not.toHaveBeenCalled() + }) + + it('should be disabled when files count reaches number_limits', () => { + const onUpload = vi.fn() + const settings = createVisionSettings({ number_limits: 1 }) + const files = [createImageFile({ _id: 'file-1' })] + + renderHook(() => + useClipboardUploader({ files, visionConfig: settings, onUpload }), + ) + + expect(onUpload).not.toHaveBeenCalled() + }) + + it('should call handleLocalFileUpload when pasting a file', () => { + const onUpload = vi.fn() + const settings = createVisionSettings() + + const { result } = renderHook(() => + useClipboardUploader({ files: [], visionConfig: settings, onUpload }), + ) + + const file = new File(['test'], 'test.png', { type: 'image/png' }) + const mockEvent = { + clipboardData: { + files: [file], + }, + preventDefault: vi.fn(), + } as unknown as ClipboardEvent + + act(() => { + result.current.onPaste(mockEvent) + }) + + expect(mockEvent.preventDefault).toHaveBeenCalled() + }) + + it('should not prevent default when pasting text (no file)', () => { + const onUpload = vi.fn() + const settings = createVisionSettings() + + const { result } = renderHook(() => + useClipboardUploader({ files: [], visionConfig: settings, onUpload }), + ) + + const mockEvent = { + clipboardData: { + files: [] as File[], + }, + preventDefault: vi.fn(), + } as unknown as ClipboardEvent + + act(() => { + result.current.onPaste(mockEvent) + }) + + expect(mockEvent.preventDefault).not.toHaveBeenCalled() + }) +}) + +describe('useDraggableUploader', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const createDragEvent = (files: File[] = []) => ({ + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: { + files, + }, + } as unknown as DragEvent) + + it('should return drag event handlers and isDragActive state', () => { + const onUpload = vi.fn() + const settings = createVisionSettings() + const { result } = renderHook(() => + useDraggableUploader({ files: [], visionConfig: settings, onUpload }), + ) + + expect(result.current.onDragEnter).toBeInstanceOf(Function) + expect(result.current.onDragOver).toBeInstanceOf(Function) + expect(result.current.onDragLeave).toBeInstanceOf(Function) + expect(result.current.onDrop).toBeInstanceOf(Function) + expect(result.current.isDragActive).toBe(false) + }) + + it('should set isDragActive to true on dragEnter when not disabled', () => { + const onUpload = vi.fn() + const settings = createVisionSettings() + const { result } = renderHook(() => + useDraggableUploader({ files: [], visionConfig: settings, onUpload }), + ) + + const event = createDragEvent() + + act(() => { + result.current.onDragEnter(event) + }) + + expect(result.current.isDragActive).toBe(true) + expect(event.preventDefault).toHaveBeenCalled() + expect(event.stopPropagation).toHaveBeenCalled() + }) + + it('should not set isDragActive on dragEnter when disabled', () => { + const onUpload = vi.fn() + const settings = createVisionSettings({ enabled: false }) + const { result } = renderHook(() => + useDraggableUploader({ files: [], visionConfig: settings, onUpload }), + ) + + const event = createDragEvent() + + act(() => { + result.current.onDragEnter(event) + }) + + expect(result.current.isDragActive).toBe(false) + }) + + it('should call preventDefault and stopPropagation on dragOver', () => { + const onUpload = vi.fn() + const settings = createVisionSettings() + const { result } = renderHook(() => + useDraggableUploader({ files: [], visionConfig: settings, onUpload }), + ) + + const event = createDragEvent() + + act(() => { + result.current.onDragOver(event) + }) + + expect(event.preventDefault).toHaveBeenCalled() + expect(event.stopPropagation).toHaveBeenCalled() + }) + + it('should set isDragActive to false on dragLeave', () => { + const onUpload = vi.fn() + const settings = createVisionSettings() + const { result } = renderHook(() => + useDraggableUploader({ files: [], visionConfig: settings, onUpload }), + ) + + // First activate drag + act(() => { + result.current.onDragEnter(createDragEvent()) + }) + expect(result.current.isDragActive).toBe(true) + + // Then leave + const leaveEvent = createDragEvent() + + act(() => { + result.current.onDragLeave(leaveEvent) + }) + + expect(result.current.isDragActive).toBe(false) + expect(leaveEvent.preventDefault).toHaveBeenCalled() + expect(leaveEvent.stopPropagation).toHaveBeenCalled() + }) + + it('should set isDragActive to false on drop and upload file', async () => { + const onUpload = vi.fn() + const settings = createVisionSettings() + const { result } = renderHook(() => + useDraggableUploader({ files: [], visionConfig: settings, onUpload }), + ) + + const file = new File(['test'], 'test.png', { type: 'image/png' }) + const event = createDragEvent([file]) + + // Activate drag first + act(() => { + result.current.onDragEnter(createDragEvent()) + }) + expect(result.current.isDragActive).toBe(true) + + act(() => { + result.current.onDrop(event) + }) + + expect(result.current.isDragActive).toBe(false) + expect(event.preventDefault).toHaveBeenCalled() + expect(event.stopPropagation).toHaveBeenCalled() + + // Verify the file was actually handed to the upload pipeline + await vi.waitFor(() => { + expect(mockImageUpload).toHaveBeenCalled() + }) + }) + + it('should not upload when dropping with no files', () => { + const onUpload = vi.fn() + const settings = createVisionSettings() + const { result } = renderHook(() => + useDraggableUploader({ files: [], visionConfig: settings, onUpload }), + ) + + const event = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: { + files: [] as unknown as FileList, + }, + } as unknown as React.DragEvent + + act(() => { + result.current.onDrop(event) + }) + + // onUpload should not be called directly since no file was dropped + expect(onUpload).not.toHaveBeenCalled() + }) + + it('should be disabled when files count exceeds number_limits', () => { + const onUpload = vi.fn() + const settings = createVisionSettings({ number_limits: 1 }) + const files = [createImageFile({ _id: 'file-1' })] + + const { result } = renderHook(() => + useDraggableUploader({ files, visionConfig: settings, onUpload }), + ) + + const event = createDragEvent() + + act(() => { + result.current.onDragEnter(event) + }) + + // Should not activate drag when disabled + expect(result.current.isDragActive).toBe(false) + }) +}) diff --git a/web/app/components/base/image-uploader/image-link-input.spec.tsx b/web/app/components/base/image-uploader/image-link-input.spec.tsx new file mode 100644 index 0000000000..209c5d4c0c --- /dev/null +++ b/web/app/components/base/image-uploader/image-link-input.spec.tsx @@ -0,0 +1,184 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { TransferMethod } from '@/types/app' +import ImageLinkInput from './image-link-input' + +describe('ImageLinkInput', () => { + const defaultProps = { + onUpload: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('should render an input with placeholder text', () => { + render() + const input = screen.getByRole('textbox') + expect(input).toHaveAttribute('placeholder') + expect(input).toHaveAttribute('type', 'text') + }) + + it('should render a submit button', () => { + render() + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should disable the button when input is empty', () => { + render() + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('should disable the button when disabled prop is true', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox') + await user.type(input, 'https://example.com/image.png') + + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('should enable the button when input has text and not disabled', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox') + await user.type(input, 'https://example.com/image.png') + + expect(screen.getByRole('button')).toBeEnabled() + }) + }) + + describe('User Interactions', () => { + it('should update input value when typing', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox') + await user.type(input, 'https://example.com/image.png') + + expect(input).toHaveValue('https://example.com/image.png') + }) + + it('should call onUpload with progress 0 when URL matches http/https/ftp pattern', async () => { + const user = userEvent.setup() + const onUpload = vi.fn() + render() + + const input = screen.getByRole('textbox') + await user.type(input, 'https://example.com/image.png') + await user.click(screen.getByRole('button')) + + expect(onUpload).toHaveBeenCalledTimes(1) + expect(onUpload).toHaveBeenCalledWith( + expect.objectContaining({ + type: TransferMethod.remote_url, + url: 'https://example.com/image.png', + progress: 0, + fileId: '', + }), + ) + }) + + it('should call onUpload with progress -1 when URL does not match pattern', async () => { + const user = userEvent.setup() + const onUpload = vi.fn() + render() + + const input = screen.getByRole('textbox') + await user.type(input, 'not-a-valid-url') + await user.click(screen.getByRole('button')) + + expect(onUpload).toHaveBeenCalledTimes(1) + expect(onUpload).toHaveBeenCalledWith( + expect.objectContaining({ + progress: -1, + url: 'not-a-valid-url', + }), + ) + }) + + it('should set progress 0 for http:// URLs', async () => { + const user = userEvent.setup() + const onUpload = vi.fn() + render() + + await user.type(screen.getByRole('textbox'), 'http://example.com/img.jpg') + await user.click(screen.getByRole('button')) + + expect(onUpload).toHaveBeenCalledWith( + expect.objectContaining({ progress: 0 }), + ) + }) + + it('should set progress 0 for ftp:// URLs', async () => { + const user = userEvent.setup() + const onUpload = vi.fn() + render() + + await user.type(screen.getByRole('textbox'), 'ftp://files.example.com/img.png') + await user.click(screen.getByRole('button')) + + expect(onUpload).toHaveBeenCalledWith( + expect.objectContaining({ progress: 0 }), + ) + }) + + it('should not call onUpload when disabled and button is clicked', async () => { + const user = userEvent.setup() + const onUpload = vi.fn() + render() + + const input = screen.getByRole('textbox') + await user.type(input, 'https://example.com/image.png') + await user.click(screen.getByRole('button')) + + // Button is disabled, so click won't fire handleClick + expect(onUpload).not.toHaveBeenCalled() + }) + + it('should include _id as a timestamp string in the uploaded file', async () => { + const user = userEvent.setup() + const onUpload = vi.fn() + const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(1234567890) + render() + await user.type(screen.getByRole('textbox'), 'https://example.com/img.png') + await user.click(screen.getByRole('button')) + expect(onUpload).toHaveBeenCalledWith( + expect.objectContaining({ _id: '1234567890' }), + ) + dateNowSpy.mockRestore() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty string input without errors', () => { + render() + const input = screen.getByRole('textbox') + expect(input).toHaveValue('') + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('should handle URL-like strings without protocol prefix', async () => { + const user = userEvent.setup() + const onUpload = vi.fn() + render() + + await user.type(screen.getByRole('textbox'), 'example.com/image.png') + await user.click(screen.getByRole('button')) + + expect(onUpload).toHaveBeenCalledWith( + expect.objectContaining({ progress: -1 }), + ) + }) + }) +}) diff --git a/web/app/components/base/image-uploader/image-link-input.tsx b/web/app/components/base/image-uploader/image-link-input.tsx index 99de12ca2f..b8d4f7d1cf 100644 --- a/web/app/components/base/image-uploader/image-link-input.tsx +++ b/web/app/components/base/image-uploader/image-link-input.tsx @@ -40,6 +40,7 @@ const ImageLinkInput: FC = ({ value={imageLink} onChange={e => setImageLink(e.target.value)} placeholder={t('imageUploader.pasteImageLinkInputPlaceholder', { ns: 'common' }) || ''} + data-testid="image-link-input" /> )} diff --git a/web/app/components/base/image-uploader/image-preview.spec.tsx b/web/app/components/base/image-uploader/image-preview.spec.tsx new file mode 100644 index 0000000000..949ce01842 --- /dev/null +++ b/web/app/components/base/image-uploader/image-preview.spec.tsx @@ -0,0 +1,414 @@ +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ImagePreview from './image-preview' + +type HotkeyHandler = () => void + +const mocks = vi.hoisted(() => ({ + hotkeys: {} as Record, + notify: vi.fn(), + downloadUrl: vi.fn(), + windowOpen: vi.fn<(...args: unknown[]) => Window | null>(), + clipboardWrite: vi.fn<(items: ClipboardItem[]) => Promise>(), +})) + +vi.mock('react-hotkeys-hook', () => ({ + useHotkeys: (keys: string, handler: HotkeyHandler) => { + mocks.hotkeys[keys] = handler + }, +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: (...args: Parameters) => mocks.notify(...args), + }, +})) + +vi.mock('@/utils/download', () => ({ + downloadUrl: (...args: Parameters) => mocks.downloadUrl(...args), +})) + +const getOverlay = () => screen.getByTestId('image-preview-container') as HTMLDivElement +const getCloseButton = () => screen.getByTestId('image-preview-close-button') as HTMLDivElement +const getCopyButton = () => screen.getByTestId('image-preview-copy-button') as HTMLDivElement +const getZoomOutButton = () => screen.getByTestId('image-preview-zoom-out-button') as HTMLDivElement +const getZoomInButton = () => screen.getByTestId('image-preview-zoom-in-button') as HTMLDivElement +const getDownloadButton = () => screen.getByTestId('image-preview-download-button') as HTMLDivElement +const getOpenInTabButton = () => screen.getByTestId('image-preview-open-in-tab-button') as HTMLDivElement + +const base64Image = 'aGVsbG8=' +const dataImage = `data:image/png;base64,${base64Image}` + +describe('ImagePreview', () => { + const originalClipboardItem = globalThis.ClipboardItem + + beforeEach(() => { + vi.clearAllMocks() + mocks.hotkeys = {} + + if (!navigator.clipboard) { + Object.defineProperty(globalThis.navigator, 'clipboard', { + value: { + write: vi.fn(), + }, + writable: true, + configurable: true, + }) + } + const clipboardTarget = navigator.clipboard as { write: (items: ClipboardItem[]) => Promise } + // In some test environments `write` lives on the prototype rather than + // the clipboard instance itself; locate the actual owner so vi.spyOn + // patches the right object. + const writeOwner = Object.prototype.hasOwnProperty.call(clipboardTarget, 'write') + ? clipboardTarget + : (Object.getPrototypeOf(clipboardTarget) as { write: (items: ClipboardItem[]) => Promise }) + vi.spyOn(writeOwner, 'write').mockImplementation((items: ClipboardItem[]) => { + return mocks.clipboardWrite(items) + }) + + globalThis.ClipboardItem = class { + constructor(public readonly data: Record) { } + } as unknown as typeof ClipboardItem + vi.spyOn(window, 'open').mockImplementation((...args: Parameters) => { + return mocks.windowOpen(...args) + }) + }) + + afterEach(() => { + globalThis.ClipboardItem = originalClipboardItem + vi.restoreAllMocks() + }) + + describe('Rendering', () => { + it('should render preview in portal with image from url', () => { + render( + , + ) + + const overlay = getOverlay() + expect(overlay).toBeInTheDocument() + expect(overlay?.parentElement).toBe(document.body) + expect(screen.getByRole('img', { name: 'Preview Image' })).toHaveAttribute('src', 'https://example.com/image.png') + }) + + it('should convert plain base64 string into data image src', () => { + render( + , + ) + + expect(screen.getByRole('img', { name: 'Preview Image' })).toHaveAttribute('src', dataImage) + }) + }) + + describe('Hotkeys', () => { + it('should register hotkeys and invoke esc/left/right handlers', () => { + const onCancel = vi.fn() + const onPrev = vi.fn() + const onNext = vi.fn() + render( + , + ) + + expect(mocks.hotkeys.esc).toBeInstanceOf(Function) + expect(mocks.hotkeys.left).toBeInstanceOf(Function) + expect(mocks.hotkeys.right).toBeInstanceOf(Function) + + mocks.hotkeys.esc?.() + mocks.hotkeys.left?.() + mocks.hotkeys.right?.() + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(onPrev).toHaveBeenCalledTimes(1) + expect(onNext).toHaveBeenCalledTimes(1) + }) + }) + + describe('User Interactions', () => { + it('should call onCancel when close button is clicked', async () => { + const user = userEvent.setup() + const onCancel = vi.fn() + render( + , + ) + + const closeButton = getCloseButton() + await user.click(closeButton) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('should zoom in and out with wheel interactions', async () => { + render( + , + ) + const overlay = getOverlay() + const image = screen.getByRole('img', { name: 'Preview Image' }) + + act(() => { + overlay.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: -100 })) + }) + await waitFor(() => { + expect(image).toHaveStyle({ transform: 'scale(1.2) translate(0px, 0px)' }) + }) + + act(() => { + overlay.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 100 })) + }) + await waitFor(() => { + expect(image).toHaveStyle({ transform: 'scale(1) translate(0px, 0px)' }) + }) + }) + + it('should update position while dragging when zoomed in and stop dragging on mouseup', async () => { + const user = userEvent.setup() + render( + , + ) + + const overlay = getOverlay() + const image = screen.getByRole('img', { name: 'Preview Image' }) as HTMLImageElement + const imageParent = image.parentElement + if (!imageParent) + throw new Error('Image parent element not found') + + vi.spyOn(image, 'getBoundingClientRect').mockReturnValue({ + width: 200, + height: 120, + top: 0, + left: 0, + bottom: 120, + right: 200, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect) + vi.spyOn(imageParent, 'getBoundingClientRect').mockReturnValue({ + width: 100, + height: 100, + top: 0, + left: 0, + bottom: 100, + right: 100, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect) + + const zoomInButton = getZoomInButton() + await user.click(zoomInButton) + + act(() => { + overlay.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: 10, clientY: 10 })) + overlay.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 40, clientY: 30 })) + }) + + await waitFor(() => { + expect(image.style.transition).toBe('none') + }) + expect(image.style.transform).toContain('translate(') + + act(() => { + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })) + }) + await waitFor(() => { + expect(image.style.transition).toContain('transform 0.2s ease-in-out') + }) + }) + }) + + describe('Action Buttons', () => { + it('should open valid url in new tab', async () => { + const user = userEvent.setup() + render( + , + ) + + const openInTabButton = getOpenInTabButton() + await user.click(openInTabButton) + + expect(mocks.windowOpen).toHaveBeenCalledWith('https://example.com/image.png', '_blank') + }) + + it('should open data image by writing to popup window document', async () => { + const user = userEvent.setup() + const write = vi.fn() + mocks.windowOpen.mockReturnValue({ + document: { + write, + }, + } as unknown as Window) + + render( + , + ) + + const openInTabButton = getOpenInTabButton() + await user.click(openInTabButton) + + expect(mocks.windowOpen).toHaveBeenCalledWith() + expect(write).toHaveBeenCalledWith(`Preview Image`) + }) + + it('should show error toast when opening unsupported url', async () => { + const user = userEvent.setup() + render( + , + ) + + const openInTabButton = getOpenInTabButton() + await user.click(openInTabButton) + + expect(mocks.notify).toHaveBeenCalledWith({ + type: 'error', + message: 'Unable to open image: file:///tmp/image.png', + }) + }) + + it('should fall back to download and show info toast when clipboard copy fails', async () => { + const user = userEvent.setup() + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) + mocks.clipboardWrite.mockRejectedValue(new Error('copy failed')) + + render( + , + ) + + const copyButton = getCopyButton() + await user.click(copyButton) + + await waitFor(() => { + expect(mocks.downloadUrl).toHaveBeenCalledWith({ url: dataImage, fileName: 'Preview Image.png' }) + }) + expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'info', + })) + expect(consoleErrorSpy).toHaveBeenCalled() + consoleErrorSpy.mockRestore() + }) + + it('should copy image and show success toast', async () => { + const user = userEvent.setup() + mocks.clipboardWrite.mockResolvedValue() + render( + , + ) + + const copyButton = getCopyButton() + await user.click(copyButton) + + await waitFor(() => { + expect(mocks.clipboardWrite).toHaveBeenCalledTimes(1) + }) + expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + })) + }) + + it('should call download action for valid url', async () => { + const user = userEvent.setup() + render( + , + ) + const downloadButton = getDownloadButton() + await user.click(downloadButton) + + expect(mocks.downloadUrl).toHaveBeenCalledWith({ + url: 'https://example.com/image.png', + fileName: 'Preview Image', + target: '_blank', + }) + }) + + it('should show error toast for invalid download url', async () => { + const user = userEvent.setup() + render( + , + ) + const downloadButton = getDownloadButton() + await user.click(downloadButton) + + expect(mocks.notify).toHaveBeenCalledWith({ + type: 'error', + message: 'Unable to open image: invalid://image.png', + }) + }) + + it('should zoom with dedicated zoom buttons', async () => { + const user = userEvent.setup() + render( + , + ) + const image = screen.getByRole('img', { name: 'Preview Image' }) + + const zoomInButton = getZoomInButton() + const zoomOutButton = getZoomOutButton() + await user.click(zoomInButton) + await waitFor(() => { + expect(image).toHaveStyle({ transform: 'scale(1.2) translate(0px, 0px)' }) + }) + + await user.click(zoomOutButton) + await waitFor(() => { + expect(image).toHaveStyle({ transform: 'scale(1) translate(0px, 0px)' }) + }) + }) + }) +}) diff --git a/web/app/components/base/image-uploader/image-preview.tsx b/web/app/components/base/image-uploader/image-preview.tsx index 54a5fabf9c..2f76b85967 100644 --- a/web/app/components/base/image-uploader/image-preview.tsx +++ b/web/app/components/base/image-uploader/image-preview.tsx @@ -1,5 +1,4 @@ import type { FC } from 'react' -import { RiAddBoxLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' import { t } from 'i18next' import * as React from 'react' @@ -209,6 +208,7 @@ const ImagePreview: FC = ({ transform: `scale(${scale}) translate(${position.x}px, ${position.y}px)`, transition: isDragging ? 'none' : 'transform 0.2s ease-in-out', }} + data-testid="image-preview-image" />
= ({ onClick={imageCopy} > {isCopied - ? - : } + ? + : }
@@ -225,7 +225,7 @@ const ImagePreview: FC = ({ className="absolute right-40 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" onClick={zoomOut} > - + @@ -233,7 +233,7 @@ const ImagePreview: FC = ({ className="absolute right-32 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" onClick={zoomIn} > - + @@ -241,7 +241,7 @@ const ImagePreview: FC = ({ className="absolute right-24 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" onClick={downloadImage} > - + @@ -249,7 +249,7 @@ const ImagePreview: FC = ({ className="absolute right-16 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" onClick={openInNewTab} > - + diff --git a/web/app/components/base/image-uploader/text-generation-image-uploader.spec.tsx b/web/app/components/base/image-uploader/text-generation-image-uploader.spec.tsx new file mode 100644 index 0000000000..5bba9135b7 --- /dev/null +++ b/web/app/components/base/image-uploader/text-generation-image-uploader.spec.tsx @@ -0,0 +1,223 @@ +import type { useLocalFileUploader } from './hooks' +import type { ImageFile, VisionSettings } from '@/types/app' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Resolution, TransferMethod } from '@/types/app' +import TextGenerationImageUploader from './text-generation-image-uploader' + +type LocalUploaderArgs = Parameters[0] + +const mocks = vi.hoisted(() => ({ + files: [] as ImageFile[], + onUpload: vi.fn<(imageFile: ImageFile) => void>(), + onRemove: vi.fn<(imageFileId: string) => void>(), + onImageLinkLoadError: vi.fn<(imageFileId: string) => void>(), + onImageLinkLoadSuccess: vi.fn<(imageFileId: string) => void>(), + onReUpload: vi.fn<(imageFileId: string) => void>(), + handleLocalFileUpload: vi.fn<(file: File) => void>(), + localUploaderArgs: undefined as LocalUploaderArgs | undefined, +})) + +vi.mock('./hooks', () => ({ + useImageFiles: () => ({ + files: mocks.files, + onUpload: mocks.onUpload, + onRemove: mocks.onRemove, + onImageLinkLoadError: mocks.onImageLinkLoadError, + onImageLinkLoadSuccess: mocks.onImageLinkLoadSuccess, + onReUpload: mocks.onReUpload, + }), + useLocalFileUploader: (args: LocalUploaderArgs) => { + mocks.localUploaderArgs = args + return { + handleLocalFileUpload: mocks.handleLocalFileUpload, + } + }, +})) + +const createSettings = (overrides: Partial = {}): VisionSettings => ({ + enabled: true, + number_limits: 3, + detail: Resolution.high, + transfer_methods: [TransferMethod.local_file], + image_file_size_limit: 10, + ...overrides, +}) + +describe('TextGenerationImageUploader', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.files = [] + mocks.localUploaderArgs = undefined + }) + + describe('Rendering', () => { + it('should render local upload action for local_file transfer method', () => { + const onFilesChange = vi.fn() + const settings = createSettings({ + transfer_methods: [TransferMethod.local_file], + }) + + render() + + expect(screen.getByText('common.imageUploader.uploadFromComputer')).toBeInTheDocument() + expect(screen.queryByText('common.imageUploader.pasteImageLink')).not.toBeInTheDocument() + }) + + it('should render URL upload action for remote_url transfer method', () => { + const settings = createSettings({ + transfer_methods: [TransferMethod.remote_url], + }) + + render() + + expect(screen.getByText('common.imageUploader.pasteImageLink')).toBeInTheDocument() + expect(screen.queryByText('common.imageUploader.uploadFromComputer')).not.toBeInTheDocument() + }) + + it('should render two-column grid when two transfer methods are enabled', () => { + const settings = createSettings({ + transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url], + }) + render() + + const grid = screen.getByTestId('upload-actions') + expect(grid).toHaveClass('grid-cols-2') + }) + + it('should render single-column grid when one transfer method is enabled', () => { + const settings = createSettings({ + transfer_methods: [TransferMethod.local_file], + }) + render() + + const grid = screen.getByTestId('upload-actions') + expect(grid).toHaveClass('grid-cols-1') + }) + + it('should render no upload action for unsupported transfer method value', () => { + const settings = createSettings({ + transfer_methods: [TransferMethod.all], + }) + + render() + + expect(screen.queryByText('common.imageUploader.uploadFromComputer')).not.toBeInTheDocument() + expect(screen.queryByText('common.imageUploader.pasteImageLink')).not.toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should pass numeric image size limit to local uploader hook', () => { + const settings = createSettings({ + image_file_size_limit: '15', + transfer_methods: [TransferMethod.local_file], + }) + + render() + + expect(mocks.localUploaderArgs?.limit).toBe(15) + }) + + it('should disable local uploader when disabled prop is true', () => { + const settings = createSettings({ + transfer_methods: [TransferMethod.local_file], + }) + render( + , + ) + + const fileInput = screen.getByTestId('local-file-input') + expect(fileInput).toBeDisabled() + expect(mocks.localUploaderArgs?.disabled).toBe(true) + }) + + it('should disable upload actions when file count reaches number limit', async () => { + const user = userEvent.setup() + const settings = createSettings({ + number_limits: 1, + transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url], + }) + mocks.files = [{ + type: TransferMethod.remote_url, + _id: 'file-1', + fileId: 'id-1', + progress: 100, + url: 'https://example.com/image.png', + }] + render() + + const fileInput = screen.getByTestId('local-file-input') + expect(fileInput).toBeDisabled() + expect(mocks.localUploaderArgs?.disabled).toBe(true) + + await user.click(screen.getByText('common.imageUploader.pasteImageLink')) + expect(screen.queryByPlaceholderText('common.imageUploader.pasteImageLinkInputPlaceholder')).not.toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call handleLocalFileUpload when a local file is selected', async () => { + const user = userEvent.setup() + const settings = createSettings({ + transfer_methods: [TransferMethod.local_file], + }) + render() + const fileInput = screen.getByTestId('local-file-input') + const file = new File(['content'], 'sample.png', { type: 'image/png' }) + await user.upload(fileInput as HTMLInputElement, file) + + expect(mocks.handleLocalFileUpload).toHaveBeenCalledWith(file) + }) + + it('should open paste link popover and upload remote url', async () => { + const user = userEvent.setup() + const settings = createSettings({ + transfer_methods: [TransferMethod.remote_url], + }) + + render() + + await user.click(screen.getByText('common.imageUploader.pasteImageLink')) + const input = await screen.findByPlaceholderText('common.imageUploader.pasteImageLinkInputPlaceholder') + await user.type(input, 'https://example.com/remote.png') + await user.click(screen.getByRole('button', { name: 'common.operation.ok' })) + + expect(mocks.onUpload).toHaveBeenCalledWith(expect.objectContaining({ + type: TransferMethod.remote_url, + url: 'https://example.com/remote.png', + progress: 0, + })) + + await waitFor(() => { + expect(screen.queryByPlaceholderText('common.imageUploader.pasteImageLinkInputPlaceholder')).not.toBeInTheDocument() + }) + }) + }) + + describe('Files Effect', () => { + it('should call onFilesChange when files value changes', () => { + const onFilesChange = vi.fn() + const settings = createSettings() + + const { rerender } = render() + expect(onFilesChange).toHaveBeenCalledWith([]) + + const updatedFiles: ImageFile[] = [{ + type: TransferMethod.remote_url, + _id: 'new-file', + fileId: '', + progress: 0, + url: 'https://example.com/new.png', + }] + mocks.files = updatedFiles + rerender() + + expect(onFilesChange).toHaveBeenCalledWith(updatedFiles) + }) + }) +}) diff --git a/web/app/components/base/image-uploader/text-generation-image-uploader.tsx b/web/app/components/base/image-uploader/text-generation-image-uploader.tsx index 569ff559a2..1b986744f2 100644 --- a/web/app/components/base/image-uploader/text-generation-image-uploader.tsx +++ b/web/app/components/base/image-uploader/text-generation-image-uploader.tsx @@ -132,7 +132,7 @@ const TextGenerationImageUploader: FC = ({ onImageLinkLoadSuccess={onImageLinkLoadSuccess} /> -
+
{ settings.transfer_methods.map((method) => { if (method === TransferMethod.local_file) diff --git a/web/app/components/base/image-uploader/uploader.spec.tsx b/web/app/components/base/image-uploader/uploader.spec.tsx new file mode 100644 index 0000000000..7fd916a497 --- /dev/null +++ b/web/app/components/base/image-uploader/uploader.spec.tsx @@ -0,0 +1,154 @@ +import type { ComponentProps } from 'react' +import type { useLocalFileUploader } from './hooks' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ALLOW_FILE_EXTENSIONS } from '@/types/app' +import Uploader from './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 { + handleLocalFileUpload: mocks.handleLocalFileUpload, + } + }, +})) + +const getInput = () => { + const input = screen.getByTestId('local-file-input') + return input as HTMLInputElement +} + +const renderUploader = (props: Partial> = {}) => { + const onUpload = vi.fn() + const closePopover = vi.fn() + const childRenderer = vi.fn((hovering: boolean) => ( +
{hovering ? 'hovering' : 'idle'}
+ )) + + const result = render( + + {childRenderer} + , + ) + + return { + ...result, + onUpload, + closePopover, + childRenderer, + } +} + +describe('Uploader', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.hookArgs = undefined + }) + + describe('Rendering', () => { + it('should render file input and idle child content', () => { + renderUploader() + const input = getInput() + + expect(screen.getByTestId('hover-state')).toHaveTextContent('idle') + expect(input).toBeInTheDocument() + }) + + it('should set accept attribute from allowed file extensions', () => { + renderUploader() + const input = getInput() + const expectedAccept = ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',') + + expect(input).toHaveAttribute('accept', expectedAccept) + }) + + it('should pass hook arguments to useLocalFileUploader', () => { + const { onUpload } = renderUploader({ limit: 5, disabled: true }) + + expect(mocks.hookArgs).toMatchObject({ + limit: 5, + disabled: true, + }) + expect(mocks.hookArgs?.onUpload).toBe(onUpload) + }) + }) + + describe('User Interactions', () => { + it('should update hovering state on mouse enter and leave', async () => { + const user = userEvent.setup() + renderUploader() + const input = getInput() + + expect(screen.getByTestId('hover-state')).toHaveTextContent('idle') + + await user.hover(input) + expect(screen.getByTestId('hover-state')).toHaveTextContent('hovering') + + await user.unhover(input) + expect(screen.getByTestId('hover-state')).toHaveTextContent('idle') + }) + + it('should call handleLocalFileUpload and closePopover when file is selected', async () => { + const user = userEvent.setup() + const { closePopover } = renderUploader() + const input = getInput() + const file = new File(['hello'], 'demo.png', { type: 'image/png' }) + + await user.upload(input, file) + + expect(mocks.handleLocalFileUpload).toHaveBeenCalledWith(file) + expect(closePopover).toHaveBeenCalledTimes(1) + }) + + it('should reset input value on click', async () => { + const user = userEvent.setup() + renderUploader() + const input = getInput() + const file = new File(['hello'], 'demo.png', { type: 'image/png' }) + + await user.upload(input, file) + expect(input.files).toHaveLength(1) + + await user.click(input) + + expect(input.value).toBe('') + }) + + it('should not upload or close popover when no file is selected', () => { + const { closePopover } = renderUploader() + const input = getInput() + + Object.defineProperty(input, 'files', { + value: [] as unknown as FileList, + configurable: true, + }) + input.dispatchEvent(new Event('change', { bubbles: true })) + + expect(mocks.handleLocalFileUpload).not.toHaveBeenCalled() + expect(closePopover).not.toHaveBeenCalled() + }) + }) + + describe('Props', () => { + it('should disable file input when disabled prop is true', () => { + renderUploader({ disabled: true }) + const input = getInput() + + expect(input).toBeDisabled() + }) + }) +}) diff --git a/web/app/components/base/image-uploader/uploader.tsx b/web/app/components/base/image-uploader/uploader.tsx index a02b804c48..c4dd292320 100644 --- a/web/app/components/base/image-uploader/uploader.tsx +++ b/web/app/components/base/image-uploader/uploader.tsx @@ -44,6 +44,7 @@ const Uploader: FC = ({ > {children(hovering)} ((e.target as HTMLInputElement).value = '')} type="file" diff --git a/web/app/components/base/image-uploader/utils.spec.ts b/web/app/components/base/image-uploader/utils.spec.ts new file mode 100644 index 0000000000..dff7fa25c3 --- /dev/null +++ b/web/app/components/base/image-uploader/utils.spec.ts @@ -0,0 +1,134 @@ +import type { TFunction } from 'i18next' +import { waitFor } from '@testing-library/react' +import { upload } from '@/service/base' +import { getImageUploadErrorMessage, imageUpload } from './utils' + +vi.mock('@/service/base', () => ({ + upload: vi.fn(), +})) + +describe('image-uploader utils', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('getImageUploadErrorMessage', () => { + it('should return backend message when error code is forbidden', () => { + const t = vi.fn() as unknown as TFunction + + const result = getImageUploadErrorMessage( + { response: { code: 'forbidden', message: 'Forbidden by policy' } }, + 'Default error', + t, + ) + + expect(result).toBe('Forbidden by policy') + expect(t).not.toHaveBeenCalled() + }) + + it('should return translated message when error code is file_extension_blocked', () => { + const t = vi.fn(() => 'common.fileUploader.fileExtensionBlocked') as unknown as TFunction + + const result = getImageUploadErrorMessage( + { response: { code: 'file_extension_blocked' } }, + 'Default error', + t, + ) + + expect(result).toBe('common.fileUploader.fileExtensionBlocked') + expect(t).toHaveBeenCalledWith('fileUploader.fileExtensionBlocked', { ns: 'common' }) + }) + + it('should return default message when error code is unknown', () => { + const t = vi.fn() as unknown as TFunction + + const result = getImageUploadErrorMessage( + { response: { code: 'unexpected_error' } }, + 'Default error', + t, + ) + + expect(result).toBe('Default error') + expect(t).not.toHaveBeenCalled() + }) + + it('should return default message when error is missing response code', () => { + const t = vi.fn() as unknown as TFunction + + const result = getImageUploadErrorMessage(undefined, 'Default error', t) + + expect(result).toBe('Default error') + expect(t).not.toHaveBeenCalled() + }) + }) + + describe('imageUpload', () => { + const createCallbacks = () => ({ + onProgressCallback: vi.fn<(progress: number) => void>(), + onSuccessCallback: vi.fn<(res: { id: string }) => void>(), + onErrorCallback: vi.fn<(error?: unknown) => void>(), + }) + + it('should upload file and call success callback', async () => { + const file = new File(['hello'], 'image.png', { type: 'image/png' }) + const callbacks = createCallbacks() + vi.mocked(upload).mockResolvedValue({ id: 'uploaded-id' }) + + imageUpload({ file, ...callbacks }, true, '/files/upload') + + expect(upload).toHaveBeenCalledTimes(1) + + const [options, isPublic, url] = vi.mocked(upload).mock.calls[0] + expect(isPublic).toBe(true) + expect(url).toBe('/files/upload') + expect(options.xhr).toBeInstanceOf(XMLHttpRequest) + expect(options.data).toBeInstanceOf(FormData) + expect((options.data as FormData).get('file')).toBe(file) + + await waitFor(() => { + expect(callbacks.onSuccessCallback).toHaveBeenCalledWith({ id: 'uploaded-id' }) + }) + expect(callbacks.onErrorCallback).not.toHaveBeenCalled() + }) + + it('should call error callback when upload fails', async () => { + const file = new File(['hello'], 'image.png', { type: 'image/png' }) + const callbacks = createCallbacks() + const error = new Error('Upload failed') + vi.mocked(upload).mockRejectedValue(error) + + imageUpload({ file, ...callbacks }) + + await waitFor(() => { + expect(callbacks.onErrorCallback).toHaveBeenCalledWith(error) + }) + expect(callbacks.onSuccessCallback).not.toHaveBeenCalled() + }) + + it('should report progress percentage when progress is computable', () => { + const file = new File(['hello'], 'image.png', { type: 'image/png' }) + const callbacks = createCallbacks() + vi.mocked(upload).mockImplementation((options: { onprogress?: (e: ProgressEvent) => void }) => { + options.onprogress?.({ lengthComputable: true, loaded: 5, total: 8 } as ProgressEvent) + return Promise.resolve({ id: 'uploaded-id' }) + }) + + imageUpload({ file, ...callbacks }) + + expect(callbacks.onProgressCallback).toHaveBeenCalledWith(62) + }) + + it('should not report progress when length is not computable', () => { + const file = new File(['hello'], 'image.png', { type: 'image/png' }) + const callbacks = createCallbacks() + vi.mocked(upload).mockImplementation((options: { onprogress?: (e: ProgressEvent) => void }) => { + options.onprogress?.({ lengthComputable: false, loaded: 5, total: 8 } as ProgressEvent) + return Promise.resolve({ id: 'uploaded-id' }) + }) + + imageUpload({ file, ...callbacks }) + + expect(callbacks.onProgressCallback).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/base/image-uploader/video-preview.spec.tsx b/web/app/components/base/image-uploader/video-preview.spec.tsx new file mode 100644 index 0000000000..c9501b9059 --- /dev/null +++ b/web/app/components/base/image-uploader/video-preview.spec.tsx @@ -0,0 +1,117 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import VideoPreview from './video-preview' + +const getOverlay = () => screen.getByTestId('video-preview') +const getCloseButton = () => screen.getByTestId('close-button') +describe('VideoPreview', () => { + const defaultProps = { + url: 'https://example.com/video.mp4', + title: 'Test Video', + onCancel: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + + expect(screen.getByTitle('Test Video')).toBeInTheDocument() + }) + + it('should render video element with controls and preload metadata', () => { + render() + + const video = screen.getByTitle('Test Video') + expect(video.tagName).toBe('VIDEO') + expect(video).toHaveAttribute('controls') + expect(video).toHaveAttribute('preload', 'metadata') + expect((video as HTMLVideoElement).autoplay).toBe(false) + }) + + it('should render source element with correct src and type', () => { + render() + + const source = screen.getByTitle('Test Video').querySelector('source') + expect(source).toHaveAttribute('src', 'https://example.com/video.mp4') + expect(source).toHaveAttribute('type', 'video/mp4') + }) + + it('should render close button', () => { + render() + + expect(getCloseButton()).toBeInTheDocument() + }) + + it('should render via portal into document.body', () => { + render() + + const overlay = getOverlay() + expect(overlay).toBeInTheDocument() + expect(overlay.parentElement).toBe(document.body) + }) + }) + + describe('Props', () => { + it('should set video title from title prop', () => { + render() + + expect(screen.getByTitle('Demo Video')).toBeInTheDocument() + }) + + it('should set video source from url prop', () => { + render() + + const source = screen.getByTitle('Test Video').querySelector('source') + expect(source).toHaveAttribute('src', 'https://example.com/demo.mp4') + }) + }) + + describe('User Interactions', () => { + it('should call onCancel when close button is clicked', async () => { + const user = userEvent.setup() + const onCancel = vi.fn() + + render() + + const closeButton = getCloseButton() + await user.click(closeButton) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('should not call onCancel when overlay is clicked', async () => { + const user = userEvent.setup() + const onCancel = vi.fn() + render() + + const overlay = getOverlay() + await user.click(overlay) + + expect(onCancel).not.toHaveBeenCalled() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty url', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) + render() + + const source = screen.getByTestId('video-element').querySelector('source') + expect(source).not.toHaveAttribute('src') + + consoleErrorSpy.mockRestore() + }) + + it('should handle empty title', () => { + render() + + const video = screen.getByTestId('video-element') + expect(video).toBeInTheDocument() + expect(video).toHaveAttribute('title', '') + }) + }) +}) diff --git a/web/app/components/base/image-uploader/video-preview.tsx b/web/app/components/base/image-uploader/video-preview.tsx index 59a439e6c8..95882e00f5 100644 --- a/web/app/components/base/image-uploader/video-preview.tsx +++ b/web/app/components/base/image-uploader/video-preview.tsx @@ -1,5 +1,4 @@ import type { FC } from 'react' -import { RiCloseLine } from '@remixicon/react' import { createPortal } from 'react-dom' type VideoPreviewProps = { @@ -13,9 +12,9 @@ const VideoPreview: FC = ({ onCancel, }) => { return createPortal( -
e.stopPropagation()}> +
e.stopPropagation()} data-testid="video-preview">
-
, document.body,