+
-
+
)
}
@@ -118,10 +115,7 @@ const FileItem = ({
}
{
uploadError && (
- onReUpload?.(id)}
- />
+ onReUpload?.(id)} data-testid="replay-icon" role="button" tabIndex={0} />
)
}
diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-list.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-list.spec.tsx
new file mode 100644
index 0000000000..cae64eb6cb
--- /dev/null
+++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-list.spec.tsx
@@ -0,0 +1,137 @@
+import type { FileEntity } from '../types'
+import type { FileUpload } from '@/app/components/base/features/types'
+import { render, screen } from '@testing-library/react'
+import { TransferMethod } from '@/types/app'
+import { FileContextProvider } from '../store'
+import { FileList, FileListInChatInput } from './file-list'
+
+vi.mock('../hooks', () => ({
+ useFile: () => ({
+ handleRemoveFile: vi.fn(),
+ handleReUploadFile: vi.fn(),
+ }),
+}))
+
+vi.mock('@/utils/format', () => ({
+ formatFileSize: (size: number) => `${size}B`,
+}))
+
+vi.mock('@/utils/download', () => ({
+ downloadUrl: vi.fn(),
+}))
+
+const createFile = (overrides: Partial
= {}): FileEntity => ({
+ id: `file-${Math.random()}`,
+ name: 'document.pdf',
+ size: 1024,
+ type: 'application/pdf',
+ progress: 100,
+ transferMethod: TransferMethod.local_file,
+ supportFileType: 'document',
+ ...overrides,
+})
+
+describe('FileList', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render FileImageItem for image files', () => {
+ const files = [createFile({
+ name: 'photo.png',
+ type: 'image/png',
+ supportFileType: 'image',
+ base64Url: 'data:image/png;base64,abc',
+ })]
+ render()
+
+ expect(screen.getByRole('img')).toBeInTheDocument()
+ })
+
+ it('should render FileItem for non-image files', () => {
+ const files = [createFile({
+ name: 'document.pdf',
+ supportFileType: 'document',
+ })]
+ render()
+
+ expect(screen.getByText(/document\.pdf/i)).toBeInTheDocument()
+ })
+
+ it('should render both image and non-image files', () => {
+ const files = [
+ createFile({
+ name: 'photo.png',
+ type: 'image/png',
+ supportFileType: 'image',
+ base64Url: 'data:image/png;base64,abc',
+ }),
+ createFile({ name: 'doc.pdf', supportFileType: 'document' }),
+ ]
+ render()
+
+ expect(screen.getByRole('img')).toBeInTheDocument()
+ expect(screen.getByText(/doc\.pdf/i)).toBeInTheDocument()
+ })
+
+ it('should render empty list when no files', () => {
+ const { container } = render()
+
+ expect(container.firstChild).toBeInTheDocument()
+ expect(screen.queryAllByRole('img')).toHaveLength(0)
+ })
+
+ it('should apply custom className', () => {
+ const { container } = render()
+
+ expect(container.firstChild).toHaveClass('custom-class')
+ })
+
+ it('should render multiple files', () => {
+ const files = [
+ createFile({ name: 'a.pdf' }),
+ createFile({ name: 'b.pdf' }),
+ createFile({ name: 'c.pdf' }),
+ ]
+ render()
+
+ expect(screen.getByText(/a\.pdf/i)).toBeInTheDocument()
+ expect(screen.getByText(/b\.pdf/i)).toBeInTheDocument()
+ expect(screen.getByText(/c\.pdf/i)).toBeInTheDocument()
+ })
+})
+
+describe('FileListInChatInput', () => {
+ let mockStoreFiles: FileEntity[] = []
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockStoreFiles = []
+ })
+
+ it('should render FileList with files from store', () => {
+ mockStoreFiles = [createFile({ name: 'test.pdf' })]
+ const fileConfig = { enabled: true, allowed_file_types: ['document'] } as FileUpload
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText(/test\.pdf/i)).toBeInTheDocument()
+ })
+
+ it('should render empty FileList when store has no files', () => {
+ const fileConfig = { enabled: true, allowed_file_types: ['document'] } as FileUpload
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.queryAllByRole('img')).toHaveLength(0)
+ expect(screen.queryByText(/\.pdf/i)).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.spec.tsx
new file mode 100644
index 0000000000..0cdde4835d
--- /dev/null
+++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.spec.tsx
@@ -0,0 +1,101 @@
+import type { FileUpload } from '@/app/components/base/features/types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { FileContextProvider } from '../store'
+import FileUploaderInChatInput from './index'
+
+vi.mock('@/types/app', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ TransferMethod: {
+ local_file: 'local_file',
+ remote_url: 'remote_url',
+ },
+ }
+})
+
+vi.mock('../hooks', () => ({
+ useFile: () => ({
+ handleLoadFileFromLink: vi.fn(),
+ }),
+}))
+
+function renderWithProvider(ui: React.ReactElement) {
+ return render(
+
+ {ui}
+ ,
+ )
+}
+
+const createFileConfig = (overrides: Partial = {}): FileUpload => ({
+ enabled: true,
+ allowed_file_types: ['image'],
+ allowed_file_upload_methods: ['local_file', 'remote_url'],
+ allowed_file_extensions: [],
+ number_limits: 5,
+ ...overrides,
+} as unknown as FileUpload)
+
+describe('FileUploaderInChatInput', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render an attachment icon SVG', () => {
+ renderWithProvider()
+
+ const button = screen.getByRole('button')
+ expect(button.querySelector('svg')).toBeInTheDocument()
+ })
+
+ it('should render FileFromLinkOrLocal when not readonly', () => {
+ renderWithProvider()
+
+ const button = screen.getByRole('button')
+ expect(button).toBeInTheDocument()
+ expect(button).not.toBeDisabled()
+ })
+
+ it('should render only the trigger button when readonly', () => {
+ renderWithProvider()
+
+ const button = screen.getByRole('button')
+ expect(button).toBeDisabled()
+ })
+
+ it('should render button with attachment icon for local_file upload method', () => {
+ renderWithProvider(
+ )}
+ />,
+ )
+
+ const button = screen.getByRole('button')
+ expect(button).toBeInTheDocument()
+ expect(button.querySelector('svg')).toBeInTheDocument()
+ })
+
+ it('should render button with attachment icon for remote_url upload method', () => {
+ renderWithProvider(
+ )}
+ />,
+ )
+
+ const button = screen.getByRole('button')
+ expect(button).toBeInTheDocument()
+ expect(button.querySelector('svg')).toBeInTheDocument()
+ })
+
+ it('should apply open state styling when trigger is activated', () => {
+ renderWithProvider()
+
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ expect(button).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/base/file-uploader/hooks.spec.ts b/web/app/components/base/file-uploader/hooks.spec.ts
new file mode 100644
index 0000000000..5577b87649
--- /dev/null
+++ b/web/app/components/base/file-uploader/hooks.spec.ts
@@ -0,0 +1,867 @@
+import type { FileEntity } from './types'
+import type { FileUpload } from '@/app/components/base/features/types'
+import type { FileUploadConfigResponse } from '@/models/common'
+import { act, renderHook } from '@testing-library/react'
+import { useFile, useFileSizeLimit } from './hooks'
+
+const mockNotify = vi.fn()
+
+vi.mock('next/navigation', () => ({
+ useParams: () => ({ token: undefined }),
+}))
+
+// Exception: hook requires toast context that isn't available without a provider wrapper
+vi.mock('@/app/components/base/toast', () => ({
+ useToastContext: () => ({
+ notify: mockNotify,
+ }),
+}))
+
+const mockSetFiles = vi.fn()
+let mockStoreFiles: FileEntity[] = []
+vi.mock('./store', () => ({
+ useFileStore: () => ({
+ getState: () => ({
+ files: mockStoreFiles,
+ setFiles: mockSetFiles,
+ }),
+ }),
+}))
+
+const mockFileUpload = vi.fn()
+const mockIsAllowedFileExtension = vi.fn().mockReturnValue(true)
+const mockGetSupportFileType = vi.fn().mockReturnValue('document')
+vi.mock('./utils', () => ({
+ fileUpload: (...args: unknown[]) => mockFileUpload(...args),
+ getFileUploadErrorMessage: vi.fn().mockReturnValue('Upload error'),
+ getSupportFileType: (...args: unknown[]) => mockGetSupportFileType(...args),
+ isAllowedFileExtension: (...args: unknown[]) => mockIsAllowedFileExtension(...args),
+}))
+
+const mockUploadRemoteFileInfo = vi.fn()
+vi.mock('@/service/common', () => ({
+ uploadRemoteFileInfo: (...args: unknown[]) => mockUploadRemoteFileInfo(...args),
+}))
+
+vi.mock('uuid', () => ({
+ v4: () => 'mock-uuid',
+}))
+
+describe('useFileSizeLimit', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should return default limits when no config is provided', () => {
+ const { result } = renderHook(() => useFileSizeLimit())
+
+ expect(result.current.imgSizeLimit).toBe(10 * 1024 * 1024)
+ expect(result.current.docSizeLimit).toBe(15 * 1024 * 1024)
+ expect(result.current.audioSizeLimit).toBe(50 * 1024 * 1024)
+ expect(result.current.videoSizeLimit).toBe(100 * 1024 * 1024)
+ expect(result.current.maxFileUploadLimit).toBe(10)
+ })
+
+ it('should use config values when provided', () => {
+ const config: FileUploadConfigResponse = {
+ image_file_size_limit: 20,
+ file_size_limit: 30,
+ audio_file_size_limit: 100,
+ video_file_size_limit: 200,
+ workflow_file_upload_limit: 20,
+ } as FileUploadConfigResponse
+
+ const { result } = renderHook(() => useFileSizeLimit(config))
+
+ expect(result.current.imgSizeLimit).toBe(20 * 1024 * 1024)
+ expect(result.current.docSizeLimit).toBe(30 * 1024 * 1024)
+ expect(result.current.audioSizeLimit).toBe(100 * 1024 * 1024)
+ expect(result.current.videoSizeLimit).toBe(200 * 1024 * 1024)
+ expect(result.current.maxFileUploadLimit).toBe(20)
+ })
+
+ it('should fall back to defaults when config values are zero', () => {
+ const config = {
+ image_file_size_limit: 0,
+ file_size_limit: 0,
+ audio_file_size_limit: 0,
+ video_file_size_limit: 0,
+ workflow_file_upload_limit: 0,
+ } as FileUploadConfigResponse
+
+ const { result } = renderHook(() => useFileSizeLimit(config))
+
+ expect(result.current.imgSizeLimit).toBe(10 * 1024 * 1024)
+ expect(result.current.docSizeLimit).toBe(15 * 1024 * 1024)
+ expect(result.current.audioSizeLimit).toBe(50 * 1024 * 1024)
+ expect(result.current.videoSizeLimit).toBe(100 * 1024 * 1024)
+ expect(result.current.maxFileUploadLimit).toBe(10)
+ })
+})
+
+describe('useFile', () => {
+ const defaultFileConfig: FileUpload = {
+ enabled: true,
+ allowed_file_types: ['image', 'document'],
+ allowed_file_extensions: [],
+ number_limits: 5,
+ } as FileUpload
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockStoreFiles = []
+ mockIsAllowedFileExtension.mockReturnValue(true)
+ mockGetSupportFileType.mockReturnValue('document')
+ })
+
+ it('should return all file handler functions', () => {
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+
+ expect(result.current.handleAddFile).toBeDefined()
+ expect(result.current.handleUpdateFile).toBeDefined()
+ expect(result.current.handleRemoveFile).toBeDefined()
+ expect(result.current.handleReUploadFile).toBeDefined()
+ expect(result.current.handleLoadFileFromLink).toBeDefined()
+ expect(result.current.handleLoadFileFromLinkSuccess).toBeDefined()
+ expect(result.current.handleLoadFileFromLinkError).toBeDefined()
+ expect(result.current.handleClearFiles).toBeDefined()
+ expect(result.current.handleLocalFileUpload).toBeDefined()
+ expect(result.current.handleClipboardPasteFile).toBeDefined()
+ expect(result.current.handleDragFileEnter).toBeDefined()
+ expect(result.current.handleDragFileOver).toBeDefined()
+ expect(result.current.handleDragFileLeave).toBeDefined()
+ expect(result.current.handleDropFile).toBeDefined()
+ expect(result.current.isDragActive).toBe(false)
+ })
+
+ it('should add a file via handleAddFile', () => {
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+
+ result.current.handleAddFile({
+ id: 'test-id',
+ name: 'test.txt',
+ type: 'text/plain',
+ size: 100,
+ progress: 0,
+ transferMethod: 'local_file',
+ supportFileType: 'document',
+ } as FileEntity)
+ expect(mockSetFiles).toHaveBeenCalled()
+ })
+
+ it('should update a file via handleUpdateFile', () => {
+ mockStoreFiles = [{ id: 'file-1', name: 'a.txt', progress: 0 }] as FileEntity[]
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+
+ result.current.handleUpdateFile({ id: 'file-1', name: 'a.txt', progress: 50 } as FileEntity)
+ expect(mockSetFiles).toHaveBeenCalled()
+ })
+
+ it('should not update file when id is not found', () => {
+ mockStoreFiles = [{ id: 'file-1', name: 'a.txt' }] as FileEntity[]
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+
+ result.current.handleUpdateFile({ id: 'nonexistent' } as FileEntity)
+ expect(mockSetFiles).toHaveBeenCalled()
+ })
+
+ it('should remove a file via handleRemoveFile', () => {
+ mockStoreFiles = [{ id: 'file-1', name: 'a.txt' }] as FileEntity[]
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+
+ result.current.handleRemoveFile('file-1')
+ expect(mockSetFiles).toHaveBeenCalled()
+ })
+
+ it('should clear all files via handleClearFiles', () => {
+ mockStoreFiles = [{ id: 'a' }] as FileEntity[]
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+
+ result.current.handleClearFiles()
+ expect(mockSetFiles).toHaveBeenCalledWith([])
+ })
+
+ describe('handleReUploadFile', () => {
+ it('should re-upload a file and call fileUpload', () => {
+ const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' })
+ mockStoreFiles = [{
+ id: 'file-1',
+ name: 'test.txt',
+ type: 'text/plain',
+ size: 100,
+ progress: -1,
+ transferMethod: 'local_file',
+ supportFileType: 'document',
+ originalFile,
+ }] as FileEntity[]
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+
+ result.current.handleReUploadFile('file-1')
+ expect(mockSetFiles).toHaveBeenCalled()
+ expect(mockFileUpload).toHaveBeenCalled()
+ })
+
+ it('should not re-upload when file id is not found', () => {
+ mockStoreFiles = []
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+
+ result.current.handleReUploadFile('nonexistent')
+ expect(mockFileUpload).not.toHaveBeenCalled()
+ })
+
+ it('should handle progress callback during re-upload', () => {
+ const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' })
+ mockStoreFiles = [{
+ id: 'file-1',
+ name: 'test.txt',
+ type: 'text/plain',
+ size: 100,
+ progress: -1,
+ transferMethod: 'local_file',
+ supportFileType: 'document',
+ originalFile,
+ }] as FileEntity[]
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleReUploadFile('file-1')
+
+ const uploadCall = mockFileUpload.mock.calls[0][0]
+ uploadCall.onProgressCallback(50)
+ expect(mockSetFiles).toHaveBeenCalled()
+ })
+
+ it('should handle success callback during re-upload', () => {
+ const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' })
+ mockStoreFiles = [{
+ id: 'file-1',
+ name: 'test.txt',
+ type: 'text/plain',
+ size: 100,
+ progress: -1,
+ transferMethod: 'local_file',
+ supportFileType: 'document',
+ originalFile,
+ }] as FileEntity[]
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleReUploadFile('file-1')
+
+ const uploadCall = mockFileUpload.mock.calls[0][0]
+ uploadCall.onSuccessCallback({ id: 'uploaded-1' })
+ expect(mockSetFiles).toHaveBeenCalled()
+ })
+
+ it('should handle error callback during re-upload', () => {
+ const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' })
+ mockStoreFiles = [{
+ id: 'file-1',
+ name: 'test.txt',
+ type: 'text/plain',
+ size: 100,
+ progress: -1,
+ transferMethod: 'local_file',
+ supportFileType: 'document',
+ originalFile,
+ }] as FileEntity[]
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleReUploadFile('file-1')
+
+ const uploadCall = mockFileUpload.mock.calls[0][0]
+ uploadCall.onErrorCallback(new Error('fail'))
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
+ })
+ })
+
+ describe('handleLoadFileFromLink', () => {
+ it('should run startProgressTimer to increment file progress', () => {
+ vi.useFakeTimers()
+ mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {})) // never resolves
+
+ // Set up a file in the store that has progress 0
+ mockStoreFiles = [{
+ id: 'mock-uuid',
+ name: 'https://example.com/file.txt',
+ type: '',
+ size: 0,
+ progress: 0,
+ transferMethod: 'remote_url',
+ supportFileType: '',
+ }] as FileEntity[]
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLoadFileFromLink('https://example.com/file.txt')
+
+ // Advance timer to trigger the interval
+ vi.advanceTimersByTime(200)
+ expect(mockSetFiles).toHaveBeenCalled()
+
+ vi.useRealTimers()
+ })
+
+ it('should add file and call uploadRemoteFileInfo', () => {
+ mockUploadRemoteFileInfo.mockResolvedValue({
+ id: 'remote-1',
+ mime_type: 'text/plain',
+ size: 100,
+ name: 'remote.txt',
+ url: 'https://example.com/remote.txt',
+ })
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLoadFileFromLink('https://example.com/file.txt')
+
+ expect(mockSetFiles).toHaveBeenCalled()
+ expect(mockUploadRemoteFileInfo).toHaveBeenCalledWith('https://example.com/file.txt', false)
+ })
+
+ it('should remove file when extension is not allowed', async () => {
+ mockIsAllowedFileExtension.mockReturnValue(false)
+ mockUploadRemoteFileInfo.mockResolvedValue({
+ id: 'remote-1',
+ mime_type: 'text/plain',
+ size: 100,
+ name: 'remote.txt',
+ url: 'https://example.com/remote.txt',
+ })
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ await act(async () => {
+ result.current.handleLoadFileFromLink('https://example.com/file.txt')
+ await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled())
+ })
+
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
+ })
+
+ it('should use empty arrays when allowed_file_types and allowed_file_extensions are undefined', async () => {
+ mockIsAllowedFileExtension.mockReturnValue(false)
+ mockUploadRemoteFileInfo.mockResolvedValue({
+ id: 'remote-1',
+ mime_type: 'text/plain',
+ size: 100,
+ name: 'remote.txt',
+ url: 'https://example.com/remote.txt',
+ })
+
+ const configWithUndefined = {
+ ...defaultFileConfig,
+ allowed_file_types: undefined,
+ allowed_file_extensions: undefined,
+ } as unknown as FileUpload
+
+ const { result } = renderHook(() => useFile(configWithUndefined))
+ await act(async () => {
+ result.current.handleLoadFileFromLink('https://example.com/file.txt')
+ await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled())
+ })
+
+ expect(mockIsAllowedFileExtension).toHaveBeenCalledWith('remote.txt', 'text/plain', [], [])
+ })
+
+ it('should remove file when remote upload fails', async () => {
+ mockUploadRemoteFileInfo.mockRejectedValue(new Error('network error'))
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ await act(async () => {
+ result.current.handleLoadFileFromLink('https://example.com/file.txt')
+ await vi.waitFor(() => expect(mockNotify).toHaveBeenCalled())
+ })
+
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
+ })
+
+ it('should remove file when size limit is exceeded on remote upload', async () => {
+ mockGetSupportFileType.mockReturnValue('image')
+ mockUploadRemoteFileInfo.mockResolvedValue({
+ id: 'remote-1',
+ mime_type: 'image/png',
+ size: 20 * 1024 * 1024,
+ name: 'large.png',
+ url: 'https://example.com/large.png',
+ })
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ await act(async () => {
+ result.current.handleLoadFileFromLink('https://example.com/large.png')
+ await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled())
+ })
+
+ // File should be removed because image exceeds 10MB limit
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
+ })
+
+ it('should update file on successful remote upload within limits', async () => {
+ mockUploadRemoteFileInfo.mockResolvedValue({
+ id: 'remote-1',
+ mime_type: 'text/plain',
+ size: 100,
+ name: 'remote.txt',
+ url: 'https://example.com/remote.txt',
+ })
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ await act(async () => {
+ result.current.handleLoadFileFromLink('https://example.com/remote.txt')
+ await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled())
+ })
+
+ // setFiles should be called: once for add, once for update
+ expect(mockSetFiles).toHaveBeenCalled()
+ })
+
+ it('should stop progress timer when file reaches 80 percent', () => {
+ vi.useFakeTimers()
+ mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {}))
+
+ // Set up a file already at 80% progress
+ mockStoreFiles = [{
+ id: 'mock-uuid',
+ name: 'https://example.com/file.txt',
+ type: '',
+ size: 0,
+ progress: 80,
+ transferMethod: 'remote_url',
+ supportFileType: '',
+ }] as FileEntity[]
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLoadFileFromLink('https://example.com/file.txt')
+
+ // At progress 80, the timer should stop (clearTimeout path)
+ vi.advanceTimersByTime(200)
+
+ vi.useRealTimers()
+ })
+
+ it('should stop progress timer when progress is negative', () => {
+ vi.useFakeTimers()
+ mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {}))
+
+ // Set up a file with negative progress (error state)
+ mockStoreFiles = [{
+ id: 'mock-uuid',
+ name: 'https://example.com/file.txt',
+ type: '',
+ size: 0,
+ progress: -1,
+ transferMethod: 'remote_url',
+ supportFileType: '',
+ }] as FileEntity[]
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLoadFileFromLink('https://example.com/file.txt')
+
+ vi.advanceTimersByTime(200)
+
+ vi.useRealTimers()
+ })
+ })
+
+ describe('handleLocalFileUpload', () => {
+ let capturedListeners: Record void)[]>
+ let mockReaderResult: string | null
+
+ beforeEach(() => {
+ capturedListeners = {}
+ mockReaderResult = 'data:text/plain;base64,Y29udGVudA=='
+
+ class MockFileReader {
+ result: string | null = null
+ addEventListener(event: string, handler: () => void) {
+ if (!capturedListeners[event])
+ capturedListeners[event] = []
+ capturedListeners[event].push(handler)
+ }
+
+ readAsDataURL() {
+ this.result = mockReaderResult
+ capturedListeners.load?.forEach(handler => handler())
+ }
+ }
+ vi.stubGlobal('FileReader', MockFileReader)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('should upload a local file', () => {
+ const file = new File(['content'], 'test.txt', { type: 'text/plain' })
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLocalFileUpload(file)
+
+ expect(mockSetFiles).toHaveBeenCalled()
+ })
+
+ it('should reject file with unsupported extension', () => {
+ mockIsAllowedFileExtension.mockReturnValue(false)
+ const file = new File(['content'], 'test.xyz', { type: 'application/xyz' })
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLocalFileUpload(file)
+
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
+ expect(mockSetFiles).not.toHaveBeenCalled()
+ })
+
+ it('should use empty arrays when allowed_file_types and allowed_file_extensions are undefined', () => {
+ mockIsAllowedFileExtension.mockReturnValue(false)
+ const file = new File(['content'], 'test.xyz', { type: 'application/xyz' })
+
+ const configWithUndefined = {
+ ...defaultFileConfig,
+ allowed_file_types: undefined,
+ allowed_file_extensions: undefined,
+ } as unknown as FileUpload
+
+ const { result } = renderHook(() => useFile(configWithUndefined))
+ result.current.handleLocalFileUpload(file)
+
+ expect(mockIsAllowedFileExtension).toHaveBeenCalledWith('test.xyz', 'application/xyz', [], [])
+ })
+
+ it('should reject file when upload is disabled and noNeedToCheckEnable is false', () => {
+ const disabledConfig = { ...defaultFileConfig, enabled: false } as FileUpload
+ const file = new File(['content'], 'test.txt', { type: 'text/plain' })
+
+ const { result } = renderHook(() => useFile(disabledConfig, false))
+ result.current.handleLocalFileUpload(file)
+
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
+ })
+
+ it('should reject image file exceeding size limit', () => {
+ mockGetSupportFileType.mockReturnValue('image')
+ const largeFile = new File([new ArrayBuffer(20 * 1024 * 1024)], 'large.png', { type: 'image/png' })
+ Object.defineProperty(largeFile, 'size', { value: 20 * 1024 * 1024 })
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLocalFileUpload(largeFile)
+
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
+ })
+
+ it('should reject audio file exceeding size limit', () => {
+ mockGetSupportFileType.mockReturnValue('audio')
+ const largeFile = new File([], 'large.mp3', { type: 'audio/mpeg' })
+ Object.defineProperty(largeFile, 'size', { value: 60 * 1024 * 1024 })
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLocalFileUpload(largeFile)
+
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
+ })
+
+ it('should reject video file exceeding size limit', () => {
+ mockGetSupportFileType.mockReturnValue('video')
+ const largeFile = new File([], 'large.mp4', { type: 'video/mp4' })
+ Object.defineProperty(largeFile, 'size', { value: 200 * 1024 * 1024 })
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLocalFileUpload(largeFile)
+
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
+ })
+
+ it('should reject document file exceeding size limit', () => {
+ mockGetSupportFileType.mockReturnValue('document')
+ const largeFile = new File([], 'large.pdf', { type: 'application/pdf' })
+ Object.defineProperty(largeFile, 'size', { value: 20 * 1024 * 1024 })
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLocalFileUpload(largeFile)
+
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
+ })
+
+ it('should reject custom file exceeding document size limit', () => {
+ mockGetSupportFileType.mockReturnValue('custom')
+ const largeFile = new File([], 'large.xyz', { type: 'application/octet-stream' })
+ Object.defineProperty(largeFile, 'size', { value: 20 * 1024 * 1024 })
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLocalFileUpload(largeFile)
+
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
+ })
+
+ it('should allow custom file within document size limit', () => {
+ mockGetSupportFileType.mockReturnValue('custom')
+ const file = new File(['content'], 'file.xyz', { type: 'application/octet-stream' })
+ Object.defineProperty(file, 'size', { value: 1024 })
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLocalFileUpload(file)
+
+ expect(mockNotify).not.toHaveBeenCalled()
+ expect(mockSetFiles).toHaveBeenCalled()
+ })
+
+ it('should allow document file within size limit', () => {
+ mockGetSupportFileType.mockReturnValue('document')
+ const file = new File(['content'], 'small.pdf', { type: 'application/pdf' })
+ Object.defineProperty(file, 'size', { value: 1024 })
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLocalFileUpload(file)
+
+ expect(mockNotify).not.toHaveBeenCalled()
+ expect(mockSetFiles).toHaveBeenCalled()
+ })
+
+ it('should allow file with unknown type (default case)', () => {
+ mockGetSupportFileType.mockReturnValue('unknown')
+ const file = new File(['content'], 'test.bin', { type: 'application/octet-stream' })
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLocalFileUpload(file)
+
+ // Should not be rejected - unknown type passes checkSizeLimit
+ expect(mockNotify).not.toHaveBeenCalled()
+ })
+
+ it('should allow image file within size limit', () => {
+ mockGetSupportFileType.mockReturnValue('image')
+ const file = new File(['content'], 'small.png', { type: 'image/png' })
+ Object.defineProperty(file, 'size', { value: 1024 })
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLocalFileUpload(file)
+
+ expect(mockNotify).not.toHaveBeenCalled()
+ expect(mockSetFiles).toHaveBeenCalled()
+ })
+
+ it('should allow audio file within size limit', () => {
+ mockGetSupportFileType.mockReturnValue('audio')
+ const file = new File(['content'], 'small.mp3', { type: 'audio/mpeg' })
+ Object.defineProperty(file, 'size', { value: 1024 })
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLocalFileUpload(file)
+
+ expect(mockNotify).not.toHaveBeenCalled()
+ expect(mockSetFiles).toHaveBeenCalled()
+ })
+
+ it('should allow video file within size limit', () => {
+ mockGetSupportFileType.mockReturnValue('video')
+ const file = new File(['content'], 'small.mp4', { type: 'video/mp4' })
+ Object.defineProperty(file, 'size', { value: 1024 })
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLocalFileUpload(file)
+
+ expect(mockNotify).not.toHaveBeenCalled()
+ expect(mockSetFiles).toHaveBeenCalled()
+ })
+
+ it('should set base64Url for image files during upload', () => {
+ mockGetSupportFileType.mockReturnValue('image')
+ const file = new File(['content'], 'photo.png', { type: 'image/png' })
+ Object.defineProperty(file, 'size', { value: 1024 })
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLocalFileUpload(file)
+
+ expect(mockSetFiles).toHaveBeenCalled()
+ // The file should have been added with base64Url set (for image type)
+ const addedFiles = mockSetFiles.mock.calls[0][0]
+ expect(addedFiles[0].base64Url).toBe('data:text/plain;base64,Y29udGVudA==')
+ })
+
+ it('should set empty base64Url for non-image files during upload', () => {
+ mockGetSupportFileType.mockReturnValue('document')
+ const file = new File(['content'], 'doc.pdf', { type: 'application/pdf' })
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLocalFileUpload(file)
+
+ expect(mockSetFiles).toHaveBeenCalled()
+ const addedFiles = mockSetFiles.mock.calls[0][0]
+ expect(addedFiles[0].base64Url).toBe('')
+ })
+
+ it('should call fileUpload with callbacks after FileReader loads', () => {
+ const file = new File(['content'], 'test.txt', { type: 'text/plain' })
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLocalFileUpload(file)
+
+ expect(mockFileUpload).toHaveBeenCalled()
+ const uploadCall = mockFileUpload.mock.calls[0][0]
+
+ // Test progress callback
+ uploadCall.onProgressCallback(50)
+ expect(mockSetFiles).toHaveBeenCalled()
+
+ // Test success callback
+ uploadCall.onSuccessCallback({ id: 'uploaded-1' })
+ expect(mockSetFiles).toHaveBeenCalled()
+ })
+
+ it('should handle fileUpload error callback', () => {
+ const file = new File(['content'], 'test.txt', { type: 'text/plain' })
+
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLocalFileUpload(file)
+
+ const uploadCall = mockFileUpload.mock.calls[0][0]
+ uploadCall.onErrorCallback(new Error('upload failed'))
+
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
+ })
+
+ it('should handle FileReader error event', () => {
+ capturedListeners = {}
+ const errorListeners: (() => void)[] = []
+
+ class ErrorFileReader {
+ result: string | null = null
+ addEventListener(event: string, handler: () => void) {
+ if (event === 'error')
+ errorListeners.push(handler)
+ if (!capturedListeners[event])
+ capturedListeners[event] = []
+ capturedListeners[event].push(handler)
+ }
+
+ readAsDataURL() {
+ // Simulate error instead of load
+ errorListeners.forEach(handler => handler())
+ }
+ }
+ vi.stubGlobal('FileReader', ErrorFileReader)
+
+ const file = new File(['content'], 'test.txt', { type: 'text/plain' })
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+ result.current.handleLocalFileUpload(file)
+
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
+ })
+ })
+
+ describe('handleClipboardPasteFile', () => {
+ it('should handle file paste from clipboard', () => {
+ const file = new File(['content'], 'pasted.png', { type: 'image/png' })
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+
+ const event = {
+ clipboardData: {
+ files: [file],
+ getData: () => '',
+ },
+ preventDefault: vi.fn(),
+ } as unknown as React.ClipboardEvent
+
+ result.current.handleClipboardPasteFile(event)
+ expect(event.preventDefault).toHaveBeenCalled()
+ })
+
+ it('should not handle paste when text is present', () => {
+ const file = new File(['content'], 'pasted.png', { type: 'image/png' })
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+
+ const event = {
+ clipboardData: {
+ files: [file],
+ getData: () => 'some text',
+ },
+ preventDefault: vi.fn(),
+ } as unknown as React.ClipboardEvent
+
+ result.current.handleClipboardPasteFile(event)
+ expect(event.preventDefault).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('drag and drop handlers', () => {
+ it('should set isDragActive on drag enter', () => {
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+
+ const event = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent
+ act(() => {
+ result.current.handleDragFileEnter(event)
+ })
+
+ expect(result.current.isDragActive).toBe(true)
+ })
+
+ it('should call preventDefault on drag over', () => {
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+
+ const event = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent
+ result.current.handleDragFileOver(event)
+
+ expect(event.preventDefault).toHaveBeenCalled()
+ })
+
+ it('should unset isDragActive on drag leave', () => {
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+
+ const enterEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent
+ act(() => {
+ result.current.handleDragFileEnter(enterEvent)
+ })
+ expect(result.current.isDragActive).toBe(true)
+
+ const leaveEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent
+ act(() => {
+ result.current.handleDragFileLeave(leaveEvent)
+ })
+ expect(result.current.isDragActive).toBe(false)
+ })
+
+ it('should handle file drop', () => {
+ const file = new File(['content'], 'dropped.txt', { type: 'text/plain' })
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+
+ const event = {
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ dataTransfer: { files: [file] },
+ } as unknown as React.DragEvent
+
+ act(() => {
+ result.current.handleDropFile(event)
+ })
+
+ expect(event.preventDefault).toHaveBeenCalled()
+ expect(result.current.isDragActive).toBe(false)
+ })
+
+ it('should not upload when no file is dropped', () => {
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+
+ const event = {
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ dataTransfer: { files: [] },
+ } as unknown as React.DragEvent
+
+ act(() => {
+ result.current.handleDropFile(event)
+ })
+
+ // No file upload should be triggered
+ expect(mockSetFiles).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('noop handlers', () => {
+ it('should have handleLoadFileFromLinkSuccess as noop', () => {
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+
+ expect(() => result.current.handleLoadFileFromLinkSuccess()).not.toThrow()
+ })
+
+ it('should have handleLoadFileFromLinkError as noop', () => {
+ const { result } = renderHook(() => useFile(defaultFileConfig))
+
+ expect(() => result.current.handleLoadFileFromLinkError()).not.toThrow()
+ })
+ })
+})
diff --git a/web/app/components/base/file-uploader/pdf-highlighter-adapter.tsx b/web/app/components/base/file-uploader/pdf-highlighter-adapter.tsx
new file mode 100644
index 0000000000..c2fb780ca8
--- /dev/null
+++ b/web/app/components/base/file-uploader/pdf-highlighter-adapter.tsx
@@ -0,0 +1,7 @@
+import { PdfHighlighter, PdfLoader } from 'react-pdf-highlighter'
+import 'react-pdf-highlighter/dist/style.css'
+
+export {
+ PdfHighlighter,
+ PdfLoader,
+}
diff --git a/web/app/components/base/file-uploader/pdf-preview.spec.tsx b/web/app/components/base/file-uploader/pdf-preview.spec.tsx
new file mode 100644
index 0000000000..df07a592ef
--- /dev/null
+++ b/web/app/components/base/file-uploader/pdf-preview.spec.tsx
@@ -0,0 +1,142 @@
+import type { ReactNode } from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import PdfPreview from './pdf-preview'
+
+vi.mock('./pdf-highlighter-adapter', () => ({
+ PdfLoader: ({ children, beforeLoad }: { children: (doc: unknown) => ReactNode, beforeLoad: ReactNode }) => (
+
+ {beforeLoad}
+ {children({ numPages: 1 })}
+
+ ),
+ PdfHighlighter: ({ enableAreaSelection, highlightTransform, scrollRef, onScrollChange, onSelectionFinished }: {
+ enableAreaSelection?: (event: MouseEvent) => boolean
+ highlightTransform?: () => ReactNode
+ scrollRef?: (ref: unknown) => void
+ onScrollChange?: () => void
+ onSelectionFinished?: () => unknown
+ }) => {
+ enableAreaSelection?.(new MouseEvent('click'))
+ highlightTransform?.()
+ scrollRef?.(null)
+ onScrollChange?.()
+ onSelectionFinished?.()
+ return
+ },
+}))
+
+describe('PdfPreview', () => {
+ const mockOnCancel = vi.fn()
+
+ const getScaleContainer = () => {
+ const container = document.querySelector('div[style*="transform"]') as HTMLDivElement | null
+ expect(container).toBeInTheDocument()
+ return container!
+ }
+
+ const getControl = (rightClass: 'right-24' | 'right-16' | 'right-6') => {
+ const control = document.querySelector(`div.absolute.${rightClass}.top-6`) as HTMLDivElement | null
+ expect(control).toBeInTheDocument()
+ return control!
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ window.innerWidth = 1024
+ fireEvent(window, new Event('resize'))
+ })
+
+ it('should render the pdf preview portal with overlay and loading indicator', () => {
+ render()
+
+ expect(document.querySelector('[tabindex="-1"]')).toBeInTheDocument()
+ expect(screen.getByTestId('pdf-loader')).toBeInTheDocument()
+ expect(screen.getByTestId('pdf-highlighter')).toBeInTheDocument()
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ })
+
+ it('should render zoom in, zoom out, and close icon SVGs', () => {
+ render()
+
+ const svgs = document.querySelectorAll('svg')
+ expect(svgs.length).toBeGreaterThanOrEqual(3)
+ })
+
+ it('should zoom in when zoom in control is clicked', () => {
+ render()
+
+ fireEvent.click(getControl('right-16'))
+
+ expect(getScaleContainer().getAttribute('style')).toContain('scale(1.2)')
+ })
+
+ it('should zoom out when zoom out control is clicked', () => {
+ render()
+
+ fireEvent.click(getControl('right-24'))
+
+ expect(getScaleContainer().getAttribute('style')).toMatch(/scale\(0\.8333/)
+ })
+
+ it('should keep non-1 scale when zooming out from a larger scale', () => {
+ render()
+
+ fireEvent.click(getControl('right-16'))
+ fireEvent.click(getControl('right-16'))
+ fireEvent.click(getControl('right-24'))
+
+ expect(getScaleContainer().getAttribute('style')).toContain('scale(1.2)')
+ })
+
+ it('should reset scale back to 1 when zooming in then out', () => {
+ render()
+
+ fireEvent.click(getControl('right-16'))
+ fireEvent.click(getControl('right-24'))
+
+ expect(getScaleContainer().getAttribute('style')).toContain('scale(1)')
+ })
+
+ it('should zoom in when ArrowUp key is pressed', () => {
+ render()
+
+ fireEvent.keyDown(document, { key: 'ArrowUp', code: 'ArrowUp' })
+
+ expect(getScaleContainer().getAttribute('style')).toContain('scale(1.2)')
+ })
+
+ it('should zoom out when ArrowDown key is pressed', () => {
+ render()
+
+ fireEvent.keyDown(document, { key: 'ArrowDown', code: 'ArrowDown' })
+
+ expect(getScaleContainer().getAttribute('style')).toMatch(/scale\(0\.8333/)
+ })
+
+ it('should call onCancel when close control is clicked', () => {
+ render()
+
+ fireEvent.click(getControl('right-6'))
+
+ expect(mockOnCancel).toHaveBeenCalled()
+ })
+
+ it('should call onCancel when Escape key is pressed', () => {
+ render()
+
+ fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
+
+ expect(mockOnCancel).toHaveBeenCalled()
+ })
+
+ it('should render the overlay and stop click propagation', () => {
+ render()
+
+ const overlay = document.querySelector('[tabindex="-1"]')
+ expect(overlay).toBeInTheDocument()
+ const event = new MouseEvent('click', { bubbles: true })
+ const stopPropagation = vi.spyOn(event, 'stopPropagation')
+ overlay!.dispatchEvent(event)
+ expect(stopPropagation).toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/base/file-uploader/pdf-preview.tsx b/web/app/components/base/file-uploader/pdf-preview.tsx
index aab8bcd9d1..32b2528cf8 100644
--- a/web/app/components/base/file-uploader/pdf-preview.tsx
+++ b/web/app/components/base/file-uploader/pdf-preview.tsx
@@ -6,11 +6,10 @@ import * as React from 'react'
import { useState } from 'react'
import { createPortal } from 'react-dom'
import { useHotkeys } from 'react-hotkeys-hook'
-import { PdfHighlighter, PdfLoader } from 'react-pdf-highlighter'
import Loading from '@/app/components/base/loading'
import Tooltip from '@/app/components/base/tooltip'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
-import 'react-pdf-highlighter/dist/style.css'
+import { PdfHighlighter, PdfLoader } from './pdf-highlighter-adapter'
type PdfPreviewProps = {
url: string
diff --git a/web/app/components/base/file-uploader/store.spec.tsx b/web/app/components/base/file-uploader/store.spec.tsx
new file mode 100644
index 0000000000..96053498d9
--- /dev/null
+++ b/web/app/components/base/file-uploader/store.spec.tsx
@@ -0,0 +1,168 @@
+import type { FileEntity } from './types'
+import { render, renderHook, screen } from '@testing-library/react'
+import { TransferMethod } from '@/types/app'
+import { createFileStore, FileContext, FileContextProvider, useFileStore, useStore } from './store'
+
+const createMockFile = (overrides: Partial = {}): FileEntity => ({
+ id: 'file-1',
+ name: 'test.txt',
+ size: 1024,
+ type: 'text/plain',
+ progress: 100,
+ transferMethod: TransferMethod.local_file,
+ supportFileType: 'document',
+ ...overrides,
+})
+
+describe('createFileStore', () => {
+ it('should create a store with empty files by default', () => {
+ const store = createFileStore()
+ expect(store.getState().files).toEqual([])
+ })
+
+ it('should create a store with empty array when value is falsy', () => {
+ const store = createFileStore(undefined)
+ expect(store.getState().files).toEqual([])
+ })
+
+ it('should create a store with initial files', () => {
+ const files = [createMockFile()]
+ const store = createFileStore(files)
+ expect(store.getState().files).toEqual(files)
+ })
+
+ it('should spread initial value to create a new array', () => {
+ const files = [createMockFile()]
+ const store = createFileStore(files)
+ expect(store.getState().files).not.toBe(files)
+ expect(store.getState().files).toEqual(files)
+ })
+
+ it('should update files via setFiles', () => {
+ const store = createFileStore()
+ const newFiles = [createMockFile()]
+ store.getState().setFiles(newFiles)
+ expect(store.getState().files).toEqual(newFiles)
+ })
+
+ it('should call onChange when setFiles is called', () => {
+ const onChange = vi.fn()
+ const store = createFileStore([], onChange)
+ const newFiles = [createMockFile()]
+ store.getState().setFiles(newFiles)
+ expect(onChange).toHaveBeenCalledWith(newFiles)
+ })
+
+ it('should not throw when onChange is not provided', () => {
+ const store = createFileStore()
+ expect(() => store.getState().setFiles([])).not.toThrow()
+ })
+})
+
+describe('useStore', () => {
+ it('should return selected state from the store', () => {
+ const files = [createMockFile()]
+ const store = createFileStore(files)
+
+ const { result } = renderHook(() => useStore(s => s.files), {
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ })
+
+ expect(result.current).toEqual(files)
+ })
+
+ it('should throw when used without FileContext.Provider', () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ expect(() => {
+ renderHook(() => useStore(s => s.files))
+ }).toThrow('Missing FileContext.Provider in the tree')
+
+ consoleError.mockRestore()
+ })
+})
+
+describe('useFileStore', () => {
+ it('should return the store from context', () => {
+ const store = createFileStore()
+
+ const { result } = renderHook(() => useFileStore(), {
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ })
+
+ expect(result.current).toBe(store)
+ })
+})
+
+describe('FileContextProvider', () => {
+ it('should render children', () => {
+ render(
+
+ Hello
+ ,
+ )
+
+ expect(screen.getByTestId('child')).toBeInTheDocument()
+ })
+
+ it('should provide a store to children', () => {
+ const TestChild = () => {
+ const files = useStore(s => s.files)
+ return {files.length}
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByTestId('files')).toHaveTextContent('0')
+ })
+
+ it('should initialize store with value prop', () => {
+ const files = [createMockFile()]
+ const TestChild = () => {
+ const storeFiles = useStore(s => s.files)
+ return {storeFiles.length}
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByTestId('files')).toHaveTextContent('1')
+ })
+
+ it('should reuse store on re-render instead of creating a new one', () => {
+ const TestChild = () => {
+ const storeFiles = useStore(s => s.files)
+ return {storeFiles.length}
+ }
+
+ const { rerender } = render(
+
+
+ ,
+ )
+
+ expect(screen.getByTestId('files')).toHaveTextContent('0')
+
+ // Re-render with new value prop - store should be reused (storeRef.current exists)
+ rerender(
+
+
+ ,
+ )
+
+ // Store was created once on first render, so the value prop change won't create a new store
+ // The files count should still be 0 since storeRef.current is already set
+ expect(screen.getByTestId('files')).toHaveTextContent('0')
+ })
+})
diff --git a/web/app/components/base/file-uploader/utils.spec.ts b/web/app/components/base/file-uploader/utils.spec.ts
index f69b3c27f5..358fc586eb 100644
--- a/web/app/components/base/file-uploader/utils.spec.ts
+++ b/web/app/components/base/file-uploader/utils.spec.ts
@@ -1,4 +1,4 @@
-import mime from 'mime'
+import type { FileEntity } from './types'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { upload } from '@/service/base'
import { TransferMethod } from '@/types/app'
@@ -11,6 +11,7 @@ import {
getFileExtension,
getFileNameFromUrl,
getFilesInLogs,
+ getFileUploadErrorMessage,
getProcessedFiles,
getProcessedFilesFromResponse,
getSupportFileExtensionList,
@@ -18,23 +19,40 @@ import {
isAllowedFileExtension,
} from './utils'
-vi.mock('mime', () => ({
- default: {
- getAllExtensions: vi.fn(),
- },
-}))
-
vi.mock('@/service/base', () => ({
upload: vi.fn(),
}))
describe('file-uploader utils', () => {
beforeEach(() => {
- vi.clearAllMocks()
+ vi.resetAllMocks()
+ })
+
+ describe('getFileUploadErrorMessage', () => {
+ const createMockT = () => vi.fn().mockImplementation((key: string) => key) as unknown as import('i18next').TFunction
+
+ it('should return forbidden message when error code is forbidden', () => {
+ const error = { response: { code: 'forbidden', message: 'Access denied' } }
+ expect(getFileUploadErrorMessage(error, 'default', createMockT())).toBe('Access denied')
+ })
+
+ it('should return file_extension_blocked translation when error code matches', () => {
+ const error = { response: { code: 'file_extension_blocked' } }
+ expect(getFileUploadErrorMessage(error, 'default', createMockT())).toBe('fileUploader.fileExtensionBlocked')
+ })
+
+ it('should return default message for other errors', () => {
+ const error = { response: { code: 'unknown_error' } }
+ expect(getFileUploadErrorMessage(error, 'Upload failed', createMockT())).toBe('Upload failed')
+ })
+
+ it('should return default message when error has no response', () => {
+ expect(getFileUploadErrorMessage(null, 'Upload failed', createMockT())).toBe('Upload failed')
+ })
})
describe('fileUpload', () => {
- it('should handle successful file upload', () => {
+ it('should handle successful file upload', async () => {
const mockFile = new File(['test'], 'test.txt')
const mockCallbacks = {
onProgressCallback: vi.fn(),
@@ -50,32 +68,102 @@ describe('file-uploader utils', () => {
})
expect(upload).toHaveBeenCalled()
+
+ // Wait for the promise to resolve and call onSuccessCallback
+ await vi.waitFor(() => {
+ expect(mockCallbacks.onSuccessCallback).toHaveBeenCalledWith({ id: '123' })
+ })
+ })
+
+ it('should call onErrorCallback when upload fails', async () => {
+ const mockFile = new File(['test'], 'test.txt')
+ const mockCallbacks = {
+ onProgressCallback: vi.fn(),
+ onSuccessCallback: vi.fn(),
+ onErrorCallback: vi.fn(),
+ }
+
+ const uploadError = new Error('Upload failed')
+ vi.mocked(upload).mockRejectedValue(uploadError)
+
+ fileUpload({
+ file: mockFile,
+ ...mockCallbacks,
+ })
+
+ await vi.waitFor(() => {
+ expect(mockCallbacks.onErrorCallback).toHaveBeenCalledWith(uploadError)
+ })
+ })
+
+ it('should call onProgressCallback when progress event is computable', () => {
+ const mockFile = new File(['test'], 'test.txt')
+ const mockCallbacks = {
+ onProgressCallback: vi.fn(),
+ onSuccessCallback: vi.fn(),
+ onErrorCallback: vi.fn(),
+ }
+
+ vi.mocked(upload).mockImplementation(({ onprogress }) => {
+ // Simulate a progress event
+ if (onprogress)
+ onprogress.call({} as XMLHttpRequest, { lengthComputable: true, loaded: 50, total: 100 } as ProgressEvent)
+
+ return Promise.resolve({ id: '123' })
+ })
+
+ fileUpload({
+ file: mockFile,
+ ...mockCallbacks,
+ })
+
+ expect(mockCallbacks.onProgressCallback).toHaveBeenCalledWith(50)
+ })
+
+ it('should not call onProgressCallback when progress event is not computable', () => {
+ const mockFile = new File(['test'], 'test.txt')
+ const mockCallbacks = {
+ onProgressCallback: vi.fn(),
+ onSuccessCallback: vi.fn(),
+ onErrorCallback: vi.fn(),
+ }
+
+ vi.mocked(upload).mockImplementation(({ onprogress }) => {
+ if (onprogress)
+ onprogress.call({} as XMLHttpRequest, { lengthComputable: false, loaded: 0, total: 0 } as ProgressEvent)
+
+ return Promise.resolve({ id: '123' })
+ })
+
+ fileUpload({
+ file: mockFile,
+ ...mockCallbacks,
+ })
+
+ expect(mockCallbacks.onProgressCallback).not.toHaveBeenCalled()
})
})
describe('getFileExtension', () => {
it('should get extension from mimetype', () => {
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf']))
expect(getFileExtension('file', 'application/pdf')).toBe('pdf')
})
- it('should get extension from mimetype and file name 1', () => {
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf']))
+ it('should get extension from mimetype and file name', () => {
expect(getFileExtension('file.pdf', 'application/pdf')).toBe('pdf')
})
it('should get extension from mimetype with multiple ext candidates with filename hint', () => {
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['der', 'crt', 'pem']))
expect(getFileExtension('file.pem', 'application/x-x509-ca-cert')).toBe('pem')
})
it('should get extension from mimetype with multiple ext candidates without filename hint', () => {
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['der', 'crt', 'pem']))
- expect(getFileExtension('file', 'application/x-x509-ca-cert')).toBe('der')
+ const ext = getFileExtension('file', 'application/x-x509-ca-cert')
+ // mime returns Set(['der', 'crt', 'pem']), first value is used when no filename hint
+ expect(['der', 'crt', 'pem']).toContain(ext)
})
- it('should get extension from filename if mimetype fails', () => {
- vi.mocked(mime.getAllExtensions).mockReturnValue(null)
+ it('should get extension from filename when mimetype is empty', () => {
expect(getFileExtension('file.txt', '')).toBe('txt')
expect(getFileExtension('file.txt.docx', '')).toBe('docx')
expect(getFileExtension('file', '')).toBe('')
@@ -84,164 +172,123 @@ describe('file-uploader utils', () => {
it('should return empty string for remote files', () => {
expect(getFileExtension('file.txt', '', true)).toBe('')
})
+
+ it('should fall back to filename extension for unknown mimetype', () => {
+ expect(getFileExtension('file.txt', 'application/unknown')).toBe('txt')
+ })
+
+ it('should return empty string for unknown mimetype without filename extension', () => {
+ expect(getFileExtension('file', 'application/unknown')).toBe('')
+ })
})
describe('getFileAppearanceType', () => {
it('should identify gif files', () => {
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['gif']))
expect(getFileAppearanceType('image.gif', 'image/gif'))
.toBe(FileAppearanceTypeEnum.gif)
})
it('should identify image files', () => {
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['jpg']))
expect(getFileAppearanceType('image.jpg', 'image/jpeg'))
.toBe(FileAppearanceTypeEnum.image)
-
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['jpeg']))
expect(getFileAppearanceType('image.jpeg', 'image/jpeg'))
.toBe(FileAppearanceTypeEnum.image)
-
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['png']))
expect(getFileAppearanceType('image.png', 'image/png'))
.toBe(FileAppearanceTypeEnum.image)
-
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['webp']))
expect(getFileAppearanceType('image.webp', 'image/webp'))
.toBe(FileAppearanceTypeEnum.image)
-
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['svg']))
- expect(getFileAppearanceType('image.svg', 'image/svgxml'))
+ expect(getFileAppearanceType('image.svg', 'image/svg+xml'))
.toBe(FileAppearanceTypeEnum.image)
})
it('should identify video files', () => {
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mp4']))
expect(getFileAppearanceType('video.mp4', 'video/mp4'))
.toBe(FileAppearanceTypeEnum.video)
-
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mov']))
expect(getFileAppearanceType('video.mov', 'video/quicktime'))
.toBe(FileAppearanceTypeEnum.video)
-
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mpeg']))
expect(getFileAppearanceType('video.mpeg', 'video/mpeg'))
.toBe(FileAppearanceTypeEnum.video)
-
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['webm']))
- expect(getFileAppearanceType('video.web', 'video/webm'))
+ expect(getFileAppearanceType('video.webm', 'video/webm'))
.toBe(FileAppearanceTypeEnum.video)
})
it('should identify audio files', () => {
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mp3']))
expect(getFileAppearanceType('audio.mp3', 'audio/mpeg'))
.toBe(FileAppearanceTypeEnum.audio)
-
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['m4a']))
expect(getFileAppearanceType('audio.m4a', 'audio/mp4'))
.toBe(FileAppearanceTypeEnum.audio)
-
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['wav']))
- expect(getFileAppearanceType('audio.wav', 'audio/vnd.wav'))
+ expect(getFileAppearanceType('audio.wav', 'audio/wav'))
.toBe(FileAppearanceTypeEnum.audio)
-
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['amr']))
expect(getFileAppearanceType('audio.amr', 'audio/AMR'))
.toBe(FileAppearanceTypeEnum.audio)
-
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mpga']))
expect(getFileAppearanceType('audio.mpga', 'audio/mpeg'))
.toBe(FileAppearanceTypeEnum.audio)
})
it('should identify code files', () => {
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['html']))
expect(getFileAppearanceType('index.html', 'text/html'))
.toBe(FileAppearanceTypeEnum.code)
})
it('should identify PDF files', () => {
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf']))
expect(getFileAppearanceType('doc.pdf', 'application/pdf'))
.toBe(FileAppearanceTypeEnum.pdf)
})
it('should identify markdown files', () => {
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['md']))
expect(getFileAppearanceType('file.md', 'text/markdown'))
.toBe(FileAppearanceTypeEnum.markdown)
-
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['markdown']))
expect(getFileAppearanceType('file.markdown', 'text/markdown'))
.toBe(FileAppearanceTypeEnum.markdown)
-
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mdx']))
expect(getFileAppearanceType('file.mdx', 'text/mdx'))
.toBe(FileAppearanceTypeEnum.markdown)
})
it('should identify excel files', () => {
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['xlsx']))
expect(getFileAppearanceType('doc.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'))
.toBe(FileAppearanceTypeEnum.excel)
-
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['xls']))
expect(getFileAppearanceType('doc.xls', 'application/vnd.ms-excel'))
.toBe(FileAppearanceTypeEnum.excel)
})
it('should identify word files', () => {
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['doc']))
expect(getFileAppearanceType('doc.doc', 'application/msword'))
.toBe(FileAppearanceTypeEnum.word)
-
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['docx']))
expect(getFileAppearanceType('doc.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'))
.toBe(FileAppearanceTypeEnum.word)
})
- it('should identify word files', () => {
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['ppt']))
+ it('should identify ppt files', () => {
expect(getFileAppearanceType('doc.ppt', 'application/vnd.ms-powerpoint'))
.toBe(FileAppearanceTypeEnum.ppt)
-
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pptx']))
expect(getFileAppearanceType('doc.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'))
.toBe(FileAppearanceTypeEnum.ppt)
})
it('should identify document files', () => {
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['txt']))
expect(getFileAppearanceType('file.txt', 'text/plain'))
.toBe(FileAppearanceTypeEnum.document)
-
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['csv']))
expect(getFileAppearanceType('file.csv', 'text/csv'))
.toBe(FileAppearanceTypeEnum.document)
-
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['msg']))
expect(getFileAppearanceType('file.msg', 'application/vnd.ms-outlook'))
.toBe(FileAppearanceTypeEnum.document)
-
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['eml']))
expect(getFileAppearanceType('file.eml', 'message/rfc822'))
.toBe(FileAppearanceTypeEnum.document)
-
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['xml']))
- expect(getFileAppearanceType('file.xml', 'application/rssxml'))
+ expect(getFileAppearanceType('file.xml', 'application/xml'))
.toBe(FileAppearanceTypeEnum.document)
-
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['epub']))
- expect(getFileAppearanceType('file.epub', 'application/epubzip'))
+ expect(getFileAppearanceType('file.epub', 'application/epub+zip'))
.toBe(FileAppearanceTypeEnum.document)
})
- it('should handle null mime extension', () => {
- vi.mocked(mime.getAllExtensions).mockReturnValue(null)
- expect(getFileAppearanceType('file.txt', 'text/plain'))
+ it('should fall back to filename extension for unknown mimetype', () => {
+ expect(getFileAppearanceType('file.txt', 'application/unknown'))
.toBe(FileAppearanceTypeEnum.document)
})
+
+ it('should return custom type for unrecognized extensions', () => {
+ expect(getFileAppearanceType('file.xyz', 'application/xyz'))
+ .toBe(FileAppearanceTypeEnum.custom)
+ })
})
describe('getSupportFileType', () => {
@@ -278,25 +325,70 @@ describe('file-uploader utils', () => {
upload_file_id: '123',
})
})
+
+ it('should fallback to empty string when url is missing', () => {
+ const files = [{
+ id: '123',
+ name: 'test.txt',
+ size: 1024,
+ type: 'text/plain',
+ progress: 100,
+ supportFileType: 'document',
+ transferMethod: TransferMethod.local_file,
+ url: undefined,
+ uploadedId: '123',
+ }] as unknown as FileEntity[]
+
+ const result = getProcessedFiles(files)
+ expect(result[0].url).toBe('')
+ })
+
+ it('should fallback to empty string when uploadedId is missing', () => {
+ const files = [{
+ id: '123',
+ name: 'test.txt',
+ size: 1024,
+ type: 'text/plain',
+ progress: 100,
+ supportFileType: 'document',
+ transferMethod: TransferMethod.local_file,
+ url: 'http://example.com',
+ uploadedId: undefined,
+ }] as unknown as FileEntity[]
+
+ const result = getProcessedFiles(files)
+ expect(result[0].upload_file_id).toBe('')
+ })
+
+ it('should filter out files with progress -1', () => {
+ const files = [
+ {
+ id: '1',
+ name: 'good.txt',
+ progress: 100,
+ supportFileType: 'document',
+ transferMethod: TransferMethod.local_file,
+ url: 'http://example.com',
+ uploadedId: '1',
+ },
+ {
+ id: '2',
+ name: 'bad.txt',
+ progress: -1,
+ supportFileType: 'document',
+ transferMethod: TransferMethod.local_file,
+ url: 'http://example.com',
+ uploadedId: '2',
+ },
+ ] as unknown as FileEntity[]
+
+ const result = getProcessedFiles(files)
+ expect(result).toHaveLength(1)
+ expect(result[0].upload_file_id).toBe('1')
+ })
})
describe('getProcessedFilesFromResponse', () => {
- beforeEach(() => {
- vi.mocked(mime.getAllExtensions).mockImplementation((mimeType: string) => {
- const mimeMap: Record> = {
- 'image/jpeg': new Set(['jpg', 'jpeg']),
- 'image/png': new Set(['png']),
- 'image/gif': new Set(['gif']),
- 'video/mp4': new Set(['mp4']),
- 'audio/mp3': new Set(['mp3']),
- 'application/pdf': new Set(['pdf']),
- 'text/plain': new Set(['txt']),
- 'application/json': new Set(['json']),
- }
- return mimeMap[mimeType] || new Set()
- })
- })
-
it('should process files correctly without type correction', () => {
const files = [{
related_id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9',
@@ -367,7 +459,7 @@ describe('file-uploader utils', () => {
extension: '.mp3',
filename: 'audio.mp3',
size: 1024,
- mime_type: 'audio/mp3',
+ mime_type: 'audio/mpeg',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/audio.mp3',
@@ -415,7 +507,7 @@ describe('file-uploader utils', () => {
expect(result[0].supportFileType).toBe('document')
})
- it('should NOT correct when filename and MIME type both point to wrong type', () => {
+ it('should NOT correct when filename and MIME type both point to same type', () => {
const files = [{
related_id: '123',
extension: '.jpg',
@@ -540,6 +632,11 @@ describe('file-uploader utils', () => {
expect(getFileNameFromUrl('http://example.com/path/file.txt'))
.toBe('file.txt')
})
+
+ it('should return empty string for URL ending with slash', () => {
+ expect(getFileNameFromUrl('http://example.com/path/'))
+ .toBe('')
+ })
})
describe('getSupportFileExtensionList', () => {
@@ -599,7 +696,6 @@ describe('file-uploader utils', () => {
describe('isAllowedFileExtension', () => {
it('should validate allowed file extensions', () => {
- vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf']))
expect(isAllowedFileExtension(
'test.pdf',
'application/pdf',
diff --git a/web/app/components/base/file-uploader/video-preview.spec.tsx b/web/app/components/base/file-uploader/video-preview.spec.tsx
new file mode 100644
index 0000000000..2384281c8e
--- /dev/null
+++ b/web/app/components/base/file-uploader/video-preview.spec.tsx
@@ -0,0 +1,69 @@
+import { fireEvent, render } from '@testing-library/react'
+import VideoPreview from './video-preview'
+
+describe('VideoPreview', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render video element with correct title', () => {
+ render()
+
+ const video = document.querySelector('video')
+ expect(video).toBeInTheDocument()
+ expect(video).toHaveAttribute('title', 'Test Video')
+ })
+
+ it('should render source element with correct src and type', () => {
+ render()
+
+ const source = document.querySelector('source')
+ expect(source).toHaveAttribute('src', 'https://example.com/video.mp4')
+ expect(source).toHaveAttribute('type', 'video/mp4')
+ })
+
+ it('should render close button with icon', () => {
+ const { getByTestId } = render()
+
+ const closeIcon = getByTestId('video-preview-close-btn')
+ expect(closeIcon).toBeInTheDocument()
+ })
+
+ it('should call onCancel when close button is clicked', () => {
+ const onCancel = vi.fn()
+ const { getByTestId } = render()
+
+ const closeIcon = getByTestId('video-preview-close-btn')
+ fireEvent.click(closeIcon.parentElement!)
+
+ expect(onCancel).toHaveBeenCalled()
+ })
+
+ it('should stop propagation when backdrop is clicked', () => {
+ const { baseElement } = render()
+
+ const backdrop = baseElement.querySelector('[tabindex="-1"]')
+ const event = new MouseEvent('click', { bubbles: true })
+ const stopPropagation = vi.spyOn(event, 'stopPropagation')
+ backdrop!.dispatchEvent(event)
+
+ expect(stopPropagation).toHaveBeenCalled()
+ })
+
+ it('should call onCancel when Escape key is pressed', () => {
+ const onCancel = vi.fn()
+
+ render()
+
+ fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
+
+ expect(onCancel).toHaveBeenCalled()
+ })
+
+ it('should render in a portal attached to document.body', () => {
+ render()
+
+ const video = document.querySelector('video')
+ expect(video?.closest('[tabindex="-1"]')?.parentElement).toBe(document.body)
+ })
+})
diff --git a/web/app/components/base/file-uploader/video-preview.tsx b/web/app/components/base/file-uploader/video-preview.tsx
index 94d9a94c58..e328f58770 100644
--- a/web/app/components/base/file-uploader/video-preview.tsx
+++ b/web/app/components/base/file-uploader/video-preview.tsx
@@ -1,5 +1,4 @@
import type { FC } from 'react'
-import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { createPortal } from 'react-dom'
import { useHotkeys } from 'react-hotkeys-hook'
@@ -35,7 +34,7 @@ const VideoPreview: FC = ({
className="absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]"
onClick={onCancel}
>
-
+