Files
clawX/tests/unit/chat-message.test.tsx
2026-05-29 12:10:04 +08:00

517 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(),
readBinaryFile: 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(
<ChatMessage
message={message}
suppressProcessAttachments
/>,
);
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(
<ChatMessage
message={message}
suppressProcessAttachments
/>,
);
expect(screen.getByAltText('desktop_screenshot.png')).toBeInTheDocument();
});
it('shows an explicit loading state for image artifacts before preview hydration finishes', () => {
const message: RawMessage = {
role: 'assistant',
content: 'Image generated.',
_attachedFiles: [
{
fileName: 'generated.png',
mimeType: 'image/png',
fileSize: 0,
preview: null,
gatewayUrl: '/api/chat/media/outgoing/agent%3Amain%3As-1/generated/full',
source: 'gateway-media',
},
],
};
render(<ChatMessage message={message} />);
expect(screen.getByTestId('image-preview-loading')).toBeInTheDocument();
expect(screen.queryByTestId('image-preview-unavailable')).not.toBeInTheDocument();
});
it('shows an unavailable state after image preview hydration gives up', () => {
const message: RawMessage = {
role: 'assistant',
content: 'Image generated.',
_attachedFiles: [
{
fileName: 'generated.png',
mimeType: 'image/png',
fileSize: 0,
preview: null,
previewStatus: 'unavailable',
gatewayUrl: '/api/chat/media/outgoing/agent%3Amain%3As-1/generated/full',
source: 'gateway-media',
},
],
};
render(<ChatMessage message={message} />);
expect(screen.getByTestId('image-preview-unavailable')).toBeInTheDocument();
expect(screen.queryByTestId('image-preview-loading')).not.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(
<ChatMessage
message={message}
suppressProcessAttachments
/>,
);
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(<ChatMessage message={message} suppressProcessAttachments />);
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(<ChatMessage message={message} suppressProcessAttachments />);
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(<ChatMessage message={message} suppressProcessAttachments />);
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(
<ChatMessage
message={message}
suppressProcessAttachments
/>,
);
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(<ChatMessage message={message} suppressProcessAttachments />);
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(<ChatMessage message={message} suppressProcessAttachments />);
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(<ChatMessage message={message} suppressProcessAttachments />);
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(<ChatMessage message={message} suppressProcessAttachments onOpenFile={onOpenFile} />);
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(<ChatMessage message={message} suppressProcessAttachments />);
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(
<ChatMessage
message={message}
suppressProcessAttachments
/>,
);
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(<ChatMessage message={message} />);
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(<ChatMessage message={message} />);
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(<ChatMessage message={message} />);
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(<ChatMessage message={message} />);
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(<ChatMessage message={message} />);
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
// <code> and <a> 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(<ChatMessage message={message} />);
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(<ChatMessage message={message} />);
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(<ChatMessage message={message} />);
const inlineCode = container.querySelector('.prose code');
expect(inlineCode).not.toBeNull();
expect(inlineCode?.classList.contains('break-all')).toBe(true);
});
// Regression: fenced code blocks used to set only `overflow-x-auto`, which
// hid long log lines / paths behind a horizontal scroll that the chat
// viewport often clipped. Long lines must now wrap inside the bubble.
it('wraps fenced code block contents instead of overflowing horizontally', () => {
const longLine = 'config change requires channel reload (wecom) — deferring until 2 operation(s), 1 reply(ies), 1 embedded run(s) complete';
const message: RawMessage = {
role: 'assistant',
content: ['Gateway log:', '', '```', longLine, '```'].join('\n'),
};
const { container } = render(<ChatMessage message={message} />);
const codeBlock = container.querySelector('.prose pre');
expect(codeBlock).not.toBeNull();
expect(codeBlock?.classList.contains('whitespace-pre-wrap')).toBe(true);
expect(codeBlock?.classList.contains('break-words')).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(<ChatMessage message={message} />);
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(<ChatMessage message={message} />);
const bubble = container.querySelector('.rounded-2xl.bg-brand');
expect(bubble).not.toBeNull();
expect(bubble).toHaveTextContent('Keep the prompt bubble.');
});
});
describe('ChatMessage image copy', () => {
beforeEach(() => {
class MockClipboardItem {
constructor(public items: Record<string, Blob>) {}
}
Object.assign(globalThis, { ClipboardItem: MockClipboardItem });
Object.assign(navigator, {
clipboard: {
write: vi.fn(async () => undefined),
writeText: vi.fn(async () => undefined),
},
});
});
it('copies image bytes instead of the media URL text when an image attachment is present', async () => {
const { readBinaryFile } = await import('@/lib/api-client');
vi.mocked(readBinaryFile).mockResolvedValueOnce({
ok: true,
data: Uint8Array.from([137, 80, 78, 71]),
mimeType: 'image/png',
});
const message: RawMessage = {
role: 'assistant',
content: 'http://127.0.0.1:18789/api/chat/media/outgoing/agent/main/full',
_attachedFiles: [
{
fileName: 'cat.png',
mimeType: 'image/png',
fileSize: 1234,
preview: null,
filePath: '/tmp/cat.png',
source: 'tool-result',
},
],
};
render(<ChatMessage message={message} />);
fireEvent.click(screen.getByRole('button'));
await vi.waitFor(() => {
expect(navigator.clipboard.write).toHaveBeenCalledTimes(1);
});
expect(navigator.clipboard.writeText).not.toHaveBeenCalled();
});
});