Markdown and image previews were rendering blank because @monaco-editor/react loads its core assets from cdn.jsdelivr.net at runtime, which the deployment server's network can't reach. Switching to @uiw/react-codemirror removes the CDN dependency entirely (all assets ship in the build) and cuts bundle size from ~2.5MB to ~770KB. Also add proper image/* content-type detection so images render via <img> instead of being force-decoded as editor text.
239 lines
8.1 KiB
TypeScript
239 lines
8.1 KiB
TypeScript
import { render, screen, fireEvent } from '@testing-library/react';
|
|
import { test, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import App from '../src/App';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { waitFor } from '@testing-library/react';
|
|
import { listDocuments, getDocument, search, uploadDocument } from '../src/api/client';
|
|
|
|
vi.mock('../src/api/client');
|
|
|
|
vi.mock('@uiw/react-codemirror', () => ({
|
|
default: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
|
|
<textarea
|
|
data-testid="codemirror-mock"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
/>
|
|
),
|
|
}));
|
|
|
|
vi.mock('@codemirror/lang-markdown', () => ({ markdown: () => ({}) }));
|
|
vi.mock('@codemirror/lang-html', () => ({ html: () => ({}) }));
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(listDocuments).mockReset();
|
|
vi.mocked(getDocument).mockReset();
|
|
vi.mocked(search).mockReset();
|
|
vi.mocked(uploadDocument).mockReset();
|
|
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
|
vi.mocked(getDocument).mockResolvedValue({
|
|
key: '',
|
|
content: '',
|
|
contentType: 'text/markdown',
|
|
encoding: 'utf-8',
|
|
});
|
|
vi.mocked(search).mockResolvedValue({ results: [] });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
test('renders the DocHub heading', () => {
|
|
render(<App />);
|
|
expect(screen.getByRole('heading', { name: 'DocHub' })).toBeInTheDocument();
|
|
});
|
|
|
|
test('shows an empty state and a file tree when no document is selected', async () => {
|
|
vi.mocked(listDocuments).mockResolvedValue({
|
|
folders: [],
|
|
files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 100, modifiedAt: '2026-08-01T10:00:00Z' }],
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
|
|
expect(await screen.findByText('架构设计.md')).toBeInTheDocument();
|
|
});
|
|
|
|
test('selecting a file from the tree loads it into the editor', async () => {
|
|
vi.mocked(listDocuments).mockResolvedValue({
|
|
folders: [],
|
|
files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 100, modifiedAt: '2026-08-01T10:00:00Z' }],
|
|
});
|
|
vi.mocked(getDocument).mockResolvedValue({
|
|
key: '产品文档/架构设计.md',
|
|
content: '# 架构设计',
|
|
contentType: 'text/markdown',
|
|
encoding: 'utf-8',
|
|
});
|
|
const user = userEvent.setup();
|
|
|
|
render(<App />);
|
|
|
|
const fileRow = await screen.findByText('架构设计.md');
|
|
await user.click(fileRow);
|
|
|
|
const textarea = await screen.findByTestId('codemirror-mock');
|
|
expect(textarea).toHaveValue('# 架构设计');
|
|
});
|
|
|
|
test('searching shows the results overlay, and clicking a result opens the document', async () => {
|
|
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
|
vi.mocked(search).mockResolvedValue({
|
|
results: [{ key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...<em>检索</em>...' }],
|
|
});
|
|
vi.mocked(getDocument).mockResolvedValue({
|
|
key: '产品文档/架构设计.md',
|
|
content: '# 架构设计',
|
|
contentType: 'text/markdown',
|
|
encoding: 'utf-8',
|
|
});
|
|
const user = userEvent.setup();
|
|
|
|
render(<App />);
|
|
|
|
const searchInput = screen.getByRole('textbox', { name: '搜索文档' });
|
|
await user.type(searchInput, '检索');
|
|
|
|
await waitFor(
|
|
() => {
|
|
expect(screen.getByTestId('search-result-产品文档/架构设计.md')).toBeInTheDocument();
|
|
},
|
|
{ timeout: 1000 }
|
|
);
|
|
|
|
await user.click(screen.getByTestId('search-result-产品文档/架构设计.md'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByTestId('search-results')).not.toBeInTheDocument();
|
|
});
|
|
const textarea = await screen.findByTestId('codemirror-mock');
|
|
expect(textarea).toHaveValue('# 架构设计');
|
|
});
|
|
|
|
test('jumping to a search result clears the search input so it does not show stale text', async () => {
|
|
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
|
vi.mocked(search).mockResolvedValue({
|
|
results: [{ key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...<em>检索</em>...' }],
|
|
});
|
|
vi.mocked(getDocument).mockResolvedValue({
|
|
key: '产品文档/架构设计.md',
|
|
content: '# 架构设计',
|
|
contentType: 'text/markdown',
|
|
encoding: 'utf-8',
|
|
});
|
|
const user = userEvent.setup();
|
|
|
|
render(<App />);
|
|
|
|
const searchInput = screen.getByRole('textbox', { name: '搜索文档' }) as HTMLInputElement;
|
|
await user.type(searchInput, '检索');
|
|
|
|
await waitFor(
|
|
() => {
|
|
expect(screen.getByTestId('search-result-产品文档/架构设计.md')).toBeInTheDocument();
|
|
},
|
|
{ timeout: 1000 }
|
|
);
|
|
|
|
await user.click(screen.getByTestId('search-result-产品文档/架构设计.md'));
|
|
|
|
await waitFor(() => {
|
|
expect(searchInput).toHaveValue('');
|
|
});
|
|
});
|
|
|
|
test('typing a query does not show a false "no results" message before the debounced search resolves', async () => {
|
|
vi.useFakeTimers();
|
|
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
|
vi.mocked(search).mockResolvedValue({ results: [] });
|
|
|
|
render(<App />);
|
|
|
|
const searchInput = screen.getByRole('textbox', { name: '搜索文档' });
|
|
fireEvent.change(searchInput, { target: { value: '检索' } });
|
|
|
|
expect(screen.queryByTestId('search-no-results')).not.toBeInTheDocument();
|
|
|
|
await vi.advanceTimersByTimeAsync(300);
|
|
|
|
expect(screen.getByTestId('search-no-results')).toBeInTheDocument();
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('switching files while the current document is dirty prompts for confirmation', async () => {
|
|
vi.mocked(listDocuments).mockResolvedValue({
|
|
folders: [],
|
|
files: [
|
|
{ key: '文档A.md', name: '文档A.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' },
|
|
{ key: '文档B.md', name: '文档B.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' },
|
|
],
|
|
});
|
|
vi.mocked(getDocument).mockImplementation((key: string) =>
|
|
Promise.resolve({ key, content: `内容-${key}`, contentType: 'text/markdown', encoding: 'utf-8' as const })
|
|
);
|
|
const user = userEvent.setup();
|
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
|
|
|
render(<App />);
|
|
|
|
await user.click(await screen.findByText('文档A.md'));
|
|
const textarea = await screen.findByTestId('codemirror-mock');
|
|
await user.type(textarea, '修改内容');
|
|
|
|
await user.click(screen.getByText('文档B.md'));
|
|
|
|
expect(confirmSpy).toHaveBeenCalled();
|
|
expect(await screen.findByTestId('codemirror-mock')).toHaveValue('内容-文档A.md修改内容');
|
|
});
|
|
|
|
test('confirming the discard prompt proceeds to switch files', async () => {
|
|
vi.mocked(listDocuments).mockResolvedValue({
|
|
folders: [],
|
|
files: [
|
|
{ key: '文档A.md', name: '文档A.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' },
|
|
{ key: '文档B.md', name: '文档B.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' },
|
|
],
|
|
});
|
|
vi.mocked(getDocument).mockImplementation((key: string) =>
|
|
Promise.resolve({ key, content: `内容-${key}`, contentType: 'text/markdown', encoding: 'utf-8' as const })
|
|
);
|
|
const user = userEvent.setup();
|
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
|
|
|
render(<App />);
|
|
|
|
await user.click(await screen.findByText('文档A.md'));
|
|
const textarea = await screen.findByTestId('codemirror-mock');
|
|
await user.type(textarea, '修改内容');
|
|
|
|
await user.click(screen.getByText('文档B.md'));
|
|
|
|
await waitFor(async () => {
|
|
expect(await screen.findByTestId('codemirror-mock')).toHaveValue('内容-文档B.md');
|
|
});
|
|
});
|
|
|
|
test('a successful upload refreshes the file tree', async () => {
|
|
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
|
vi.mocked(uploadDocument).mockResolvedValue({ key: '新文档.md', name: '新文档.md', size: 5 });
|
|
const user = userEvent.setup();
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(listDocuments).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
await user.click(screen.getByRole('button', { name: '上传文档' }));
|
|
const file = new File(['# 新文档'], '新文档.md', { type: 'text/markdown' });
|
|
const input = screen.getByTestId('upload-file-input');
|
|
await user.upload(input, file);
|
|
|
|
await waitFor(() => {
|
|
expect(listDocuments).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|