// @vitest-environment jsdom import { render } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { ChatMarkdownMessage, MarkdownErrorBoundary, } from '../src/components/ChatMarkdownMessage'; function FailingChild({ shouldThrow }: { shouldThrow: boolean }) { if (shouldThrow) { throw new Error('transient render failure'); } return markdown recovered; } describe('ChatMarkdownMessage', () => { it('代码块带语言标识及高亮节点,文件预览保留空行和 HTML 源码', () => { const source = 'const value = "";\n\n\n return value;\n'; const { container } = render( , ); expect(container.querySelector('pre code')?.textContent).toBe(source); expect( container.querySelector('code.language-typescript .hljs-keyword'), ).not.toBeNull(); expect(container.querySelector('script')).toBeNull(); }); it('未知语言保留代码而不进入错误回退', () => { const { container } = render( , ); expect(container.querySelector('pre code')?.textContent).toBe('原始内容\n'); }); it('大文件跳过高亮但保留完整代码', () => { const source = 'const value = 42;\n'.repeat(6000); const { container } = render( , ); expect(container.querySelector('pre code')?.textContent).toBe(source); expect(container.querySelector('.hljs-keyword')).toBeNull(); }); it('渲染 assistant 的 GFM 内容与代码块', () => { const { container } = render( , ); expect(container.querySelector('h2')?.textContent).toBe('标题'); expect(container.querySelectorAll('li')).toHaveLength(2); expect(container.querySelector('pre code')?.textContent).toContain( 'const answer = 42;', ); }); it('无序列表用真正的列表标记并逐层缩进,不再手写短横线', () => { const { container } = render( , ); const lists = container.querySelectorAll('ul'); expect(lists).toHaveLength(3); // 真正的列表标记:靠 `list-disc` 画项目符号、`pl-4` 让换行后的第二行保持悬挂缩进。 for (const list of Array.from(lists)) { expect(list.className).toContain('list-disc'); expect(list.className).toContain('pl-4'); expect(list.className).not.toContain('list-none'); } // 列表项文本里不能再出现手写的 `- ` 前缀(那会让换行行顶回最左边,也不符合列表语义)。 for (const item of Array.from(container.querySelectorAll('ul > li'))) { expect(item.textContent ?? '').not.toMatch(/^\s*-\s/); } expect(container.querySelector('ul > li')?.textContent).toBe('无序号 A'); }); it('为四到六级标题提供递进的字号与字重', () => { const { container } = render( , ); expect(container.querySelector('h4')?.className).toContain('font-semibold'); expect(container.querySelector('h5')?.className).toContain('font-medium'); expect(container.querySelector('h6')?.className).toContain('tracking-wide'); }); it('有序列表不添加无序列表短横线,列表项段落与文本同行', () => { const { container } = render( , ); const items = container.querySelectorAll('ol > li'); expect(items[0]?.textContent?.trim()).toBe('第一件事'); expect(items[1]?.textContent?.trim()).toContain('第二件事'); expect(items[0]?.querySelector('p')?.className ?? '').toContain('inline'); expect(items[0]?.className).toContain('whitespace-normal'); expect(items[0]?.textContent).not.toContain('-'); }); it('保留 Markdown 有序列表的起始编号', () => { const { container } = render( , ); expect(container.querySelector('ol')?.getAttribute('start')).toBe('3'); expect(container.querySelectorAll('ol > li')).toHaveLength(2); }); it('为列表项后续段落保留段落间距', () => { const { container } = render( , ); const paragraphs = container.querySelectorAll('ul > li p'); expect(paragraphs).toHaveLength(2); expect(paragraphs[0]?.className).toContain('inline'); expect(paragraphs[1]?.className).toContain('mt-2'); expect(paragraphs[1]?.className).not.toContain('inline'); }); it('不会把 react-markdown 的 node 元数据泄漏到代码节点', () => { const { container } = render( , ); expect( container.querySelector('pre code')?.getAttribute('node'), ).toBeNull(); }); it('无语言标记的多行围栏代码仍使用代码块样式', () => { const { container } = render( , ); const code = container.querySelector('pre code'); expect(code?.className).toContain('whitespace-pre'); expect(code?.className).not.toContain('rounded'); }); it('保留行内代码中的 HTML 字面量', () => { const { container } = render( `'} />, ); expect(container.querySelector('code')?.textContent).toBe(''); }); it('保留围栏代码中的 HTML 字面量而不双重转义', () => { const { container } = render( \n```'} />, ); expect(container.querySelector('pre code')?.textContent).toContain( '
', ); expect(container.querySelector('pre code')?.textContent).not.toContain( '<div', ); }); it('累计文本变化后可从 Markdown 错误回退中恢复', () => { const consoleError = vi .spyOn(console, 'error') .mockImplementation(() => {}); try { const { container, rerender } = render( , ); expect(container.textContent).toBe('partial'); rerender( , ); expect(container.textContent).toBe('markdown recovered'); } finally { consoleError.mockRestore(); } }); it('保留流式 assistant 文本并标记 streaming 状态', () => { const { container } = render( , ); expect(container.textContent).toContain('正在生成'); expect(container.textContent).toContain('结果'); }); it('用户消息保持纯文本,不解析 Markdown', () => { const { container } = render( , ); expect(container.querySelector('strong')).toBeNull(); expect(container.querySelector('code')).toBeNull(); expect(container.textContent).toContain('**不要解析**'); expect(container.textContent).toContain('`/read game`'); }); it('不输出原始 HTML、可点击链接或图片节点', () => { const { container } = render( alert(1)\n\n[外链](https://example.com) ![示意图](x.png)' } />, ); expect(container.querySelector('script')).toBeNull(); expect(container.querySelector('a')).toBeNull(); expect(container.querySelector('img')).toBeNull(); expect(container.textContent).toContain('外链'); expect(container.textContent).toContain('图片:示意图'); }); it('支持表格并允许窄视口横向滚动', () => { const { container } = render( , ); expect(container.querySelectorAll('table th')).toHaveLength(2); expect(container.querySelector('div.overflow-x-auto')).toBeTruthy(); }); });