517 lines
17 KiB
TypeScript
517 lines
17 KiB
TypeScript
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();
|
||
});
|
||
});
|