From 9819f7d69c660c4870290c230167782c56edeb65 Mon Sep 17 00:00:00 2001 From: Saumya Talwani <68903741+saumyatalwani@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:46:06 +0530 Subject: [PATCH] test: add tests for file-upload components (#32373) Co-authored-by: sahil --- .../base/file-uploader/audio-preview.spec.tsx | 69 ++ .../base/file-uploader/audio-preview.tsx | 3 +- .../base/file-uploader/constants.spec.ts | 71 ++ .../file-from-link-or-local/index.spec.tsx | 173 ++++ .../file-uploader/file-image-render.spec.tsx | 67 ++ .../base/file-uploader/file-input.spec.tsx | 179 ++++ .../file-uploader/file-list-in-log.spec.tsx | 142 +++ .../file-uploader/file-type-icon.spec.tsx | 85 ++ .../file-item.spec.tsx | 407 ++++++++ .../index.spec.tsx | 207 +++++ .../file-image-item.spec.tsx | 246 +++++ .../file-item.spec.tsx | 337 +++++++ .../file-uploader-in-chat-input/file-item.tsx | 20 +- .../file-list.spec.tsx | 137 +++ .../index.spec.tsx | 101 ++ .../base/file-uploader/hooks.spec.ts | 867 ++++++++++++++++++ .../file-uploader/pdf-highlighter-adapter.tsx | 7 + .../base/file-uploader/pdf-preview.spec.tsx | 142 +++ .../base/file-uploader/pdf-preview.tsx | 3 +- .../base/file-uploader/store.spec.tsx | 168 ++++ .../base/file-uploader/utils.spec.ts | 292 ++++-- .../base/file-uploader/video-preview.spec.tsx | 69 ++ .../base/file-uploader/video-preview.tsx | 3 +- .../base/image-uploader/image-preview.tsx | 4 +- web/eslint-suppressions.json | 8 - 25 files changed, 3680 insertions(+), 127 deletions(-) create mode 100644 web/app/components/base/file-uploader/audio-preview.spec.tsx create mode 100644 web/app/components/base/file-uploader/constants.spec.ts create mode 100644 web/app/components/base/file-uploader/file-from-link-or-local/index.spec.tsx create mode 100644 web/app/components/base/file-uploader/file-image-render.spec.tsx create mode 100644 web/app/components/base/file-uploader/file-input.spec.tsx create mode 100644 web/app/components/base/file-uploader/file-list-in-log.spec.tsx create mode 100644 web/app/components/base/file-uploader/file-type-icon.spec.tsx create mode 100644 web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.spec.tsx create mode 100644 web/app/components/base/file-uploader/file-uploader-in-attachment/index.spec.tsx create mode 100644 web/app/components/base/file-uploader/file-uploader-in-chat-input/file-image-item.spec.tsx create mode 100644 web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.spec.tsx create mode 100644 web/app/components/base/file-uploader/file-uploader-in-chat-input/file-list.spec.tsx create mode 100644 web/app/components/base/file-uploader/file-uploader-in-chat-input/index.spec.tsx create mode 100644 web/app/components/base/file-uploader/hooks.spec.ts create mode 100644 web/app/components/base/file-uploader/pdf-highlighter-adapter.tsx create mode 100644 web/app/components/base/file-uploader/pdf-preview.spec.tsx create mode 100644 web/app/components/base/file-uploader/store.spec.tsx create mode 100644 web/app/components/base/file-uploader/video-preview.spec.tsx diff --git a/web/app/components/base/file-uploader/audio-preview.spec.tsx b/web/app/components/base/file-uploader/audio-preview.spec.tsx new file mode 100644 index 0000000000..a2034b202a --- /dev/null +++ b/web/app/components/base/file-uploader/audio-preview.spec.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import AudioPreview from './audio-preview' + +describe('AudioPreview', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render audio element with correct source', () => { + render() + + const audio = document.querySelector('audio') + expect(audio).toBeInTheDocument() + expect(audio).toHaveAttribute('title', 'Test Audio') + }) + + it('should render source element with correct src and type', () => { + render() + + const source = document.querySelector('source') + expect(source).toHaveAttribute('src', 'https://example.com/audio.mp3') + expect(source).toHaveAttribute('type', 'audio/mpeg') + }) + + it('should render close button with icon', () => { + render() + + const closeIcon = screen.getByTestId('close-btn') + expect(closeIcon).toBeInTheDocument() + }) + + it('should call onCancel when close button is clicked', () => { + const onCancel = vi.fn() + render() + + const closeIcon = screen.getByTestId('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 audio = document.querySelector('audio') + expect(audio?.closest('[tabindex="-1"]')?.parentElement).toBe(document.body) + }) +}) diff --git a/web/app/components/base/file-uploader/audio-preview.tsx b/web/app/components/base/file-uploader/audio-preview.tsx index e8be22fc9f..53535359e6 100644 --- a/web/app/components/base/file-uploader/audio-preview.tsx +++ b/web/app/components/base/file-uploader/audio-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' @@ -36,7 +35,7 @@ const AudioPreview: 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} > - + , document.body, diff --git a/web/app/components/base/file-uploader/constants.spec.ts b/web/app/components/base/file-uploader/constants.spec.ts new file mode 100644 index 0000000000..abe44aa842 --- /dev/null +++ b/web/app/components/base/file-uploader/constants.spec.ts @@ -0,0 +1,71 @@ +import { + AUDIO_SIZE_LIMIT, + FILE_SIZE_LIMIT, + FILE_URL_REGEX, + IMG_SIZE_LIMIT, + MAX_FILE_UPLOAD_LIMIT, + VIDEO_SIZE_LIMIT, +} from './constants' + +describe('file-uploader constants', () => { + describe('size limit constants', () => { + it('should set IMG_SIZE_LIMIT to 10 MB', () => { + expect(IMG_SIZE_LIMIT).toBe(10 * 1024 * 1024) + }) + + it('should set FILE_SIZE_LIMIT to 15 MB', () => { + expect(FILE_SIZE_LIMIT).toBe(15 * 1024 * 1024) + }) + + it('should set AUDIO_SIZE_LIMIT to 50 MB', () => { + expect(AUDIO_SIZE_LIMIT).toBe(50 * 1024 * 1024) + }) + + it('should set VIDEO_SIZE_LIMIT to 100 MB', () => { + expect(VIDEO_SIZE_LIMIT).toBe(100 * 1024 * 1024) + }) + + it('should set MAX_FILE_UPLOAD_LIMIT to 10', () => { + expect(MAX_FILE_UPLOAD_LIMIT).toBe(10) + }) + }) + + describe('FILE_URL_REGEX', () => { + it('should match http URLs', () => { + expect(FILE_URL_REGEX.test('http://example.com')).toBe(true) + expect(FILE_URL_REGEX.test('http://example.com/path/file.txt')).toBe(true) + }) + + it('should match https URLs', () => { + expect(FILE_URL_REGEX.test('https://example.com')).toBe(true) + expect(FILE_URL_REGEX.test('https://example.com/path/file.pdf')).toBe(true) + }) + + it('should match ftp URLs', () => { + expect(FILE_URL_REGEX.test('ftp://files.example.com')).toBe(true) + expect(FILE_URL_REGEX.test('ftp://files.example.com/data.csv')).toBe(true) + }) + + it('should reject URLs without a valid protocol', () => { + expect(FILE_URL_REGEX.test('example.com')).toBe(false) + expect(FILE_URL_REGEX.test('www.example.com')).toBe(false) + }) + + it('should reject empty strings', () => { + expect(FILE_URL_REGEX.test('')).toBe(false) + }) + + it('should reject unsupported protocols', () => { + expect(FILE_URL_REGEX.test('file:///local/path')).toBe(false) + expect(FILE_URL_REGEX.test('ssh://host')).toBe(false) + expect(FILE_URL_REGEX.test('data:text/plain;base64,abc')).toBe(false) + }) + + it('should reject partial protocol strings', () => { + expect(FILE_URL_REGEX.test('http:')).toBe(false) + expect(FILE_URL_REGEX.test('http:/')).toBe(false) + expect(FILE_URL_REGEX.test('https:')).toBe(false) + expect(FILE_URL_REGEX.test('ftp:')).toBe(false) + }) + }) +}) diff --git a/web/app/components/base/file-uploader/file-from-link-or-local/index.spec.tsx b/web/app/components/base/file-uploader/file-from-link-or-local/index.spec.tsx new file mode 100644 index 0000000000..5227b9b2b2 --- /dev/null +++ b/web/app/components/base/file-uploader/file-from-link-or-local/index.spec.tsx @@ -0,0 +1,173 @@ +import type { FileEntity } from '../types' +import type { FileUpload } from '@/app/components/base/features/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { FileContextProvider } from '../store' +import FileFromLinkOrLocal from './index' + +let mockFiles: FileEntity[] = [] + +function createStubFile(id: string): FileEntity { + return { id, name: `${id}.txt`, size: 0, type: '', progress: 100, transferMethod: 'local_file' as FileEntity['transferMethod'], supportFileType: 'document' } +} + +const mockHandleLoadFileFromLink = vi.fn() +vi.mock('../hooks', () => ({ + useFile: () => ({ + handleLoadFileFromLink: mockHandleLoadFileFromLink, + }), +})) + +const createFileConfig = (overrides: Partial = {}): FileUpload => ({ + enabled: true, + allowed_file_types: ['image'], + allowed_file_extensions: [], + number_limits: 5, + ...overrides, +} as FileUpload) + +function renderAndOpen(props: Partial> = {}) { + const trigger = props.trigger ?? ((open: boolean) => ) + const result = render( + + + , + ) + fireEvent.click(screen.getByTestId('trigger')) + return result +} + +describe('FileFromLinkOrLocal', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFiles = [] + }) + + it('should render trigger element', () => { + const trigger = (open: boolean) => ( + + ) + render( + + + , + ) + + expect(screen.getByTestId('trigger')).toBeInTheDocument() + }) + + it('should render URL input when showFromLink is true', () => { + renderAndOpen({ showFromLink: true }) + + expect(screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/)).toBeInTheDocument() + }) + + it('should render upload button when showFromLocal is true', () => { + renderAndOpen({ showFromLocal: true }) + + expect(screen.getByText(/fileUploader\.uploadFromComputer/)).toBeInTheDocument() + }) + + it('should render OR divider when both link and local are shown', () => { + renderAndOpen({ showFromLink: true, showFromLocal: true }) + + expect(screen.getByText('OR')).toBeInTheDocument() + }) + + it('should not render OR divider when only link is shown', () => { + renderAndOpen({ showFromLink: true, showFromLocal: false }) + + expect(screen.queryByText('OR')).not.toBeInTheDocument() + }) + + it('should show error when invalid URL is submitted', () => { + renderAndOpen({ showFromLink: true }) + + const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/) + fireEvent.change(input, { target: { value: 'invalid-url' } }) + + const okButton = screen.getByText(/operation\.ok/) + fireEvent.click(okButton) + + expect(screen.getByText(/fileUploader\.pasteFileLinkInvalid/)).toBeInTheDocument() + }) + + it('should clear error when input changes', () => { + renderAndOpen({ showFromLink: true }) + + const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/) + fireEvent.change(input, { target: { value: 'invalid-url' } }) + fireEvent.click(screen.getByText(/operation\.ok/)) + + expect(screen.getByText(/fileUploader\.pasteFileLinkInvalid/)).toBeInTheDocument() + + fireEvent.change(input, { target: { value: 'https://example.com' } }) + expect(screen.queryByText(/fileUploader\.pasteFileLinkInvalid/)).not.toBeInTheDocument() + }) + + it('should disable ok button when url is empty', () => { + renderAndOpen({ showFromLink: true }) + + const okButton = screen.getByText(/operation\.ok/) + expect(okButton.closest('button')).toBeDisabled() + }) + + it('should disable inputs when file limit is reached', () => { + mockFiles = ['1', '2', '3', '4', '5'].map(createStubFile) + renderAndOpen({ fileConfig: createFileConfig({ number_limits: 5 }), showFromLink: true, showFromLocal: true }) + + const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/) + expect(input).toBeDisabled() + }) + + it('should not submit when url is empty', () => { + renderAndOpen({ showFromLink: true }) + + const okButton = screen.getByText(/operation\.ok/) + fireEvent.click(okButton) + + expect(screen.queryByText(/fileUploader\.pasteFileLinkInvalid/)).not.toBeInTheDocument() + }) + + it('should call handleLoadFileFromLink when valid URL is submitted', () => { + renderAndOpen({ showFromLink: true }) + + const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/) + fireEvent.change(input, { target: { value: 'https://example.com/file.pdf' } }) + fireEvent.click(screen.getByText(/operation\.ok/)) + + expect(mockHandleLoadFileFromLink).toHaveBeenCalledWith('https://example.com/file.pdf') + }) + + it('should clear URL input after successful submission', () => { + renderAndOpen({ showFromLink: true }) + + const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/) as HTMLInputElement + fireEvent.change(input, { target: { value: 'https://example.com/file.pdf' } }) + fireEvent.click(screen.getByText(/operation\.ok/)) + + expect(input.value).toBe('') + }) + + it('should toggle open state when trigger is clicked', () => { + const trigger = (open: boolean) => + render( + + + , + ) + + const triggerButton = screen.getByTestId('trigger') + expect(triggerButton).toHaveTextContent('Open') + + fireEvent.click(triggerButton) + + expect(triggerButton).toHaveTextContent('Close') + }) +}) diff --git a/web/app/components/base/file-uploader/file-image-render.spec.tsx b/web/app/components/base/file-uploader/file-image-render.spec.tsx new file mode 100644 index 0000000000..fa85011f5c --- /dev/null +++ b/web/app/components/base/file-uploader/file-image-render.spec.tsx @@ -0,0 +1,67 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import FileImageRender from './file-image-render' + +describe('FileImageRender', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render an image with the given URL', () => { + render() + + const img = screen.getByRole('img') + expect(img).toHaveAttribute('src', 'https://example.com/image.png') + }) + + it('should use default alt text when alt is not provided', () => { + render() + + expect(screen.getByAltText('Preview')).toBeInTheDocument() + }) + + it('should use custom alt text when provided', () => { + render() + + expect(screen.getByAltText('Custom alt')).toBeInTheDocument() + }) + + it('should apply custom className to container', () => { + const { container } = render( + , + ) + + expect(container.firstChild).toHaveClass('custom-class') + }) + + it('should call onLoad when image loads', () => { + const onLoad = vi.fn() + render() + + fireEvent.load(screen.getByRole('img')) + + expect(onLoad).toHaveBeenCalled() + }) + + it('should call onError when image fails to load', () => { + const onError = vi.fn() + render() + + fireEvent.error(screen.getByRole('img')) + + expect(onError).toHaveBeenCalled() + }) + + it('should add cursor-pointer to image when showDownloadAction is true', () => { + render() + + const img = screen.getByRole('img') + expect(img).toHaveClass('cursor-pointer') + }) + + it('should not add cursor-pointer when showDownloadAction is false', () => { + render() + + const img = screen.getByRole('img') + expect(img).not.toHaveClass('cursor-pointer') + }) +}) diff --git a/web/app/components/base/file-uploader/file-input.spec.tsx b/web/app/components/base/file-uploader/file-input.spec.tsx new file mode 100644 index 0000000000..73c7690e29 --- /dev/null +++ b/web/app/components/base/file-uploader/file-input.spec.tsx @@ -0,0 +1,179 @@ +import type { FileEntity } from './types' +import type { FileUpload } from '@/app/components/base/features/types' +import { fireEvent, render } from '@testing-library/react' +import FileInput from './file-input' +import { FileContextProvider } from './store' + +const mockHandleLocalFileUpload = vi.fn() + +vi.mock('./hooks', () => ({ + useFile: () => ({ + handleLocalFileUpload: mockHandleLocalFileUpload, + }), +})) + +const createFileConfig = (overrides: Partial = {}): FileUpload => ({ + enabled: true, + allowed_file_types: ['image'], + allowed_file_extensions: [], + number_limits: 5, + ...overrides, +} as FileUpload) + +function createStubFile(id: string): FileEntity { + return { id, name: `${id}.txt`, size: 0, type: '', progress: 100, transferMethod: 'local_file' as FileEntity['transferMethod'], supportFileType: 'document' } +} + +function renderWithProvider(ui: React.ReactElement, fileIds: string[] = []) { + return render( + + {ui} + , + ) +} + +describe('FileInput', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render a file input element', () => { + renderWithProvider() + + const input = document.querySelector('input[type="file"]') + expect(input).toBeInTheDocument() + }) + + it('should set accept attribute based on allowed file types', () => { + renderWithProvider() + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + expect(input.accept).toBe('.JPG,.JPEG,.PNG,.GIF,.WEBP,.SVG') + }) + + it('should use custom extensions when file type is custom', () => { + renderWithProvider( + , + ) + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + expect(input.accept).toBe('.csv,.xlsx') + }) + + it('should allow multiple files when number_limits > 1', () => { + renderWithProvider() + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + expect(input.multiple).toBe(true) + }) + + it('should not allow multiple files when number_limits is 1', () => { + renderWithProvider() + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + expect(input.multiple).toBe(false) + }) + + it('should be disabled when file limit is reached', () => { + renderWithProvider( + , + ['1', '2', '3'], + ) + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + expect(input.disabled).toBe(true) + }) + + it('should not be disabled when file limit is not reached', () => { + renderWithProvider( + , + ['1'], + ) + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + expect(input.disabled).toBe(false) + }) + + it('should call handleLocalFileUpload when files are selected', () => { + renderWithProvider() + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + const file = new File(['content'], 'test.jpg', { type: 'image/jpeg' }) + fireEvent.change(input, { target: { files: [file] } }) + + expect(mockHandleLocalFileUpload).toHaveBeenCalledWith(file) + }) + + it('should respect number_limits when uploading multiple files', () => { + renderWithProvider( + , + ['1', '2'], + ) + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + const file1 = new File(['content'], 'test1.jpg', { type: 'image/jpeg' }) + const file2 = new File(['content'], 'test2.jpg', { type: 'image/jpeg' }) + + Object.defineProperty(input, 'files', { + value: [file1, file2], + }) + fireEvent.change(input) + + // Only 1 file should be uploaded (2 existing + 1 = 3 = limit) + expect(mockHandleLocalFileUpload).toHaveBeenCalledTimes(1) + expect(mockHandleLocalFileUpload).toHaveBeenCalledWith(file1) + }) + + it('should upload first file only when number_limits is not set', () => { + renderWithProvider() + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + const file = new File(['content'], 'test.jpg', { type: 'image/jpeg' }) + fireEvent.change(input, { target: { files: [file] } }) + + expect(mockHandleLocalFileUpload).toHaveBeenCalledWith(file) + }) + + it('should not upload when targetFiles is null', () => { + renderWithProvider() + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + fireEvent.change(input, { target: { files: null } }) + + expect(mockHandleLocalFileUpload).not.toHaveBeenCalled() + }) + + it('should handle empty allowed_file_types', () => { + renderWithProvider() + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + expect(input.accept).toBe('') + }) + + it('should handle custom type with undefined allowed_file_extensions', () => { + renderWithProvider( + , + ) + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + expect(input.accept).toBe('') + }) + + it('should clear input value on click', () => { + renderWithProvider() + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + Object.defineProperty(input, 'value', { writable: true, value: 'some-file' }) + fireEvent.click(input) + + expect(input.value).toBe('') + }) +}) diff --git a/web/app/components/base/file-uploader/file-list-in-log.spec.tsx b/web/app/components/base/file-uploader/file-list-in-log.spec.tsx new file mode 100644 index 0000000000..0c1dff8759 --- /dev/null +++ b/web/app/components/base/file-uploader/file-list-in-log.spec.tsx @@ -0,0 +1,142 @@ +import type { FileEntity } from './types' +import { fireEvent, render, screen } from '@testing-library/react' +import { TransferMethod } from '@/types/app' +import FileListInLog from './file-list-in-log' + +const createFile = (overrides: Partial = {}): FileEntity => ({ + id: `file-${Math.random()}`, + name: 'test.txt', + size: 1024, + type: 'text/plain', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'document', + ...overrides, +}) + +describe('FileListInLog', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return null when fileList is empty', () => { + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + it('should render collapsed view by default', () => { + const fileList = [{ varName: 'files', list: [createFile()] }] + render() + + expect(screen.getByText(/runDetail\.fileListDetail/)).toBeInTheDocument() + }) + + it('should render expanded view when isExpanded is true', () => { + const fileList = [{ varName: 'files', list: [createFile()] }] + render() + + expect(screen.getByText(/runDetail\.fileListLabel/)).toBeInTheDocument() + expect(screen.getByText('files')).toBeInTheDocument() + }) + + it('should toggle between collapsed and expanded on click', () => { + const fileList = [{ varName: 'files', list: [createFile()] }] + render() + + expect(screen.getByText(/runDetail\.fileListDetail/)).toBeInTheDocument() + + const detailLink = screen.getByText(/runDetail\.fileListDetail/) + fireEvent.click(detailLink.parentElement!) + + expect(screen.getByText(/runDetail\.fileListLabel/)).toBeInTheDocument() + }) + + it('should render image files with an img element in collapsed view', () => { + const fileList = [{ + varName: 'files', + list: [createFile({ + name: 'photo.png', + supportFileType: 'image', + url: 'https://example.com/photo.png', + })], + }] + render() + + const img = screen.getByRole('img') + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', 'https://example.com/photo.png') + }) + + it('should render non-image files with an SVG icon in collapsed view', () => { + const fileList = [{ + varName: 'files', + list: [createFile({ + name: 'doc.pdf', + supportFileType: 'document', + })], + }] + render() + + expect(screen.queryByRole('img')).not.toBeInTheDocument() + }) + + it('should render file details in expanded view', () => { + const file = createFile({ name: 'report.txt' }) + const fileList = [{ varName: 'files', list: [file] }] + render() + + expect(screen.getByText('report.txt')).toBeInTheDocument() + }) + + it('should render multiple var groups in expanded view', () => { + const fileList = [ + { varName: 'images', list: [createFile({ name: 'a.jpg' })] }, + { varName: 'documents', list: [createFile({ name: 'b.pdf' })] }, + ] + render() + + expect(screen.getByText('images')).toBeInTheDocument() + expect(screen.getByText('documents')).toBeInTheDocument() + }) + + it('should apply noBorder class when noBorder is true', () => { + const fileList = [{ varName: 'files', list: [createFile()] }] + const { container } = render() + + expect(container.firstChild).not.toHaveClass('border-t') + }) + + it('should apply noPadding class when noPadding is true', () => { + const fileList = [{ varName: 'files', list: [createFile()] }] + const { container } = render() + + expect(container.firstChild).toHaveClass('!p-0') + }) + + it('should render image file with empty url when both base64Url and url are undefined', () => { + const fileList = [{ + varName: 'files', + list: [createFile({ + name: 'photo.png', + supportFileType: 'image', + base64Url: undefined, + url: undefined, + })], + }] + render() + + const img = screen.getByRole('img') + expect(img).toBeInTheDocument() + }) + + it('should collapse when label is clicked in expanded view', () => { + const fileList = [{ varName: 'files', list: [createFile()] }] + render() + + const label = screen.getByText(/runDetail\.fileListLabel/) + fireEvent.click(label) + + expect(screen.getByText(/runDetail\.fileListDetail/)).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/file-uploader/file-type-icon.spec.tsx b/web/app/components/base/file-uploader/file-type-icon.spec.tsx new file mode 100644 index 0000000000..89b42b489d --- /dev/null +++ b/web/app/components/base/file-uploader/file-type-icon.spec.tsx @@ -0,0 +1,85 @@ +import type { FileAppearanceTypeEnum } from './types' +import { render } from '@testing-library/react' +import FileTypeIcon from './file-type-icon' + +describe('FileTypeIcon', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('icon rendering per file type', () => { + const fileTypeToColor: Array<{ type: keyof typeof FileAppearanceTypeEnum, color: string }> = [ + { type: 'pdf', color: 'text-[#EA3434]' }, + { type: 'image', color: 'text-[#00B2EA]' }, + { type: 'video', color: 'text-[#844FDA]' }, + { type: 'audio', color: 'text-[#FF3093]' }, + { type: 'document', color: 'text-[#6F8BB5]' }, + { type: 'code', color: 'text-[#BCC0D1]' }, + { type: 'markdown', color: 'text-[#309BEC]' }, + { type: 'custom', color: 'text-[#BCC0D1]' }, + { type: 'excel', color: 'text-[#01AC49]' }, + { type: 'word', color: 'text-[#2684FF]' }, + { type: 'ppt', color: 'text-[#FF650F]' }, + { type: 'gif', color: 'text-[#00B2EA]' }, + ] + + it.each(fileTypeToColor)( + 'should render $type icon with correct color', + ({ type, color }) => { + const { container } = render() + + const icon = container.querySelector('svg') + expect(icon).toBeInTheDocument() + expect(icon).toHaveClass(color) + }, + ) + }) + + it('should render document icon when type is unknown', () => { + const { container } = render() + + const icon = container.querySelector('svg') + expect(icon).toBeInTheDocument() + expect(icon).toHaveClass('text-[#6F8BB5]') + }) + + describe('size variants', () => { + const sizeMap: Array<{ size: 'sm' | 'md' | 'lg' | 'xl', expectedClass: string }> = [ + { size: 'sm', expectedClass: 'size-4' }, + { size: 'md', expectedClass: 'size-[18px]' }, + { size: 'lg', expectedClass: 'size-5' }, + { size: 'xl', expectedClass: 'size-6' }, + ] + + it.each(sizeMap)( + 'should apply $expectedClass when size is $size', + ({ size, expectedClass }) => { + const { container } = render() + + const icon = container.querySelector('svg') + expect(icon).toHaveClass(expectedClass) + }, + ) + + it('should default to sm size when no size is provided', () => { + const { container } = render() + + const icon = container.querySelector('svg') + expect(icon).toHaveClass('size-4') + }) + }) + + it('should apply custom className when provided', () => { + const { container } = render() + + const icon = container.querySelector('svg') + expect(icon).toHaveClass('extra-class') + }) + + it('should always include shrink-0 class', () => { + const { container } = render() + + const icon = container.querySelector('svg') + expect(icon).toHaveClass('shrink-0') + }) +}) diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.spec.tsx new file mode 100644 index 0000000000..72d4643955 --- /dev/null +++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.spec.tsx @@ -0,0 +1,407 @@ +import type { FileEntity } from '../types' +import { fireEvent, render, screen } from '@testing-library/react' +import { PreviewMode } from '@/app/components/base/features/types' +import { TransferMethod } from '@/types/app' +import FileInAttachmentItem from './file-item' + +vi.mock('@/utils/download', () => ({ + downloadUrl: vi.fn(), +})) + +vi.mock('@/utils/format', () => ({ + formatFileSize: (size: number) => `${size}B`, +})) + +const createFile = (overrides: Partial = {}): FileEntity => ({ + id: 'file-1', + name: 'document.pdf', + size: 2048, + type: 'application/pdf', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'document', + uploadedId: 'uploaded-1', + url: 'https://example.com/document.pdf', + ...overrides, +}) + +describe('FileInAttachmentItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render file name and extension', () => { + render() + + expect(screen.getByText(/document\.pdf/i)).toBeInTheDocument() + expect(screen.getByText(/^pdf$/i)).toBeInTheDocument() + }) + + it('should render file size', () => { + render() + + expect(screen.getByText(/2048B/)).toBeInTheDocument() + }) + + it('should render FileTypeIcon for non-image files', () => { + const { container } = render() + + expect(container.querySelector('svg')).toBeInTheDocument() + }) + + it('should render FileImageRender for image files', () => { + render( + , + ) + + const img = screen.getByRole('img') + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', 'data:image/png;base64,abc') + }) + + it('should render delete button when showDeleteAction is true', () => { + render() + + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(1) + }) + + it('should not render delete button when showDeleteAction is false', () => { + render() + + // With showDeleteAction=false, showDownloadAction defaults to true, + // so there should be exactly 1 button (the download button) + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(1) + }) + + it('should call onRemove when delete button is clicked', () => { + const onRemove = vi.fn() + // Disable download to isolate the delete button + render() + + const deleteBtn = screen.getByRole('button') + fireEvent.click(deleteBtn) + + expect(onRemove).toHaveBeenCalledWith('file-1') + }) + + it('should render download button when showDownloadAction is true', () => { + render() + + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(1) + }) + + it('should render progress circle when file is uploading', () => { + const { container } = render() + + // ProgressCircle renders an SVG with a and element + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + const circle = container.querySelector('circle') + expect(circle).toBeInTheDocument() + }) + + it('should render replay icon when upload failed', () => { + const { container } = render() + + // ReplayLine renders an SVG with data-icon="ReplayLine" + const replayIcon = container.querySelector('[data-icon="ReplayLine"]') + expect(replayIcon).toBeInTheDocument() + }) + + it('should call onReUpload when replay icon is clicked', () => { + const onReUpload = vi.fn() + const { container } = render() + + const replayIcon = container.querySelector('[data-icon="ReplayLine"]') + const replayBtn = replayIcon!.closest('button') + fireEvent.click(replayBtn!) + + expect(onReUpload).toHaveBeenCalledWith('file-1') + }) + + it('should indicate error state when progress is -1', () => { + const { container } = render() + + // Error state is confirmed by the presence of the replay icon + const replayIcon = container.querySelector('[data-icon="ReplayLine"]') + expect(replayIcon).toBeInTheDocument() + }) + + it('should render eye icon for previewable image files', () => { + render( + , + ) + + // canPreview + image renders an extra button for the eye icon + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(2) + }) + + it('should show image preview when eye icon is clicked', () => { + render( + , + ) + + // The eye button is rendered before the download button for image files + const buttons = screen.getAllByRole('button') + // Click the eye button (the first action button for image preview) + fireEvent.click(buttons[0]) + + // ImagePreview renders a portal with an img element + const previewImages = document.querySelectorAll('img') + // There should be at least 2 images: the file thumbnail + the preview + expect(previewImages.length).toBeGreaterThanOrEqual(2) + }) + + it('should close image preview when close is clicked', () => { + render( + , + ) + + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[0]) + + // ImagePreview renders via createPortal with class "image-preview-container" + const previewContainer = document.querySelector('.image-preview-container')! + expect(previewContainer).toBeInTheDocument() + + // Close button is the last clickable div with an SVG in the preview container + const closeIcon = screen.getByTestId('image-preview-close-button') + fireEvent.click(closeIcon.parentElement!) + + // Preview should be removed + expect(document.querySelector('.image-preview-container')).not.toBeInTheDocument() + }) + + it('should call downloadUrl when download button is clicked', async () => { + const { downloadUrl } = await import('@/utils/download') + render() + + // Download button is the only action button when showDeleteAction is not set + const downloadBtn = screen.getByRole('button') + fireEvent.click(downloadBtn) + + expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({ + fileName: expect.stringMatching(/document\.pdf/i), + })) + }) + + it('should open new page when previewMode is NewPage and clicked', () => { + const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null) + render( + , + ) + + // Click the file name text to trigger the row click handler + fireEvent.click(screen.getByText(/document\.pdf/i)) + + expect(windowOpen).toHaveBeenCalledWith('https://example.com/doc.pdf', '_blank') + windowOpen.mockRestore() + }) + + it('should fallback to base64Url when url is empty for NewPage preview', () => { + const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null) + render( + , + ) + + fireEvent.click(screen.getByText(/document\.pdf/i)) + + expect(windowOpen).toHaveBeenCalledWith('data:image/png;base64,abc', '_blank') + windowOpen.mockRestore() + }) + + it('should open empty string when both url and base64Url are empty for NewPage preview', () => { + const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null) + render( + , + ) + + fireEvent.click(screen.getByText(/document\.pdf/i)) + + expect(windowOpen).toHaveBeenCalledWith('', '_blank') + windowOpen.mockRestore() + }) + + it('should not open new page when previewMode is not NewPage', () => { + const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null) + render( + , + ) + + fireEvent.click(screen.getByText(/document\.pdf/i)) + + expect(windowOpen).not.toHaveBeenCalled() + windowOpen.mockRestore() + }) + + it('should use url for image render fallback when base64Url is empty', () => { + render( + , + ) + + const img = screen.getByRole('img') + expect(img).toHaveAttribute('src', 'https://example.com/img.png') + }) + + it('should render image element even when both urls are empty', () => { + render( + , + ) + + const img = screen.getByRole('img') + expect(img).toBeInTheDocument() + }) + + it('should not render eye icon when canPreview is false for image files', () => { + render( + , + ) + + // Without canPreview, only the download button should render + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(1) + }) + + it('should download using base64Url when url is not available', async () => { + const { downloadUrl } = await import('@/utils/download') + render( + , + ) + + const downloadBtn = screen.getByRole('button') + fireEvent.click(downloadBtn) + + expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({ + url: 'data:application/pdf;base64,abc', + })) + }) + + it('should not render file size when size is 0', () => { + render() + + expect(screen.queryByText(/0B/)).not.toBeInTheDocument() + }) + + it('should not render extension when ext is empty', () => { + render() + + // The file name should still show + expect(screen.getByText(/noext/)).toBeInTheDocument() + }) + + it('should show image preview with empty url when url is undefined', () => { + render( + , + ) + + const buttons = screen.getAllByRole('button') + // Click the eye preview button + fireEvent.click(buttons[0]) + + // setImagePreviewUrl(url || '') = setImagePreviewUrl('') + // Empty string is falsy, so preview should NOT render + expect(document.querySelector('.image-preview-container')).not.toBeInTheDocument() + }) + + it('should download with empty url when both url and base64Url are undefined', async () => { + const { downloadUrl } = await import('@/utils/download') + render( + , + ) + + const downloadBtn = screen.getByRole('button') + fireEvent.click(downloadBtn) + + expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({ + url: '', + })) + }) + + it('should call downloadUrl with empty url when both url and base64Url are falsy', async () => { + const { downloadUrl } = await import('@/utils/download') + render( + , + ) + + const downloadBtn = screen.getByRole('button') + fireEvent.click(downloadBtn) + + expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({ + url: '', + })) + }) +}) diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/index.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/index.spec.tsx new file mode 100644 index 0000000000..81946e0d1c --- /dev/null +++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/index.spec.tsx @@ -0,0 +1,207 @@ +import type { FileEntity } from '../types' +import type { FileUpload } from '@/app/components/base/features/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { TransferMethod } from '@/types/app' +import FileUploaderInAttachmentWrapper from './index' + +const mockHandleRemoveFile = vi.fn() +const mockHandleReUploadFile = vi.fn() +vi.mock('../hooks', () => ({ + useFile: () => ({ + handleRemoveFile: mockHandleRemoveFile, + handleReUploadFile: mockHandleReUploadFile, + }), +})) + +vi.mock('@/utils/format', () => ({ + formatFileSize: (size: number) => `${size}B`, +})) + +vi.mock('@/utils/download', () => ({ + downloadUrl: vi.fn(), +})) + +const createFileConfig = (overrides: Partial = {}): FileUpload => ({ + enabled: true, + allowed_file_types: ['image'], + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + allowed_file_extensions: [], + number_limits: 5, + ...overrides, +} as unknown as FileUpload) + +const createFile = (overrides: Partial = {}): FileEntity => ({ + id: 'file-1', + name: 'test.txt', + size: 1024, + type: 'text/plain', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'document', + ...overrides, +}) + +describe('FileUploaderInAttachmentWrapper', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render without crashing', () => { + render( + , + ) + + // FileContextProvider wraps children with a Zustand context — verify children render + expect(screen.getAllByRole('button').length).toBeGreaterThan(0) + }) + + it('should render upload buttons when not disabled', () => { + render( + , + ) + + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThan(0) + }) + + it('should not render upload buttons when disabled', () => { + render( + , + ) + + expect(screen.queryByText(/fileUploader\.uploadFromComputer/)).not.toBeInTheDocument() + }) + + it('should render file items for each file', () => { + const files = [ + createFile({ id: 'f1', name: 'a.txt' }), + createFile({ id: 'f2', name: 'b.txt' }), + ] + + render( + , + ) + + expect(screen.getByText(/a\.txt/i)).toBeInTheDocument() + expect(screen.getByText(/b\.txt/i)).toBeInTheDocument() + }) + + it('should render local upload button for local_file method', () => { + render( + )} + />, + ) + + expect(screen.getByText(/fileUploader\.uploadFromComputer/)).toBeInTheDocument() + }) + + it('should render link upload option for remote_url method', () => { + render( + )} + />, + ) + + expect(screen.getByText(/fileUploader\.pasteFileLink/)).toBeInTheDocument() + }) + + it('should call handleRemoveFile when remove button is clicked', () => { + const files = [createFile({ id: 'f1', name: 'a.txt' })] + + render( + , + ) + + // Find the file item row, then locate the delete button within it + const fileNameEl = screen.getByText(/a\.txt/i) + const fileRow = fileNameEl.closest('[title="a.txt"]')?.parentElement?.parentElement + const deleteBtn = fileRow?.querySelector('button:last-of-type') + fireEvent.click(deleteBtn!) + + expect(mockHandleRemoveFile).toHaveBeenCalledWith('f1') + }) + + it('should apply open style on remote_url trigger when portal is open', () => { + render( + )} + />, + ) + + // Click the remote_url button to open the portal + const linkButton = screen.getByText(/fileUploader\.pasteFileLink/) + fireEvent.click(linkButton) + + // The button should still be in the document + expect(linkButton.closest('button')).toBeInTheDocument() + }) + + it('should disable upload buttons when file limit is reached', () => { + const files = [ + createFile({ id: 'f1' }), + createFile({ id: 'f2' }), + createFile({ id: 'f3' }), + createFile({ id: 'f4' }), + createFile({ id: 'f5' }), + ] + + render( + , + ) + + const buttons = screen.getAllByRole('button') + const disabledButtons = buttons.filter(btn => btn.hasAttribute('disabled')) + expect(disabledButtons.length).toBeGreaterThan(0) + }) + + it('should call handleReUploadFile when reupload button is clicked', () => { + const files = [createFile({ id: 'f1', name: 'a.txt', progress: -1 })] + + const { container } = render( + , + ) + + // ReplayLine is inside ActionButton (a + + ), +})) + +const createFile = (overrides: Partial = {}): FileEntity => ({ + id: 'file-1', + name: 'document.pdf', + size: 2048, + type: 'application/pdf', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'document', + uploadedId: 'uploaded-1', + url: 'https://example.com/document.pdf', + ...overrides, +}) + +describe('FileItem (chat-input)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render file name', () => { + render() + + expect(screen.getByText(/document\.pdf/i)).toBeInTheDocument() + }) + + it('should render file extension and size', () => { + const { container } = render() + + // Extension and size are rendered as text nodes in the metadata div + expect(container.textContent).toContain('pdf') + expect(container.textContent).toContain('2048B') + }) + + it('should render FileTypeIcon', () => { + const { container } = render() + + const fileTypeIcon = container.querySelector('svg') + expect(fileTypeIcon).toBeInTheDocument() + }) + + it('should render delete button when showDeleteAction is true', () => { + render() + + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(1) + }) + + it('should call onRemove when delete button is clicked', () => { + const onRemove = vi.fn() + render() + const delete_button = screen.getByTestId('delete-button') + fireEvent.click(delete_button) + expect(onRemove).toHaveBeenCalledWith('file-1') + }) + + it('should render progress circle when file is uploading', () => { + const { container } = render( + , + ) + + const progressSvg = container.querySelector('svg circle') + expect(progressSvg).toBeInTheDocument() + }) + + it('should render replay icon when upload failed', () => { + render() + + const replayIcon = screen.getByTestId('replay-icon') + expect(replayIcon).toBeInTheDocument() + }) + + it('should call onReUpload when replay icon is clicked', () => { + const onReUpload = vi.fn() + render( + , + ) + + const replayIcon = screen.getByTestId('replay-icon') + fireEvent.click(replayIcon!) + + expect(onReUpload).toHaveBeenCalledWith('file-1') + }) + + it('should have error styling when upload failed', () => { + const { container } = render() + const fileItemContainer = container.firstChild as HTMLElement + expect(fileItemContainer).toHaveClass('border-state-destructive-border') + expect(fileItemContainer).toHaveClass('bg-state-destructive-hover-alt') + }) + + it('should show audio preview when audio file name is clicked', async () => { + render( + , + ) + + fireEvent.click(screen.getByText(/audio\.mp3/i)) + + const audioElement = document.querySelector('audio') + expect(audioElement).toBeInTheDocument() + }) + + it('should show video preview when video file name is clicked', () => { + render( + , + ) + + fireEvent.click(screen.getByText(/video\.mp4/i)) + + const videoElement = document.querySelector('video') + expect(videoElement).toBeInTheDocument() + }) + + it('should show pdf preview when pdf file name is clicked', () => { + render( + , + ) + + fireEvent.click(screen.getByText(/doc\.pdf/i)) + + expect(screen.getByTestId('pdf-preview')).toBeInTheDocument() + }) + + it('should close audio preview', () => { + render( + , + ) + + fireEvent.click(screen.getByText(/audio\.mp3/i)) + expect(document.querySelector('audio')).toBeInTheDocument() + + const deleteButton = screen.getByTestId('close-btn') + fireEvent.click(deleteButton) + + expect(document.querySelector('audio')).not.toBeInTheDocument() + }) + + it('should render download button when showDownloadAction is true and url exists', () => { + render() + + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(1) + }) + + it('should call downloadUrl when download button is clicked', async () => { + const { downloadUrl } = await import('@/utils/download') + render() + + const downloadBtn = screen.getByTestId('download-button') + fireEvent.click(downloadBtn) + + expect(downloadUrl).toHaveBeenCalled() + }) + + it('should not render download button when showDownloadAction is false', () => { + render() + + const buttons = screen.queryAllByRole('button') + expect(buttons).toHaveLength(0) + }) + + it('should not show preview when canPreview is false', () => { + render( + , + ) + + fireEvent.click(screen.getByText(/audio\.mp3/i)) + + expect(document.querySelector('audio')).not.toBeInTheDocument() + }) + + it('should close video preview', () => { + render( + , + ) + + fireEvent.click(screen.getByText(/video\.mp4/i)) + expect(document.querySelector('video')).toBeInTheDocument() + + const closeBtn = screen.getByTestId('video-preview-close-btn') + fireEvent.click(closeBtn) + + expect(document.querySelector('video')).not.toBeInTheDocument() + }) + + it('should close pdf preview', () => { + render( + , + ) + + fireEvent.click(screen.getByText(/doc\.pdf/i)) + expect(screen.getByTestId('pdf-preview')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('pdf-close')) + expect(screen.queryByTestId('pdf-preview')).not.toBeInTheDocument() + }) + + it('should use createObjectURL when no url or base64Url but has originalFile', () => { + const mockUrl = 'blob:http://localhost/test-blob' + const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue(mockUrl) + + const file = createFile({ + name: 'audio.mp3', + type: 'audio/mpeg', + url: undefined, + base64Url: undefined, + originalFile: new File(['content'], 'audio.mp3', { type: 'audio/mpeg' }), + }) + render() + + fireEvent.click(screen.getByText(/audio\.mp3/i)) + + expect(document.querySelector('audio')).toBeInTheDocument() + expect(createObjectURLSpy).toHaveBeenCalled() + createObjectURLSpy.mockRestore() + }) + + it('should not use createObjectURL when no originalFile and no urls', () => { + const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL') + const file = createFile({ + name: 'audio.mp3', + type: 'audio/mpeg', + url: undefined, + base64Url: undefined, + originalFile: undefined, + }) + render() + + fireEvent.click(screen.getByText(/audio\.mp3/i)) + expect(createObjectURLSpy).not.toHaveBeenCalled() + createObjectURLSpy.mockRestore() + expect(document.querySelector('audio')).not.toBeInTheDocument() + }) + + it('should not render download button when download_url is falsy', () => { + render( + , + ) + + const buttons = screen.queryAllByRole('button') + expect(buttons).toHaveLength(0) + }) + + it('should render download button when base64Url is available as download_url', () => { + render( + , + ) + + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(1) + }) + + it('should not render extension separator when ext is empty', () => { + render() + + expect(screen.getByText(/noext/)).toBeInTheDocument() + }) + + it('should not render file size when size is 0', () => { + render() + + expect(screen.queryByText(/0B/)).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx index af32f917b9..09f5070f1e 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx @@ -1,15 +1,10 @@ import type { FileEntity } from '../types' -import { - RiCloseLine, - RiDownloadLine, -} from '@remixicon/react' import { useState } from 'react' import ActionButton from '@/app/components/base/action-button' import Button from '@/app/components/base/button' import AudioPreview from '@/app/components/base/file-uploader/audio-preview' import PdfPreview from '@/app/components/base/file-uploader/dynamic-pdf-preview' import VideoPreview from '@/app/components/base/file-uploader/video-preview' -import { ReplayLine } from '@/app/components/base/icons/src/vender/other' import ProgressCircle from '@/app/components/base/progress-bar/progress-circle' import { cn } from '@/utils/classnames' import { downloadUrl } from '@/utils/download' @@ -62,20 +57,21 @@ const FileItem = ({ ) }
canPreview && setPreviewUrl(tmp_preview_url || '')} > {name}
-
+
- + ) } @@ -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} > - +
, document.body, diff --git a/web/app/components/base/image-uploader/image-preview.tsx b/web/app/components/base/image-uploader/image-preview.tsx index 0641af3d79..cffbde2755 100644 --- a/web/app/components/base/image-uploader/image-preview.tsx +++ b/web/app/components/base/image-uploader/image-preview.tsx @@ -1,5 +1,5 @@ import type { FC } from 'react' -import { RiAddBoxLine, RiCloseLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/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' @@ -256,7 +256,7 @@ const ImagePreview: FC = ({ className="absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/8 backdrop-blur-[2px]" onClick={onCancel} > - +
, diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index b5c02271b9..c1b87efac3 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -1953,11 +1953,6 @@ "count": 1 } }, - "app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx": { - "tailwindcss/enforce-consistent-class-order": { - "count": 2 - } - }, "app/components/base/file-uploader/hooks.ts": { "ts/no-explicit-any": { "count": 3 @@ -1969,9 +1964,6 @@ } }, "app/components/base/file-uploader/utils.spec.ts": { - "test/no-identical-title": { - "count": 1 - }, "ts/no-explicit-any": { "count": 2 }