diff --git a/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx b/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx index be9b18c13b..6adb5f5736 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx +++ b/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx @@ -17,7 +17,11 @@ vi.mock('@renderer/hooks/useSettings', () => ({ })) vi.mock('react-i18next', () => ({ - useTranslation: () => mockUseTranslation() + useTranslation: () => mockUseTranslation(), + initReactI18next: { + type: '3rdParty', + init: vi.fn() + } })) // Mock services diff --git a/src/renderer/src/utils/__tests__/download.test.ts b/src/renderer/src/utils/__tests__/download.test.ts new file mode 100644 index 0000000000..3a49ccf4fb --- /dev/null +++ b/src/renderer/src/utils/__tests__/download.test.ts @@ -0,0 +1,241 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Mock @renderer/i18n to avoid initialization issues +vi.mock('@renderer/i18n', () => ({ + default: { + t: vi.fn((key: string) => { + const translations: Record = { + 'message.download.failed': '下载失败', + 'message.download.failed.network': '下载失败,请检查网络' + } + return translations[key] || key + }) + } +})) + +import { download } from '../download' + +// Mock DOM 方法 +const mockCreateElement = vi.fn() +const mockAppendChild = vi.fn() +const mockClick = vi.fn() + +// Mock URL API +const mockCreateObjectURL = vi.fn() +const mockRevokeObjectURL = vi.fn() + +// Mock fetch +const mockFetch = vi.fn() + +// Mock window.message +const mockMessage = { + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + info: vi.fn() +} + +// 辅助函数 +const waitForAsync = () => new Promise((resolve) => setTimeout(resolve, 10)) +const createMockResponse = (options = {}) => ({ + ok: true, + headers: new Headers(), + blob: () => Promise.resolve(new Blob(['test'])), + ...options +}) + +describe('download', () => { + describe('download', () => { + beforeEach(() => { + vi.clearAllMocks() + + // 设置 window.message mock + Object.defineProperty(window, 'message', { value: mockMessage, writable: true }) + + // 设置 DOM mock + const mockElement = { + href: '', + download: '', + click: mockClick, + remove: vi.fn() + } + mockCreateElement.mockReturnValue(mockElement) + + Object.defineProperty(document, 'createElement', { value: mockCreateElement }) + Object.defineProperty(document.body, 'appendChild', { value: mockAppendChild }) + Object.defineProperty(URL, 'createObjectURL', { value: mockCreateObjectURL }) + Object.defineProperty(URL, 'revokeObjectURL', { value: mockRevokeObjectURL }) + + global.fetch = mockFetch + mockCreateObjectURL.mockReturnValue('blob:mock-url') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('Direct download support', () => { + it('should handle local file URLs', () => { + download('file:///path/to/document.pdf', 'test.pdf') + + const element = mockCreateElement.mock.results[0].value + expect(element.href).toBe('file:///path/to/document.pdf') + expect(element.download).toBe('test.pdf') + expect(mockClick).toHaveBeenCalled() + }) + + it('should handle blob URLs', () => { + download('blob:http://localhost:3000/12345') + + const element = mockCreateElement.mock.results[0].value + expect(element.href).toBe('blob:http://localhost:3000/12345') + expect(mockClick).toHaveBeenCalled() + }) + + it('should handle data URLs', () => { + const dataUrl = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==' + + download(dataUrl) + + const element = mockCreateElement.mock.results[0].value + expect(element.href).toBe(dataUrl) + expect(mockClick).toHaveBeenCalled() + }) + + it('should handle different MIME types in data URLs', async () => { + const now = Date.now() + vi.spyOn(Date, 'now').mockReturnValue(now) + + // 只有 image/png 和 image/jpeg 会直接下载 + const directDownloadTests = [ + { url: 'data:image/jpeg;base64,xxx', expectedExt: '.jpg' }, + { url: 'data:image/png;base64,xxx', expectedExt: '.png' } + ] + + directDownloadTests.forEach(({ url, expectedExt }) => { + mockCreateElement.mockClear() + download(url) + const element = mockCreateElement.mock.results[0].value + expect(element.download).toBe(`${now}_download${expectedExt}`) + }) + + // 其他类型会通过 fetch 处理 + mockCreateElement.mockClear() + mockFetch.mockResolvedValueOnce( + createMockResponse({ + headers: new Headers({ 'Content-Type': 'application/pdf' }) + }) + ) + + download('data:application/pdf;base64,xxx') + await waitForAsync() + + expect(mockFetch).toHaveBeenCalled() + }) + + it('should generate filename with timestamp for blob URLs', () => { + const now = Date.now() + vi.spyOn(Date, 'now').mockReturnValue(now) + + download('blob:http://localhost:3000/12345') + + const element = mockCreateElement.mock.results[0].value + expect(element.download).toBe(`${now}_diagram.svg`) + }) + }) + + describe('Filename handling', () => { + it('should extract filename from file path', () => { + download('file:///Users/test/Documents/report.pdf') + + const element = mockCreateElement.mock.results[0].value + expect(element.download).toBe('report.pdf') + }) + + it('should handle URL encoded filenames', () => { + download('file:///path/to/%E6%96%87%E6%A1%A3.pdf') // 编码的"文档.pdf" + + const element = mockCreateElement.mock.results[0].value + expect(element.download).toBe('文档.pdf') + }) + }) + + describe('Network download', () => { + it('should handle successful network request', async () => { + mockFetch.mockResolvedValue(createMockResponse()) + + download('https://example.com/file.pdf', 'custom.pdf') + await waitForAsync() + + expect(mockFetch).toHaveBeenCalledWith('https://example.com/file.pdf') + expect(mockCreateObjectURL).toHaveBeenCalledWith(expect.any(Blob)) + expect(mockClick).toHaveBeenCalled() + }) + + it('should extract filename from URL and headers', async () => { + const headers = new Headers() + headers.set('Content-Disposition', 'attachment; filename="server-file.pdf"') + mockFetch.mockResolvedValue(createMockResponse({ headers })) + + download('https://example.com/files/document.docx') + await waitForAsync() + + // 验证下载被触发(具体文件名由实现决定) + expect(mockClick).toHaveBeenCalled() + }) + + it('should add timestamp to network downloaded files', async () => { + const now = Date.now() + vi.spyOn(Date, 'now').mockReturnValue(now) + + mockFetch.mockResolvedValue(createMockResponse()) + + download('https://example.com/file.pdf') + await waitForAsync() + + const element = mockCreateElement.mock.results[0].value + expect(element.download).toBe(`${now}_file.pdf`) + }) + + it('should handle Content-Type when filename has no extension', async () => { + const headers = new Headers() + headers.set('Content-Type', 'application/pdf') + mockFetch.mockResolvedValue(createMockResponse({ headers })) + + download('https://example.com/download') + await waitForAsync() + + const element = mockCreateElement.mock.results[0].value + expect(element.download).toMatch(/\d+_download\.pdf$/) + }) + }) + + describe('Error handling', () => { + it('should handle network errors gracefully', async () => { + const networkError = new Error('Network error') + mockFetch.mockRejectedValue(networkError) + + expect(() => download('https://example.com/file.pdf')).not.toThrow() + await waitForAsync() + + expect(mockMessage.error).toHaveBeenCalledWith('下载失败:Network error') + }) + + it('should handle fetch errors without message', async () => { + mockFetch.mockRejectedValue(new Error()) + + expect(() => download('https://example.com/file.pdf')).not.toThrow() + await waitForAsync() + + expect(mockMessage.error).toHaveBeenCalledWith('下载失败,请检查网络') + }) + + it('should handle HTTP errors gracefully', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 404 }) + + expect(() => download('https://example.com/file.pdf')).not.toThrow() + }) + }) + }) +}) diff --git a/src/renderer/src/utils/__tests__/fetch.test.ts b/src/renderer/src/utils/__tests__/fetch.test.ts new file mode 100644 index 0000000000..6b36cb41f8 --- /dev/null +++ b/src/renderer/src/utils/__tests__/fetch.test.ts @@ -0,0 +1,208 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Mock 外部依赖 +vi.mock('turndown', () => ({ + default: vi.fn(() => ({ + turndown: vi.fn(() => '# Test content') + })) +})) +vi.mock('@mozilla/readability', () => ({ + Readability: vi.fn(() => ({ + parse: vi.fn(() => ({ + title: 'Test Article', + content: '

Test content

', + textContent: 'Test content' + })) + })) +})) +vi.mock('@reduxjs/toolkit', () => ({ + nanoid: vi.fn(() => 'test-id') +})) + +import { fetchRedirectUrl, fetchWebContent, fetchWebContents } from '../fetch' + +// 设置基础 mocks +global.DOMParser = vi.fn().mockImplementation(() => ({ + parseFromString: vi.fn(() => ({})) +})) as any + +global.window = { + api: { + searchService: { + openUrlInSearchWindow: vi.fn() + } + } +} as any + +// 辅助函数 +const createMockResponse = (overrides = {}) => + ({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue('Test content'), + ...overrides + }) as unknown as Response + +describe('fetch', () => { + beforeEach(() => { + // Mock fetch 和 AbortSignal + global.fetch = vi.fn() + global.AbortSignal = { + timeout: vi.fn(() => ({})), + any: vi.fn(() => ({})) + } as any + + // 清理 mock 调用历史 + vi.clearAllMocks() + }) + + describe('fetchWebContent', () => { + it('should fetch and return content successfully', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse()) + + const result = await fetchWebContent('https://example.com') + + expect(result).toEqual({ + title: 'Test Article', + url: 'https://example.com', + content: '# Test content' + }) + expect(global.fetch).toHaveBeenCalledWith('https://example.com', expect.any(Object)) + }) + + it('should use browser mode when specified', async () => { + vi.mocked(window.api.searchService.openUrlInSearchWindow).mockResolvedValueOnce( + 'Browser content' + ) + + const result = await fetchWebContent('https://example.com', 'markdown', true) + + expect(result.content).toBe('# Test content') + expect(window.api.searchService.openUrlInSearchWindow).toHaveBeenCalled() + }) + + it('should handle errors gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + // 无效 URL + const invalidResult = await fetchWebContent('not-a-url') + expect(invalidResult.content).toBe('No content found') + + // 网络错误 + vi.mocked(global.fetch).mockRejectedValueOnce(new Error('Network error')) + const networkResult = await fetchWebContent('https://example.com') + expect(networkResult.content).toBe('No content found') + + consoleSpy.mockRestore() + }) + + it('should rethrow abort errors', async () => { + const abortError = new DOMException('Aborted', 'AbortError') + vi.mocked(global.fetch).mockRejectedValueOnce(abortError) + + await expect(fetchWebContent('https://example.com')).rejects.toThrow(DOMException) + }) + + it.each([ + ['markdown', '# Test content'], + ['html', '

Test content

'], + ['text', 'Test content'] + ])('should return %s format correctly', async (format, expectedContent) => { + vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse()) + + const result = await fetchWebContent('https://example.com', format as any) + + expect(result.content).toBe(expectedContent) + expect(result.title).toBe('Test Article') + expect(result.url).toBe('https://example.com') + }) + + it('should handle timeout signal in AbortSignal.any', async () => { + const mockTimeoutSignal = new AbortController().signal + vi.spyOn(global.AbortSignal, 'timeout').mockReturnValue(mockTimeoutSignal) + + vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse()) + + await fetchWebContent('https://example.com') + + // 验证 AbortSignal.timeout 是否被调用,并传入 30000ms + expect(global.AbortSignal.timeout).toHaveBeenCalledWith(30000) + + vi.spyOn(global.AbortSignal, 'timeout').mockRestore() + }) + + it('should combine user signal with timeout signal', async () => { + const userController = new AbortController() + const mockAnyCalls: any[] = [] + + vi.spyOn(global.AbortSignal, 'any').mockImplementation((signals) => { + mockAnyCalls.push(signals) + return new AbortController().signal + }) + + vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse()) + + await fetchWebContent('https://example.com', 'markdown', false, { + signal: userController.signal + }) + + // 验证 AbortSignal.any 是否被调用,并传入两个信号 + expect(mockAnyCalls).toHaveLength(1) + expect(mockAnyCalls[0]).toHaveLength(2) + expect(mockAnyCalls[0]).toContain(userController.signal) + + vi.spyOn(global.AbortSignal, 'any').mockRestore() + }) + }) + + describe('fetchWebContents', () => { + it('should fetch multiple URLs in parallel', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse()).mockResolvedValueOnce(createMockResponse()) + + const urls = ['https://example1.com', 'https://example2.com'] + const results = await fetchWebContents(urls) + + expect(results).toHaveLength(2) + expect(results[0].content).toBe('# Test content') + expect(results[1].content).toBe('# Test content') + }) + + it('should handle partial failures gracefully', async () => { + vi.mocked(global.fetch) + .mockResolvedValueOnce(createMockResponse()) + .mockRejectedValueOnce(new Error('Network error')) + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const results = await fetchWebContents(['https://success.com', 'https://fail.com']) + + expect(results).toHaveLength(2) + expect(results[0].content).toBe('# Test content') + expect(results[1].content).toBe('No content found') + + consoleSpy.mockRestore() + }) + }) + + describe('fetchRedirectUrl', () => { + it('should return final redirect URL', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce({ + url: 'https://redirected.com/final' + } as any) + + const result = await fetchRedirectUrl('https://example.com') + + expect(result).toBe('https://redirected.com/final') + expect(global.fetch).toHaveBeenCalledWith('https://example.com', expect.any(Object)) + }) + + it('should return original URL on error', async () => { + vi.mocked(global.fetch).mockRejectedValueOnce(new Error('Network error')) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const result = await fetchRedirectUrl('https://example.com') + expect(result).toBe('https://example.com') + + consoleSpy.mockRestore() + }) + }) +}) diff --git a/src/renderer/src/utils/download.ts b/src/renderer/src/utils/download.ts index 5e207eff67..454c1ac82a 100644 --- a/src/renderer/src/utils/download.ts +++ b/src/renderer/src/utils/download.ts @@ -1,3 +1,5 @@ +import i18n from '@renderer/i18n' + export const download = (url: string, filename?: string) => { // 处理可直接通过 标签下载的 URL: // - 本地文件 ( file:// ) @@ -76,6 +78,15 @@ export const download = (url: string, filename?: string) => { URL.revokeObjectURL(blobUrl) link.remove() }) + .catch((error) => { + console.error('Download failed:', error) + // 显示用户友好的错误提示 + if (error.message) { + window.message?.error(`${i18n.t('message.download.failed')}:${error.message}`) + } else { + window.message?.error(i18n.t('message.download.failed.network')) + } + }) } // 辅助函数:根据MIME类型获取文件扩展名