Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions src/components/ReadmeModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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<typeof vi.fn>).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<typeof vi.fn>;

const mockRepository: Repository = {
id: 1,
name: 'demo',
Expand All @@ -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<typeof vi.fn>).mockResolvedValue('Default README content');
(backend.getRepositoryReadmeByPath as ReturnType<typeof vi.fn>).mockResolvedValue('中文 README 内容');
(backend.listRepositoryReadmeCandidates as ReturnType<typeof vi.fn>).mockResolvedValue([
Expand Down Expand Up @@ -104,9 +142,79 @@ describe('ReadmeModal multilingual README switching', () => {
render(<ReadmeModal isOpen onClose={vi.fn()} repository={mockRepository} />);

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<typeof vi.fn>).mockRejectedValue(new Error('GitHub token not configured'));
mockGitHubApi.getRepositoryReadme.mockResolvedValue('Direct README content');

render(<ReadmeModal isOpen onClose={vi.fn()} repository={mockRepository} />);

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<typeof vi.fn>).mockResolvedValue('');

render(<ReadmeModal isOpen onClose={vi.fn()} repository={mockRepository} />);

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<typeof vi.fn>).mockRejectedValue(new Error('GitHub token not configured'));
mockGitHubApi.getRepositoryReadmeByPath.mockResolvedValue('Direct 中文 README 内容');

render(<ReadmeModal isOpen onClose={vi.fn()} repository={mockRepository} />);

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<typeof vi.fn>).mockRejectedValue(new Error('GitHub token not configured'));
mockGitHubApi.listRepositoryReadmeCandidates.mockResolvedValue([
{ path: 'README.md', type: 'blob' },
{ path: 'README_zh.md', type: 'blob' },
]);

render(<ReadmeModal isOpen onClose={vi.fn()} repository={mockRepository} />);

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();
}
});
});
99 changes: 70 additions & 29 deletions src/components/ReadmeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReadmeModalProps> = ({
isOpen,
onClose,
Expand Down Expand Up @@ -316,6 +320,68 @@ export const ReadmeModal: React.FC<ReadmeModalProps> = ({
scrollToTop();
}, [resetTranslationState, scrollToTop]);

const fetchReadmeContentFromAvailableSource = useCallback(async (
owner: string,
name: string,
variant: ReadmeVariant,
signal: AbortSignal
): Promise<string> => {
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<GitHubReadmeCandidateItem[]> => {
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;

Expand All @@ -330,23 +396,7 @@ export const ReadmeModal: React.FC<ReadmeModalProps> = ({

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;

Expand Down Expand Up @@ -374,7 +424,7 @@ export const ReadmeModal: React.FC<ReadmeModalProps> = ({
setLoading(false);
}
}
}, [repository, githubToken, language]);
}, [repository, fetchReadmeContentFromAvailableSource, language]);

const fetchReadmeVariants = useCallback(async () => {
if (!repository) return;
Expand All @@ -390,16 +440,7 @@ export const ReadmeModal: React.FC<ReadmeModalProps> = ({
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));
Expand All @@ -413,7 +454,7 @@ export const ReadmeModal: React.FC<ReadmeModalProps> = ({
setVariantsLoading(false);
}
}
}, [repository, githubToken, language, defaultReadmeVariant]);
}, [repository, fetchReadmeCandidatesFromAvailableSource, language, defaultReadmeVariant]);

const fetchReadme = useCallback(async () => {
const currentVariant = readmeVariants.find(variant => variant.key === selectedReadmeKey) || defaultReadmeVariant;
Expand Down