From b5641aa441661f1d8ac4ac3536fc03edc2c1eed9 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Mon, 8 Jun 2026 16:01:42 +0800 Subject: [PATCH] fix: fallback to direct README fetch when backend proxy fails Co-Authored-By: Claude Opus 4.8 --- src/components/ReadmeModal.test.tsx | 108 ++++++++++++++++++++++++++++ src/components/ReadmeModal.tsx | 99 +++++++++++++++++-------- 2 files changed, 178 insertions(+), 29 deletions(-) diff --git a/src/components/ReadmeModal.test.tsx b/src/components/ReadmeModal.test.tsx index a52568ae..7b3f064e 100644 --- a/src/components/ReadmeModal.test.tsx +++ b/src/components/ReadmeModal.test.tsx @@ -2,6 +2,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ReadmeModal } from './ReadmeModal'; import { backend } from '../services/backendAdapter'; +import { GitHubApiService } from '../services/githubApi'; +import { useAppStore } from '../store/useAppStore'; import type { Repository } from '../types'; vi.mock('./BilingualMarkdownRenderer', async () => { @@ -23,6 +25,33 @@ vi.mock('../services/backendAdapter', () => ({ }, })); +vi.mock('../services/githubApi', () => ({ + GitHubApiService: vi.fn(), +})); + +vi.mock('../store/useAppStore', () => ({ + useAppStore: vi.fn(), +})); + +const mockGitHubApi = { + getRepositoryReadme: vi.fn(), + getRepositoryReadmeByPath: vi.fn(), + listRepositoryReadmeCandidates: vi.fn(), +}; + +const setMockStore = (githubToken: string | null = null) => { + (useAppStore as unknown as ReturnType).mockImplementation((selector?: (state: unknown) => unknown) => { + const state = { + language: 'zh', + githubToken, + setReadmeModalOpen: vi.fn(), + }; + return selector ? selector(state) : state; + }); +}; + +const mockedGitHubApiService = GitHubApiService as unknown as ReturnType; + const mockRepository: Repository = { id: 1, name: 'demo', @@ -46,6 +75,15 @@ const mockRepository: Repository = { describe('ReadmeModal multilingual README switching', () => { beforeEach(() => { vi.clearAllMocks(); + (backend as { isAvailable: boolean }).isAvailable = true; + setMockStore(); + mockGitHubApi.getRepositoryReadme.mockResolvedValue('Direct README content'); + mockGitHubApi.getRepositoryReadmeByPath.mockResolvedValue('Direct localized README content'); + mockGitHubApi.listRepositoryReadmeCandidates.mockResolvedValue([ + { path: 'README.md', type: 'blob' }, + { path: 'README_zh.md', type: 'blob' }, + ]); + mockedGitHubApiService.mockImplementation(() => mockGitHubApi); (backend.getRepositoryReadme as ReturnType).mockResolvedValue('Default README content'); (backend.getRepositoryReadmeByPath as ReturnType).mockResolvedValue('中文 README 内容'); (backend.listRepositoryReadmeCandidates as ReturnType).mockResolvedValue([ @@ -104,9 +142,79 @@ describe('ReadmeModal multilingual README switching', () => { render(); expect(await screen.findByText('GitHub token not configured')).toBeInTheDocument(); + expect(mockGitHubApi.getRepositoryReadme).not.toHaveBeenCalled(); expect(screen.queryByText('该仓库没有 README 文件')).not.toBeInTheDocument(); } finally { consoleErrorSpy.mockRestore(); } }); + + it('falls back to the direct GitHub API when backend README proxy fails and a token exists', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + setMockStore('github-token'); + (backend.getRepositoryReadme as ReturnType).mockRejectedValue(new Error('GitHub token not configured')); + mockGitHubApi.getRepositoryReadme.mockResolvedValue('Direct README content'); + + render(); + + expect(await screen.findByText('Direct README content')).toBeInTheDocument(); + expect(mockedGitHubApiService).toHaveBeenCalledWith('github-token'); + expect(mockGitHubApi.getRepositoryReadme).toHaveBeenCalledWith('owner', 'demo', expect.any(AbortSignal)); + } finally { + consoleWarnSpy.mockRestore(); + } + }); + + it('does not use direct fallback when the backend reports README is missing', async () => { + setMockStore('github-token'); + (backend.getRepositoryReadme as ReturnType).mockResolvedValue(''); + + render(); + + expect(await screen.findByText('该仓库没有 README 文件')).toBeInTheDocument(); + expect(mockGitHubApi.getRepositoryReadme).not.toHaveBeenCalled(); + }); + + it('falls back to the direct GitHub API when a selected README path fails through backend', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + setMockStore('github-token'); + (backend.getRepositoryReadmeByPath as ReturnType).mockRejectedValue(new Error('GitHub token not configured')); + mockGitHubApi.getRepositoryReadmeByPath.mockResolvedValue('Direct 中文 README 内容'); + + render(); + + await screen.findByText('Default README content'); + const selector = await screen.findByLabelText('切换 README 语言'); + fireEvent.change(selector, { target: { value: 'README_zh.md' } }); + + expect(await screen.findByText('Direct 中文 README 内容')).toBeInTheDocument(); + expect(mockGitHubApi.getRepositoryReadmeByPath).toHaveBeenCalledWith('owner', 'demo', 'README_zh.md', expect.any(AbortSignal)); + } finally { + consoleWarnSpy.mockRestore(); + } + }); + + it('falls back to direct README variant detection when backend candidate listing fails', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + setMockStore('github-token'); + (backend.listRepositoryReadmeCandidates as ReturnType).mockRejectedValue(new Error('GitHub token not configured')); + mockGitHubApi.listRepositoryReadmeCandidates.mockResolvedValue([ + { path: 'README.md', type: 'blob' }, + { path: 'README_zh.md', type: 'blob' }, + ]); + + render(); + + expect(await screen.findByText('Default README content')).toBeInTheDocument(); + const selector = await screen.findByLabelText('切换 README 语言'); + expect(selector).toBeInTheDocument(); + expect(screen.getByRole('option', { name: '中文 · README_zh.md' })).toBeInTheDocument(); + expect(mockGitHubApi.listRepositoryReadmeCandidates).toHaveBeenCalledWith('owner', 'demo', undefined, expect.any(AbortSignal)); + } finally { + consoleWarnSpy.mockRestore(); + } + }); }); diff --git a/src/components/ReadmeModal.tsx b/src/components/ReadmeModal.tsx index a8def681..ca7737f4 100644 --- a/src/components/ReadmeModal.tsx +++ b/src/components/ReadmeModal.tsx @@ -33,6 +33,10 @@ const getDefaultReadmeVariant = (language: 'zh' | 'en'): ReadmeVariant => ({ label: language === 'zh' ? '默认 README' : 'Default README', }); +const isAbortError = (error: unknown, signal?: AbortSignal): boolean => { + return Boolean(signal?.aborted || (error as { name?: string })?.name === 'AbortError'); +}; + export const ReadmeModal: React.FC = ({ isOpen, onClose, @@ -316,6 +320,68 @@ export const ReadmeModal: React.FC = ({ scrollToTop(); }, [resetTranslationState, scrollToTop]); + const fetchReadmeContentFromAvailableSource = useCallback(async ( + owner: string, + name: string, + variant: ReadmeVariant, + signal: AbortSignal + ): Promise => { + const fetchFromGitHubApi = async () => { + if (!githubToken) { + throw new Error(language === 'zh' ? '未登录且后端不可用,无法加载 README' : 'Not logged in and backend unavailable, cannot load README'); + } + const githubApi = new GitHubApiService(githubToken); + return variant.isDefault || !variant.path + ? githubApi.getRepositoryReadme(owner, name, signal) + : githubApi.getRepositoryReadmeByPath(owner, name, variant.path, signal); + }; + + if (!backend.isAvailable) { + return fetchFromGitHubApi(); + } + + try { + return variant.isDefault || !variant.path + ? await backend.getRepositoryReadme(owner, name, signal) + : await backend.getRepositoryReadmeByPath(owner, name, variant.path, signal); + } catch (backendError) { + if (isAbortError(backendError, signal) || !githubToken) { + throw backendError; + } + + console.warn('Falling back to direct GitHub README fetch after backend failure:', backendError); + return fetchFromGitHubApi(); + } + }, [githubToken, language]); + + const fetchReadmeCandidatesFromAvailableSource = useCallback(async ( + owner: string, + name: string, + defaultBranch: string | undefined, + signal: AbortSignal + ): Promise => { + const fetchFromGitHubApi = async () => { + if (!githubToken) return []; + const githubApi = new GitHubApiService(githubToken); + return githubApi.listRepositoryReadmeCandidates(owner, name, defaultBranch, signal); + }; + + if (!backend.isAvailable) { + return fetchFromGitHubApi(); + } + + try { + return await backend.listRepositoryReadmeCandidates(owner, name, defaultBranch, signal); + } catch (backendError) { + if (isAbortError(backendError, signal) || !githubToken) { + throw backendError; + } + + console.warn('Falling back to direct GitHub README variant detection after backend failure:', backendError); + return fetchFromGitHubApi(); + } + }, [githubToken]); + const fetchReadmeContent = useCallback(async (variant: ReadmeVariant) => { if (!repository) return; @@ -330,23 +396,7 @@ export const ReadmeModal: React.FC = ({ try { const [owner, name] = repository.full_name.split('/'); - let content = ''; - - if (backend.isAvailable) { - content = variant.isDefault || !variant.path - ? await backend.getRepositoryReadme(owner, name, abortController.signal) - : await backend.getRepositoryReadmeByPath(owner, name, variant.path, abortController.signal); - } else if (githubToken) { - const githubApi = new GitHubApiService(githubToken); - content = variant.isDefault || !variant.path - ? await githubApi.getRepositoryReadme(owner, name, abortController.signal) - : await githubApi.getRepositoryReadmeByPath(owner, name, variant.path, abortController.signal); - } else { - setReadmeContent(''); - setError(language === 'zh' ? '未登录且后端不可用,无法加载 README' : 'Not logged in and backend unavailable, cannot load README'); - setLoading(false); - return; - } + const content = await fetchReadmeContentFromAvailableSource(owner, name, variant, abortController.signal); if (abortController.signal.aborted) return; @@ -374,7 +424,7 @@ export const ReadmeModal: React.FC = ({ setLoading(false); } } - }, [repository, githubToken, language]); + }, [repository, fetchReadmeContentFromAvailableSource, language]); const fetchReadmeVariants = useCallback(async () => { if (!repository) return; @@ -390,16 +440,7 @@ export const ReadmeModal: React.FC = ({ try { const [owner, name] = repository.full_name.split('/'); const defaultBranch = (repository as Repository & { default_branch?: string }).default_branch; - let candidates: GitHubReadmeCandidateItem[] = []; - - if (backend.isAvailable) { - candidates = await backend.listRepositoryReadmeCandidates(owner, name, defaultBranch, abortController.signal); - } else if (githubToken) { - const githubApi = new GitHubApiService(githubToken); - candidates = await githubApi.listRepositoryReadmeCandidates(owner, name, defaultBranch, abortController.signal); - } else { - return; - } + const candidates = await fetchReadmeCandidatesFromAvailableSource(owner, name, defaultBranch, abortController.signal); if (abortController.signal.aborted) return; setReadmeVariants(buildReadmeVariants(candidates, language)); @@ -413,7 +454,7 @@ export const ReadmeModal: React.FC = ({ setVariantsLoading(false); } } - }, [repository, githubToken, language, defaultReadmeVariant]); + }, [repository, fetchReadmeCandidatesFromAvailableSource, language, defaultReadmeVariant]); const fetchReadme = useCallback(async () => { const currentVariant = readmeVariants.find(variant => variant.key === selectedReadmeKey) || defaultReadmeVariant;