From 59b2e1ab82029795c71a6db8dbd68821e010cd59 Mon Sep 17 00:00:00 2001 From: Wu Tianwei <30284043+WTW0313@users.noreply.github.com> Date: Fri, 11 Apr 2025 17:53:18 +0800 Subject: [PATCH] Chore/add unit test for utils (#17858) --- .../base/file-uploader/utils.spec.ts | 614 ++++++++++++++++++ .../base/prompt-editor/constants.tsx | 4 +- web/hooks/use-timestamp.spec.ts | 65 ++ web/jest.config.ts | 2 +- web/jest.setup.ts | 1 + web/utils/format.spec.ts | 45 +- web/utils/index.spec.ts | 295 +++++++++ 7 files changed, 1022 insertions(+), 4 deletions(-) create mode 100644 web/app/components/base/file-uploader/utils.spec.ts create mode 100644 web/hooks/use-timestamp.spec.ts create mode 100644 web/jest.setup.ts create mode 100644 web/utils/index.spec.ts diff --git a/web/app/components/base/file-uploader/utils.spec.ts b/web/app/components/base/file-uploader/utils.spec.ts new file mode 100644 index 0000000000..c8cf9fbe74 --- /dev/null +++ b/web/app/components/base/file-uploader/utils.spec.ts @@ -0,0 +1,614 @@ +import mime from 'mime' +import { upload } from '@/service/base' +import { + downloadFile, + fileIsUploaded, + fileUpload, + getFileAppearanceType, + getFileExtension, + getFileNameFromUrl, + getFilesInLogs, + getProcessedFiles, + getProcessedFilesFromResponse, + getSupportFileExtensionList, + getSupportFileType, + isAllowedFileExtension, +} from './utils' +import { FileAppearanceTypeEnum } from './types' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { TransferMethod } from '@/types/app' +import { FILE_EXTS } from '../prompt-editor/constants' + +jest.mock('mime', () => ({ + __esModule: true, + default: { + getExtension: jest.fn(), + }, +})) + +jest.mock('@/service/base', () => ({ + upload: jest.fn(), +})) + +describe('file-uploader utils', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('fileUpload', () => { + it('should handle successful file upload', async () => { + const mockFile = new File(['test'], 'test.txt') + const mockCallbacks = { + onProgressCallback: jest.fn(), + onSuccessCallback: jest.fn(), + onErrorCallback: jest.fn(), + } + + jest.mocked(upload).mockResolvedValue({ id: '123' }) + + await fileUpload({ + file: mockFile, + ...mockCallbacks, + }) + + expect(upload).toHaveBeenCalled() + expect(mockCallbacks.onSuccessCallback).toHaveBeenCalledWith({ id: '123' }) + }) + }) + + describe('getFileExtension', () => { + it('should get extension from mimetype', () => { + jest.mocked(mime.getExtension).mockReturnValue('pdf') + expect(getFileExtension('file', 'application/pdf')).toBe('pdf') + }) + + it('should get extension from filename if mimetype fails', () => { + jest.mocked(mime.getExtension).mockReturnValue(null) + expect(getFileExtension('file.txt', '')).toBe('txt') + expect(getFileExtension('file.txt.docx', '')).toBe('docx') + expect(getFileExtension('file', '')).toBe('') + }) + + it('should return empty string for remote files', () => { + expect(getFileExtension('file.txt', '', true)).toBe('') + }) + }) + + describe('getFileAppearanceType', () => { + it('should identify gif files', () => { + jest.mocked(mime.getExtension).mockReturnValue('gif') + expect(getFileAppearanceType('image.gif', 'image/gif')) + .toBe(FileAppearanceTypeEnum.gif) + }) + + it('should identify image files', () => { + jest.mocked(mime.getExtension).mockReturnValue('jpg') + expect(getFileAppearanceType('image.jpg', 'image/jpeg')) + .toBe(FileAppearanceTypeEnum.image) + + jest.mocked(mime.getExtension).mockReturnValue('jpeg') + expect(getFileAppearanceType('image.jpeg', 'image/jpeg')) + .toBe(FileAppearanceTypeEnum.image) + + jest.mocked(mime.getExtension).mockReturnValue('png') + expect(getFileAppearanceType('image.png', 'image/png')) + .toBe(FileAppearanceTypeEnum.image) + + jest.mocked(mime.getExtension).mockReturnValue('webp') + expect(getFileAppearanceType('image.webp', 'image/webp')) + .toBe(FileAppearanceTypeEnum.image) + + jest.mocked(mime.getExtension).mockReturnValue('svg') + expect(getFileAppearanceType('image.svg', 'image/svgxml')) + .toBe(FileAppearanceTypeEnum.image) + }) + + it('should identify video files', () => { + jest.mocked(mime.getExtension).mockReturnValue('mp4') + expect(getFileAppearanceType('video.mp4', 'video/mp4')) + .toBe(FileAppearanceTypeEnum.video) + + jest.mocked(mime.getExtension).mockReturnValue('mov') + expect(getFileAppearanceType('video.mov', 'video/quicktime')) + .toBe(FileAppearanceTypeEnum.video) + + jest.mocked(mime.getExtension).mockReturnValue('mpeg') + expect(getFileAppearanceType('video.mpeg', 'video/mpeg')) + .toBe(FileAppearanceTypeEnum.video) + + jest.mocked(mime.getExtension).mockReturnValue('webm') + expect(getFileAppearanceType('video.web', 'video/webm')) + .toBe(FileAppearanceTypeEnum.video) + }) + + it('should identify audio files', () => { + jest.mocked(mime.getExtension).mockReturnValue('mp3') + expect(getFileAppearanceType('audio.mp3', 'audio/mpeg')) + .toBe(FileAppearanceTypeEnum.audio) + + jest.mocked(mime.getExtension).mockReturnValue('m4a') + expect(getFileAppearanceType('audio.m4a', 'audio/mp4')) + .toBe(FileAppearanceTypeEnum.audio) + + jest.mocked(mime.getExtension).mockReturnValue('wav') + expect(getFileAppearanceType('audio.wav', 'audio/vnd.wav')) + .toBe(FileAppearanceTypeEnum.audio) + + jest.mocked(mime.getExtension).mockReturnValue('amr') + expect(getFileAppearanceType('audio.amr', 'audio/AMR')) + .toBe(FileAppearanceTypeEnum.audio) + + jest.mocked(mime.getExtension).mockReturnValue('mpga') + expect(getFileAppearanceType('audio.mpga', 'audio/mpeg')) + .toBe(FileAppearanceTypeEnum.audio) + }) + + it('should identify code files', () => { + jest.mocked(mime.getExtension).mockReturnValue('html') + expect(getFileAppearanceType('index.html', 'text/html')) + .toBe(FileAppearanceTypeEnum.code) + }) + + it('should identify PDF files', () => { + jest.mocked(mime.getExtension).mockReturnValue('pdf') + expect(getFileAppearanceType('doc.pdf', 'application/pdf')) + .toBe(FileAppearanceTypeEnum.pdf) + }) + + it('should identify markdown files', () => { + jest.mocked(mime.getExtension).mockReturnValue('md') + expect(getFileAppearanceType('file.md', 'text/markdown')) + .toBe(FileAppearanceTypeEnum.markdown) + + jest.mocked(mime.getExtension).mockReturnValue('markdown') + expect(getFileAppearanceType('file.markdown', 'text/markdown')) + .toBe(FileAppearanceTypeEnum.markdown) + + jest.mocked(mime.getExtension).mockReturnValue('mdx') + expect(getFileAppearanceType('file.mdx', 'text/mdx')) + .toBe(FileAppearanceTypeEnum.markdown) + }) + + it('should identify excel files', () => { + jest.mocked(mime.getExtension).mockReturnValue('xlsx') + expect(getFileAppearanceType('doc.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')) + .toBe(FileAppearanceTypeEnum.excel) + + jest.mocked(mime.getExtension).mockReturnValue('xls') + expect(getFileAppearanceType('doc.xls', 'application/vnd.ms-excel')) + .toBe(FileAppearanceTypeEnum.excel) + }) + + it('should identify word files', () => { + jest.mocked(mime.getExtension).mockReturnValue('doc') + expect(getFileAppearanceType('doc.doc', 'application/msword')) + .toBe(FileAppearanceTypeEnum.word) + + jest.mocked(mime.getExtension).mockReturnValue('docx') + expect(getFileAppearanceType('doc.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')) + .toBe(FileAppearanceTypeEnum.word) + }) + + it('should identify word files', () => { + jest.mocked(mime.getExtension).mockReturnValue('ppt') + expect(getFileAppearanceType('doc.ppt', 'application/vnd.ms-powerpoint')) + .toBe(FileAppearanceTypeEnum.ppt) + + jest.mocked(mime.getExtension).mockReturnValue('pptx') + expect(getFileAppearanceType('doc.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation')) + .toBe(FileAppearanceTypeEnum.ppt) + }) + + it('should identify document files', () => { + jest.mocked(mime.getExtension).mockReturnValue('txt') + expect(getFileAppearanceType('file.txt', 'text/plain')) + .toBe(FileAppearanceTypeEnum.document) + + jest.mocked(mime.getExtension).mockReturnValue('csv') + expect(getFileAppearanceType('file.csv', 'text/csv')) + .toBe(FileAppearanceTypeEnum.document) + + jest.mocked(mime.getExtension).mockReturnValue('msg') + expect(getFileAppearanceType('file.msg', 'application/vnd.ms-outlook')) + .toBe(FileAppearanceTypeEnum.document) + + jest.mocked(mime.getExtension).mockReturnValue('eml') + expect(getFileAppearanceType('file.eml', 'message/rfc822')) + .toBe(FileAppearanceTypeEnum.document) + + jest.mocked(mime.getExtension).mockReturnValue('xml') + expect(getFileAppearanceType('file.xml', 'application/rssxml')) + .toBe(FileAppearanceTypeEnum.document) + + jest.mocked(mime.getExtension).mockReturnValue('epub') + expect(getFileAppearanceType('file.epub', 'application/epubzip')) + .toBe(FileAppearanceTypeEnum.document) + }) + + it('should handle null mime extension', () => { + jest.mocked(mime.getExtension).mockReturnValue(null) + expect(getFileAppearanceType('file.txt', 'text/plain')) + .toBe(FileAppearanceTypeEnum.document) + }) + }) + + describe('getSupportFileType', () => { + it('should return custom type when isCustom is true', () => { + expect(getSupportFileType('file.txt', '', true)) + .toBe(SupportUploadFileTypes.custom) + }) + + it('should return file type when isCustom is false', () => { + expect(getSupportFileType('file.txt', 'text/plain')) + .toBe(SupportUploadFileTypes.document) + }) + }) + + describe('getProcessedFiles', () => { + it('should process files correctly', () => { + const files = [{ + id: '123', + name: 'test.txt', + size: 1024, + type: 'text/plain', + progress: 100, + supportFileType: 'document', + transferMethod: TransferMethod.remote_url, + url: 'http://example.com', + uploadedId: '123', + }] + + const result = getProcessedFiles(files) + expect(result[0]).toEqual({ + type: 'document', + transfer_method: TransferMethod.remote_url, + url: 'http://example.com', + upload_file_id: '123', + }) + }) + }) + + describe('getProcessedFilesFromResponse', () => { + it('should process files correctly', () => { + const files = [{ + related_id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9', + extension: '.jpeg', + filename: 'test.jpeg', + size: 2881761, + mime_type: 'image/jpeg', + transfer_method: TransferMethod.local_file, + type: 'image', + url: 'https://upload.dify.dev/files/xxx/file-preview', + }] + + const result = getProcessedFilesFromResponse(files) + expect(result[0]).toEqual({ + id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9', + name: 'test.jpeg', + size: 2881761, + type: 'image/jpeg', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'image', + uploadedId: '2a38e2ca-1295-415d-a51d-65d4ff9912d9', + url: 'https://upload.dify.dev/files/xxx/file-preview', + }) + }) + }) + + describe('getFileNameFromUrl', () => { + it('should extract filename from URL', () => { + expect(getFileNameFromUrl('http://example.com/path/file.txt')) + .toBe('file.txt') + }) + }) + + describe('getSupportFileExtensionList', () => { + it('should handle custom file types', () => { + const result = getSupportFileExtensionList( + [SupportUploadFileTypes.custom], + ['.pdf', '.txt', '.doc'], + ) + expect(result).toEqual(['PDF', 'TXT', 'DOC']) + }) + + it('should handle standard file types', () => { + const mockFileExts = { + image: ['JPG', 'PNG'], + document: ['PDF', 'TXT'], + video: ['MP4', 'MOV'], + } + + // Temporarily mock FILE_EXTS + const originalFileExts = { ...FILE_EXTS } + Object.assign(FILE_EXTS, mockFileExts) + + const result = getSupportFileExtensionList( + ['image', 'document'], + [], + ) + expect(result).toEqual(['JPG', 'PNG', 'PDF', 'TXT']) + + // Restore original FILE_EXTS + Object.assign(FILE_EXTS, originalFileExts) + }) + + it('should return empty array for empty inputs', () => { + const result = getSupportFileExtensionList([], []) + expect(result).toEqual([]) + }) + + it('should prioritize custom types over standard types', () => { + const mockFileExts = { + image: ['JPG', 'PNG'], + } + + // Temporarily mock FILE_EXTS + const originalFileExts = { ...FILE_EXTS } + Object.assign(FILE_EXTS, mockFileExts) + + const result = getSupportFileExtensionList( + [SupportUploadFileTypes.custom, 'image'], + ['.csv', '.xml'], + ) + expect(result).toEqual(['CSV', 'XML']) + + // Restore original FILE_EXTS + Object.assign(FILE_EXTS, originalFileExts) + }) + }) + + describe('isAllowedFileExtension', () => { + it('should validate allowed file extensions', () => { + jest.mocked(mime.getExtension).mockReturnValue('pdf') + expect(isAllowedFileExtension( + 'test.pdf', + 'application/pdf', + ['document'], + ['.pdf'], + )).toBe(true) + }) + }) + + describe('getFilesInLogs', () => { + const mockFileData = { + dify_model_identity: '__dify__file__', + related_id: '123', + filename: 'test.pdf', + size: 1024, + mime_type: 'application/pdf', + transfer_method: 'local_file', + type: 'document', + url: 'http://example.com/test.pdf', + } + + it('should handle empty or null input', () => { + expect(getFilesInLogs(null)).toEqual([]) + expect(getFilesInLogs({})).toEqual([]) + expect(getFilesInLogs(undefined)).toEqual([]) + }) + + it('should process single file object', () => { + const input = { + file1: mockFileData, + } + + const expected = [{ + varName: 'file1', + list: [{ + id: '123', + name: 'test.pdf', + size: 1024, + type: 'application/pdf', + progress: 100, + transferMethod: 'local_file', + supportFileType: 'document', + uploadedId: '123', + url: 'http://example.com/test.pdf', + }], + }] + + expect(getFilesInLogs(input)).toEqual(expected) + }) + + it('should process array of files', () => { + const input = { + files: [mockFileData, mockFileData], + } + + const expected = [{ + varName: 'files', + list: [ + { + id: '123', + name: 'test.pdf', + size: 1024, + type: 'application/pdf', + progress: 100, + transferMethod: 'local_file', + supportFileType: 'document', + uploadedId: '123', + url: 'http://example.com/test.pdf', + }, + { + id: '123', + name: 'test.pdf', + size: 1024, + type: 'application/pdf', + progress: 100, + transferMethod: 'local_file', + supportFileType: 'document', + uploadedId: '123', + url: 'http://example.com/test.pdf', + }, + ], + }] + + expect(getFilesInLogs(input)).toEqual(expected) + }) + + it('should ignore non-file objects and arrays', () => { + const input = { + regularString: 'not a file', + regularNumber: 123, + regularArray: [1, 2, 3], + regularObject: { key: 'value' }, + file: mockFileData, + } + + const expected = [{ + varName: 'file', + list: [{ + id: '123', + name: 'test.pdf', + size: 1024, + type: 'application/pdf', + progress: 100, + transferMethod: 'local_file', + supportFileType: 'document', + uploadedId: '123', + url: 'http://example.com/test.pdf', + }], + }] + + expect(getFilesInLogs(input)).toEqual(expected) + }) + + it('should handle mixed file types in array', () => { + const input = { + mixedFiles: [ + mockFileData, + { notAFile: true }, + mockFileData, + ], + } + + const expected = [{ + varName: 'mixedFiles', + list: [ + { + id: '123', + name: 'test.pdf', + size: 1024, + type: 'application/pdf', + progress: 100, + transferMethod: 'local_file', + supportFileType: 'document', + uploadedId: '123', + url: 'http://example.com/test.pdf', + }, + { + id: undefined, + name: undefined, + progress: 100, + size: 0, + supportFileType: undefined, + transferMethod: undefined, + type: undefined, + uploadedId: undefined, + url: undefined, + }, + { + id: '123', + name: 'test.pdf', + size: 1024, + type: 'application/pdf', + progress: 100, + transferMethod: 'local_file', + supportFileType: 'document', + uploadedId: '123', + url: 'http://example.com/test.pdf', + }, + ], + }] + + expect(getFilesInLogs(input)).toEqual(expected) + }) + }) + + describe('fileIsUploaded', () => { + it('should identify uploaded files', () => { + expect(fileIsUploaded({ + uploadedId: '123', + progress: 100, + } as any)).toBe(true) + }) + + it('should identify remote files as uploaded', () => { + expect(fileIsUploaded({ + transferMethod: TransferMethod.remote_url, + progress: 100, + } as any)).toBe(true) + }) + }) + + describe('downloadFile', () => { + let mockAnchor: HTMLAnchorElement + let createElementMock: jest.SpyInstance + let appendChildMock: jest.SpyInstance + let removeChildMock: jest.SpyInstance + + beforeEach(() => { + // Mock createElement and appendChild + mockAnchor = { + href: '', + download: '', + style: { display: '' }, + target: '', + title: '', + click: jest.fn(), + } as unknown as HTMLAnchorElement + + createElementMock = jest.spyOn(document, 'createElement').mockReturnValue(mockAnchor as any) + appendChildMock = jest.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => { + return node + }) + removeChildMock = jest.spyOn(document.body, 'removeChild').mockImplementation((node: Node) => { + return node + }) + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('should create and trigger download with correct attributes', () => { + const url = 'https://example.com/test.pdf' + const filename = 'test.pdf' + + downloadFile(url, filename) + + // Verify anchor element was created with correct properties + expect(createElementMock).toHaveBeenCalledWith('a') + expect(mockAnchor.href).toBe(url) + expect(mockAnchor.download).toBe(filename) + expect(mockAnchor.style.display).toBe('none') + expect(mockAnchor.target).toBe('_blank') + expect(mockAnchor.title).toBe(filename) + + // Verify DOM operations + expect(appendChildMock).toHaveBeenCalledWith(mockAnchor) + expect(mockAnchor.click).toHaveBeenCalled() + expect(removeChildMock).toHaveBeenCalledWith(mockAnchor) + }) + + it('should handle empty filename', () => { + const url = 'https://example.com/test.pdf' + const filename = '' + + downloadFile(url, filename) + + expect(mockAnchor.download).toBe('') + expect(mockAnchor.title).toBe('') + }) + + it('should handle empty url', () => { + const url = '' + const filename = 'test.pdf' + + downloadFile(url, filename) + + expect(mockAnchor.href).toBe('') + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/constants.tsx b/web/app/components/base/prompt-editor/constants.tsx index 1288e1539e..31fbc0abb4 100644 --- a/web/app/components/base/prompt-editor/constants.tsx +++ b/web/app/components/base/prompt-editor/constants.tsx @@ -53,6 +53,6 @@ export const getInputVars = (text: string): ValueSelector[] => { export const FILE_EXTS: Record = { [SupportUploadFileTypes.image]: ['JPG', 'JPEG', 'PNG', 'GIF', 'WEBP', 'SVG'], [SupportUploadFileTypes.document]: ['TXT', 'MD', 'MDX', 'MARKDOWN', 'PDF', 'HTML', 'XLSX', 'XLS', 'DOC', 'DOCX', 'CSV', 'EML', 'MSG', 'PPTX', 'PPT', 'XML', 'EPUB'], - [SupportUploadFileTypes.audio]: ['MP3', 'M4A', 'WAV', 'WEBM', 'AMR', 'MPGA'], - [SupportUploadFileTypes.video]: ['MP4', 'MOV', 'MPEG', 'MPGA'], + [SupportUploadFileTypes.audio]: ['MP3', 'M4A', 'WAV', 'AMR', 'MPGA'], + [SupportUploadFileTypes.video]: ['MP4', 'MOV', 'MPEG', 'WEBM'], } diff --git a/web/hooks/use-timestamp.spec.ts b/web/hooks/use-timestamp.spec.ts new file mode 100644 index 0000000000..d1113f56d3 --- /dev/null +++ b/web/hooks/use-timestamp.spec.ts @@ -0,0 +1,65 @@ +import { renderHook } from '@testing-library/react' +import useTimestamp from './use-timestamp' + +jest.mock('@/context/app-context', () => ({ + useAppContext: jest.fn(() => ({ + userProfile: { + id: '8b18e24b-1ac8-4262-aa5c-e9aa95c76846', + name: 'test', + avatar: null, + avatar_url: null, + email: 'test@dify.ai', + is_password_set: false, + interface_language: 'zh-Hans', + interface_theme: 'light', + timezone: 'Asia/Shanghai', + last_login_at: 1744188761, + last_login_ip: '127.0.0.1', + created_at: 1728444483, + }, + })), +})) + +describe('useTimestamp', () => { + describe('formatTime', () => { + it('should format unix timestamp correctly', () => { + const { result } = renderHook(() => useTimestamp()) + const timestamp = 1704132000 + + expect(result.current.formatTime(timestamp, 'YYYY-MM-DD HH:mm:ss')) + .toBe('2024-01-02 02:00:00') + }) + + it('should format with different patterns', () => { + const { result } = renderHook(() => useTimestamp()) + const timestamp = 1704132000 + + expect(result.current.formatTime(timestamp, 'MM/DD/YYYY')) + .toBe('01/02/2024') + + expect(result.current.formatTime(timestamp, 'HH:mm')) + .toBe('02:00') + }) + }) + + describe('formatDate', () => { + it('should format date string correctly', () => { + const { result } = renderHook(() => useTimestamp()) + const dateString = '2024-01-01T12:00:00Z' + + expect(result.current.formatDate(dateString, 'YYYY-MM-DD HH:mm:ss')) + .toBe('2024-01-01 20:00:00') + }) + + it('should format with different patterns', () => { + const { result } = renderHook(() => useTimestamp()) + const dateString = '2024-01-01T12:00:00Z' + + expect(result.current.formatDate(dateString, 'MM/DD/YYYY')) + .toBe('01/01/2024') + + expect(result.current.formatDate(dateString, 'HH:mm')) + .toBe('20:00') + }) + }) +}) diff --git a/web/jest.config.ts b/web/jest.config.ts index 9164734d64..e29734fdef 100644 --- a/web/jest.config.ts +++ b/web/jest.config.ts @@ -147,7 +147,7 @@ const config: Config = { // setupFiles: [], // A list of paths to modules that run some code to configure or set up the testing framework before each test - // setupFilesAfterEnv: [], + setupFilesAfterEnv: ['/jest.setup.ts'], // The number of seconds after which a test is considered as slow and reported as such in the results. // slowTestThreshold: 5, diff --git a/web/jest.setup.ts b/web/jest.setup.ts new file mode 100644 index 0000000000..c44951a680 --- /dev/null +++ b/web/jest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom' diff --git a/web/utils/format.spec.ts b/web/utils/format.spec.ts index f349efa4e4..ab1c5c1610 100644 --- a/web/utils/format.spec.ts +++ b/web/utils/format.spec.ts @@ -1,4 +1,5 @@ -import { formatFileSize, formatNumber, formatTime } from './format' +import { downloadFile, formatFileSize, formatNumber, formatTime } from './format' + describe('formatNumber', () => { test('should correctly format integers', () => { expect(formatNumber(1234567)).toBe('1,234,567') @@ -59,3 +60,45 @@ describe('formatTime', () => { expect(formatTime(7200)).toBe('2.00 h') }) }) +describe('downloadFile', () => { + test('should create a link and trigger a download correctly', () => { + // Mock data + const blob = new Blob(['test content'], { type: 'text/plain' }) + const fileName = 'test-file.txt' + const mockUrl = 'blob:mockUrl' + + // Mock URL.createObjectURL + const createObjectURLMock = jest.fn().mockReturnValue(mockUrl) + const revokeObjectURLMock = jest.fn() + Object.defineProperty(window.URL, 'createObjectURL', { value: createObjectURLMock }) + Object.defineProperty(window.URL, 'revokeObjectURL', { value: revokeObjectURLMock }) + + // Mock createElement and appendChild + const mockLink = { + href: '', + download: '', + click: jest.fn(), + remove: jest.fn(), + } + const createElementMock = jest.spyOn(document, 'createElement').mockReturnValue(mockLink as any) + const appendChildMock = jest.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => { + return node + }) + + // Call the function + downloadFile({ data: blob, fileName }) + + // Assertions + expect(createObjectURLMock).toHaveBeenCalledWith(blob) + expect(createElementMock).toHaveBeenCalledWith('a') + expect(mockLink.href).toBe(mockUrl) + expect(mockLink.download).toBe(fileName) + expect(appendChildMock).toHaveBeenCalledWith(mockLink) + expect(mockLink.click).toHaveBeenCalled() + expect(mockLink.remove).toHaveBeenCalled() + expect(revokeObjectURLMock).toHaveBeenCalledWith(mockUrl) + + // Clean up mocks + jest.restoreAllMocks() + }) +}) diff --git a/web/utils/index.spec.ts b/web/utils/index.spec.ts new file mode 100644 index 0000000000..4785e55594 --- /dev/null +++ b/web/utils/index.spec.ts @@ -0,0 +1,295 @@ +import { + asyncRunSafe, + canFindTool, + correctModelProvider, + correctToolProvider, + fetchWithRetry, + getPurifyHref, + getTextWidthWithCanvas, + randomString, + removeSpecificQueryParam, + sleep, +} from './index' + +describe('sleep', () => { + it('should wait for the specified time', async () => { + const timeVariance = 10 + const sleepTime = 100 + const start = Date.now() + await sleep(sleepTime) + const elapsed = Date.now() - start + expect(elapsed).toBeGreaterThanOrEqual(sleepTime - timeVariance) + }) +}) + +describe('asyncRunSafe', () => { + it('should return [null, result] when promise resolves', async () => { + const result = await asyncRunSafe(Promise.resolve('success')) + expect(result).toEqual([null, 'success']) + }) + + it('should return [error] when promise rejects', async () => { + const error = new Error('test error') + const result = await asyncRunSafe(Promise.reject(error)) + expect(result).toEqual([error]) + }) + + it('should return [Error] when promise rejects with undefined', async () => { + // eslint-disable-next-line prefer-promise-reject-errors + const result = await asyncRunSafe(Promise.reject()) + expect(result[0]).toBeInstanceOf(Error) + expect(result[0]?.message).toBe('unknown error') + }) +}) + +describe('getTextWidthWithCanvas', () => { + let originalCreateElement: any + + beforeEach(() => { + // Store original implementation + originalCreateElement = document.createElement + + // Mock canvas and context + const measureTextMock = jest.fn().mockReturnValue({ width: 100 }) + const getContextMock = jest.fn().mockReturnValue({ + measureText: measureTextMock, + font: '', + }) + + document.createElement = jest.fn().mockReturnValue({ + getContext: getContextMock, + }) + }) + + afterEach(() => { + // Restore original implementation + document.createElement = originalCreateElement + }) + + it('should return the width of text', () => { + const width = getTextWidthWithCanvas('test text') + expect(width).toBe(100) + }) + + it('should return 0 if context is not available', () => { + // Override mock for this test + document.createElement = jest.fn().mockReturnValue({ + getContext: () => null, + }) + + const width = getTextWidthWithCanvas('test text') + expect(width).toBe(0) + }) +}) + +describe('randomString', () => { + it('should generate string of specified length', () => { + const result = randomString(10) + expect(result.length).toBe(10) + }) + + it('should only contain valid characters', () => { + const result = randomString(100) + const validChars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_' + for (const char of result) + expect(validChars).toContain(char) + }) + + it('should generate different strings on consecutive calls', () => { + const result1 = randomString(20) + const result2 = randomString(20) + expect(result1).not.toEqual(result2) + }) +}) + +describe('getPurifyHref', () => { + it('should return empty string for falsy input', () => { + expect(getPurifyHref('')).toBe('') + expect(getPurifyHref(undefined as any)).toBe('') + }) + + it('should escape HTML characters', () => { + expect(getPurifyHref('')).not.toContain('