import { describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen } from '@testing-library/react'; import { ChatMessage } from '@/pages/Chat/ChatMessage'; import type { RawMessage } from '@/stores/chat'; vi.mock('@/lib/api-client', () => ({ invokeIpc: vi.fn(), statFile: vi.fn(async (path: string) => { if (path.includes('missing') || path.includes('不存在')) { return { ok: false, error: 'notFound' }; } const isFile = /\.[A-Za-z0-9]+$/.test(path); return { ok: true, isFile, isDir: !isFile, size: isFile ? 1024 : 0, }; }), })); describe('ChatMessage attachment dedupe', () => { it('keeps attachment-only assistant replies visible even when process attachments are suppressed', () => { const message: RawMessage = { role: 'assistant', content: [], _attachedFiles: [ { fileName: 'artifact.png', mimeType: 'image/png', fileSize: 0, preview: '/tmp/artifact.png', filePath: '/tmp/artifact.png', source: 'tool-result', }, ], }; render( , ); expect(screen.getByAltText('artifact.png')).toBeInTheDocument(); }); it('keeps image artifacts visible alongside reply text when process attachments are suppressed', () => { // Regression for media outgoing being silently dropped when the agent // accompanies a `MEDIA:/path.png` artifact with any narration text: // process-attachment filtering used to require PDF/XLSX/dir/skill but // had no carve-out for images, so the file card never rendered. const message: RawMessage = { role: 'assistant', content: 'Screenshot taken, sending it to you as an attachment.', _attachedFiles: [ { fileName: 'desktop_screenshot.png', mimeType: 'image/png', fileSize: 1234, preview: 'data:image/png;base64,abc', filePath: '/tmp/desktop_screenshot.png', source: 'tool-result', }, ], }; render( , ); expect(screen.getByAltText('desktop_screenshot.png')).toBeInTheDocument(); }); it('keeps message-ref image artifacts visible alongside reply text when process attachments are suppressed', () => { const message: RawMessage = { role: 'assistant', content: 'Compressed, sending it to you:', _attachedFiles: [ { fileName: 'desktop_screenshot.jpg', mimeType: 'image/jpeg', fileSize: 837_000, preview: 'data:image/jpeg;base64,xyz', filePath: '/tmp/desktop_screenshot.jpg', source: 'message-ref', }, ], }; render( , ); expect(screen.getByAltText('desktop_screenshot.jpg')).toBeInTheDocument(); }); it('keeps html artifacts visible when process attachments are suppressed', async () => { const message: RawMessage = { role: 'assistant', content: '已生成 /workspace/demo.html', }; render(); expect(await screen.findByText('demo.html')).toBeInTheDocument(); }); it('hides generic tool-result markdown attachments when process attachments are suppressed', () => { const message: RawMessage = { role: 'assistant', content: 'Coder has finished the analysis, here are the conclusions.', _attachedFiles: [ { fileName: 'CHECKLIST.md', mimeType: 'text/markdown', fileSize: 433, preview: null, filePath: '/Users/bytedance/.openclaw/workspace/CHECKLIST.md', source: 'tool-result', }, ], }; render(); expect(screen.getByText('Coder has finished the analysis, here are the conclusions.')).toBeInTheDocument(); expect(screen.queryByText('CHECKLIST.md')).not.toBeInTheDocument(); }); it('keeps attached SKILL.md visible when process attachments are suppressed', () => { const message: RawMessage = { role: 'assistant', content: '这是文件。', _attachedFiles: [ { fileName: 'SKILL.md', mimeType: 'text/markdown', fileSize: 128, preview: null, filePath: '/workspace/skills/open-xueqiu/SKILL.md', source: 'tool-result', }, ], }; render(); expect(screen.getByText('SKILL.md')).toBeInTheDocument(); }); it('keeps pdf and spreadsheet artifacts visible when process attachments are suppressed', () => { const message: RawMessage = { role: 'assistant', content: 'Here are the generated files.', _attachedFiles: [ { fileName: 'sales.xlsx', mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', fileSize: 1024, preview: null, filePath: '/tmp/sales.xlsx', source: 'message-ref', }, { fileName: 'report.pdf', mimeType: 'application/pdf', fileSize: 2048, preview: null, filePath: '/tmp/report.pdf', source: 'tool-result', }, ], }; render( , ); expect(screen.getByText('sales.xlsx')).toBeInTheDocument(); expect(screen.getByText('report.pdf')).toBeInTheDocument(); }); it('derives preview cards from assistant text paths when attachments are missing', async () => { const message: RawMessage = { role: 'assistant', content: '已生成测试 PDF 文件: 测试PDF文件.pdf 位置: `/Users/zhonghaolu/.openclaw/workspace/测试PDF文件.pdf`', }; render(); expect(await screen.findByText('测试PDF文件.pdf')).toBeInTheDocument(); }); it('derives skill directory cards from assistant text paths', async () => { const message: RawMessage = { role: 'assistant', content: '名称: open-eastmoney\n位置: ~/.openclaw/skills/open-eastmoney\n校验结果:通过', }; render(); expect(await screen.findByText('open-eastmoney')).toBeInTheDocument(); expect(screen.getByText('文件夹')).toBeInTheDocument(); }); it('keeps unicode Windows skill directory paths as cards', async () => { const message: RawMessage = { role: 'assistant', content: String.raw`位置: C:\Users\张三\.openclaw\skills\打开东方财富`, }; render(); expect(await screen.findByText('打开东方财富')).toBeInTheDocument(); expect(screen.getByText('文件夹')).toBeInTheDocument(); }); it('shows SKILL.md as a previewable file card instead of a folder', async () => { const onOpenFile = vi.fn(); const message: RawMessage = { role: 'assistant', content: '位置: ~/.openclaw/skills/open-baidu\nMarkdown 文件: ~/.openclaw/skills/open-baidu/SKILL.md', }; render(); expect(await screen.findByText('open-baidu')).toBeInTheDocument(); expect(await screen.findByText('SKILL.md')).toBeInTheDocument(); expect(screen.getAllByText('文件夹')).toHaveLength(1); fireEvent.click(screen.getByText('SKILL.md')); expect(onOpenFile).toHaveBeenCalledWith(expect.objectContaining({ fileName: 'SKILL.md', filePath: '~/.openclaw/skills/open-baidu/SKILL.md', mimeType: 'text/markdown', })); }); it('does not show cards for hallucinated missing paths', async () => { const message: RawMessage = { role: 'assistant', content: '不存在的文件: ~/.openclaw/skills/missing-skill/SKILL.md', }; render(); await new Promise((resolve) => setTimeout(resolve, 0)); expect(screen.queryByText('SKILL.md')).not.toBeInTheDocument(); }); it('continues hiding non-preview process attachments when process attachments are suppressed', () => { const message: RawMessage = { role: 'assistant', content: 'I also used a temporary file.', _attachedFiles: [ { fileName: 'debug.log', mimeType: 'text/plain', fileSize: 1024, preview: null, filePath: '/tmp/debug.log', source: 'message-ref', }, ], }; render( , ); expect(screen.queryByText('debug.log')).not.toBeInTheDocument(); }); }); describe('ChatMessage LaTeX rendering', () => { it('renders inline `$...$` math with KaTeX', () => { const message: RawMessage = { role: 'assistant', content: 'Mass-energy equivalence: $E=mc^2$ is famous.', }; const { container } = render(); expect(container.querySelector('.katex')).not.toBeNull(); }); it('renders display `$$...$$` math as a block', () => { const message: RawMessage = { role: 'assistant', content: 'Definite integral:\n\n$$\n\\int_0^1 x\\,dx = \\frac{1}{2}\n$$\n', }; const { container } = render(); expect(container.querySelector('.katex-display')).not.toBeNull(); }); it('renders `\\(...\\)` inline math (OpenAI-style escaping)', () => { const message: RawMessage = { role: 'assistant', content: 'Quadratic formula: \\(x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}\\).', }; const { container } = render(); expect(container.querySelector('.katex')).not.toBeNull(); expect(container.querySelector('.katex-display')).toBeNull(); }); it('renders `\\[...\\]` block math (OpenAI-style escaping)', () => { const message: RawMessage = { role: 'assistant', content: 'Sum formula:\n\n\\[\\sum_{i=1}^n i = \\frac{n(n+1)}{2}\\]', }; const { container } = render(); expect(container.querySelector('.katex-display')).not.toBeNull(); }); it('does not rewrite `\\(` inside code fences', () => { const message: RawMessage = { role: 'assistant', content: 'Code sample:\n\n```\nprintf("\\(hello\\)")\n```\n', }; const { container } = render(); expect(container.textContent).toContain('\\(hello\\)'); expect(container.querySelector('.katex')).toBeNull(); }); }); describe('ChatMessage word wrapping', () => { // Regression for #931: word-break:break-all on the message bubble wrappers // forced English words to split mid-character. Long unbreakable tokens // (URLs, identifiers) still wrap via overflow-wrap:break-word; inline // and children keep break-all because those carry non-prose // tokens where mid-char breaks are still desirable. it('does not apply break-all to the assistant prose wrapper', () => { const message: RawMessage = { role: 'assistant', content: 'The neural network response should wrap at word boundaries.', }; const { container } = render(); const prose = container.querySelector('.prose'); expect(prose).not.toBeNull(); expect(prose?.classList.contains('break-all')).toBe(false); expect(prose?.classList.contains('break-words')).toBe(true); }); it('does not apply break-all to user message text', () => { const message: RawMessage = { role: 'user', content: 'A user-typed sentence that should also wrap by words, not characters.', }; const { container } = render(); const paragraph = container.querySelector('p.whitespace-pre-wrap'); expect(paragraph).not.toBeNull(); expect(paragraph?.classList.contains('break-all')).toBe(false); expect(paragraph?.classList.contains('break-words')).toBe(true); }); it('keeps break-all on inline code so long identifiers can still break mid-token', () => { const message: RawMessage = { role: 'assistant', content: 'Use `someVeryLongIdentifierNameThatShouldStillBreakAnywhere` here.', }; const { container } = render(); const inlineCode = container.querySelector('.prose code'); expect(inlineCode).not.toBeNull(); expect(inlineCode?.classList.contains('break-all')).toBe(true); }); }); describe('ChatMessage reply styling', () => { it('renders assistant replies as plain Markdown without a rounded bubble wrapper', () => { const message: RawMessage = { role: 'assistant', content: 'Direct Markdown reply with **bold** text.', }; const { container } = render(); const prose = container.querySelector('.prose'); expect(prose).not.toBeNull(); expect(prose?.classList.contains('rounded-2xl')).toBe(false); expect(prose?.classList.contains('bg-black/5')).toBe(false); expect(prose?.classList.contains('dark:bg-white/5')).toBe(false); expect(prose?.parentElement?.classList.contains('rounded-2xl')).toBe(false); }); it('keeps user messages in the blue rounded bubble', () => { const message: RawMessage = { role: 'user', content: 'Keep the prompt bubble.', }; const { container } = render(); const bubble = container.querySelector('.rounded-2xl.bg-brand'); expect(bubble).not.toBeNull(); expect(bubble).toHaveTextContent('Keep the prompt bubble.'); }); });