364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { fireEvent, render, screen } from '@testing-library/react';
|
|
import { ChatMessage } from '@/pages/Chat/ChatMessage';
|
|
import { getToolVisibilityId } from '@/pages/Chat/chat-transcript';
|
|
import { formatTimestamp } from '@/pages/Chat/message-utils';
|
|
import type { RawMessage } from '@/types/chat';
|
|
|
|
vi.mock('@/lib/api-client', () => ({
|
|
invokeIpc: vi.fn(),
|
|
statFile: vi.fn(async (path: string) => {
|
|
if (path.includes('missing')) {
|
|
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 rendering', () => {
|
|
it('keeps attachment-only assistant replies visible 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', () => {
|
|
const message: RawMessage = {
|
|
role: 'assistant',
|
|
content: 'Screenshot captured.',
|
|
_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('hides metadata-only image attachments instead of rendering blank file tiles', () => {
|
|
const message: RawMessage = {
|
|
role: 'assistant',
|
|
content: 'Screenshot captured.',
|
|
_attachedFiles: [
|
|
{
|
|
fileName: 'desktop_screenshot.jpg',
|
|
mimeType: 'image/jpeg',
|
|
fileSize: 0,
|
|
preview: null,
|
|
filePath: '/tmp/desktop_screenshot.jpg',
|
|
source: 'tool-result',
|
|
},
|
|
],
|
|
};
|
|
|
|
render(<ChatMessage message={message} />);
|
|
|
|
expect(screen.queryByAltText('desktop_screenshot.jpg')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('desktop_screenshot.jpg')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('deduplicates image attachments and keeps the copy with a preview', () => {
|
|
const message: RawMessage = {
|
|
role: 'user',
|
|
content: 'Please inspect this screenshot.',
|
|
_attachedFiles: [
|
|
{
|
|
fileName: 'farm.png',
|
|
mimeType: 'image/png',
|
|
fileSize: 1024,
|
|
preview: null,
|
|
filePath: '/tmp/farm.png',
|
|
source: 'runtime-media',
|
|
},
|
|
{
|
|
fileName: 'farm.png',
|
|
mimeType: 'image/png',
|
|
fileSize: 1024,
|
|
preview: 'data:image/png;base64,xyz',
|
|
filePath: '/tmp/farm.png',
|
|
source: 'user-upload',
|
|
},
|
|
],
|
|
};
|
|
|
|
render(<ChatMessage message={message} />);
|
|
|
|
expect(screen.getAllByAltText('farm.png')).toHaveLength(1);
|
|
});
|
|
|
|
it('derives preview cards from assistant text paths under NianCode data', async () => {
|
|
const message: RawMessage = {
|
|
role: 'assistant',
|
|
content: 'Generated file: `/Users/me/.niancode/workspace/report.pdf`',
|
|
};
|
|
|
|
render(<ChatMessage message={message} suppressProcessAttachments />);
|
|
|
|
expect(await screen.findByText('report.pdf')).toBeInTheDocument();
|
|
});
|
|
|
|
it('derives skill directory cards from .niancode skill paths', async () => {
|
|
const message: RawMessage = {
|
|
role: 'assistant',
|
|
content: 'Skill location: ~/.niancode/skills/open-eastmoney',
|
|
};
|
|
|
|
render(<ChatMessage message={message} suppressProcessAttachments />);
|
|
|
|
expect(await screen.findByText('open-eastmoney')).toBeInTheDocument();
|
|
expect(screen.getByText('Folder')).toBeInTheDocument();
|
|
});
|
|
|
|
it('does not show cards for missing text paths', async () => {
|
|
const message: RawMessage = {
|
|
role: 'assistant',
|
|
content: 'Missing file: ~/.niancode/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 delivery status rendering', () => {
|
|
it('shows queued status for a pending user prompt', () => {
|
|
const message = {
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'Run the next task' }],
|
|
_deliveryStatus: 'queued',
|
|
} as RawMessage;
|
|
|
|
render(<ChatMessage message={message} />);
|
|
|
|
expect(screen.getByText('Queued')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows sending status for an active user prompt', () => {
|
|
const message = {
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'Run this now' }],
|
|
_deliveryStatus: 'sending',
|
|
} as RawMessage;
|
|
|
|
render(<ChatMessage message={message} />);
|
|
|
|
expect(screen.getByText('Sending')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('ChatMessage current-view controls', () => {
|
|
it('hides both user and assistant timestamps when showTimestamps is false', () => {
|
|
const timestamp = Date.UTC(2026, 6, 18, 8, 0, 0);
|
|
const { rerender } = render(
|
|
<ChatMessage message={{ role: 'user', content: 'hello', timestamp }} showTimestamps={false} />,
|
|
);
|
|
expect(screen.queryByText(formatTimestamp(timestamp))).not.toBeInTheDocument();
|
|
|
|
rerender(
|
|
<ChatMessage message={{ role: 'assistant', content: 'world', timestamp }} showTimestamps={false} />,
|
|
);
|
|
expect(screen.queryByText(formatTimestamp(timestamp))).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('renders tool details from the parent-controlled expanded ID set', () => {
|
|
const message: RawMessage = {
|
|
id: 'assistant-tools',
|
|
role: 'assistant',
|
|
content: [{
|
|
type: 'tool_use',
|
|
id: 'tool-read',
|
|
name: 'read',
|
|
input: { file: 'README.md' },
|
|
}],
|
|
};
|
|
const onToolExpandedChange = vi.fn();
|
|
const toolId = getToolVisibilityId('assistant-tools', 'tool-read', 0);
|
|
const { rerender } = render(
|
|
<ChatMessage
|
|
message={message}
|
|
messageKey="assistant-tools"
|
|
expandedToolIds={new Set()}
|
|
onToolExpandedChange={onToolExpandedChange}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText(/README\.md/)).not.toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('button', { name: /read/i }));
|
|
expect(onToolExpandedChange).toHaveBeenCalledWith(toolId, true);
|
|
|
|
rerender(
|
|
<ChatMessage
|
|
message={message}
|
|
messageKey="assistant-tools"
|
|
expandedToolIds={new Set([toolId])}
|
|
onToolExpandedChange={onToolExpandedChange}
|
|
/>,
|
|
);
|
|
expect(screen.getByText(/README\.md/)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('ChatMessage bubble surface styling', () => {
|
|
it('uses the Makelore light paper palette for user and assistant message backgrounds', () => {
|
|
const userMessage: RawMessage = {
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'Please inspect this prototype.' }],
|
|
};
|
|
const assistantMessage: RawMessage = {
|
|
role: 'assistant',
|
|
content: 'I will review it against the project style.',
|
|
};
|
|
|
|
const { rerender } = render(<ChatMessage message={userMessage} />);
|
|
|
|
expect(screen.getByTestId('chat-message-bubble-user')).toHaveClass(
|
|
'chat-user-message-surface',
|
|
'bg-brand-soft',
|
|
'text-foreground',
|
|
'shadow-soft',
|
|
);
|
|
expect(screen.getByTestId('chat-message-bubble-user')).not.toHaveClass('bg-[#168fff]', 'text-white');
|
|
|
|
rerender(<ChatMessage message={assistantMessage} />);
|
|
|
|
expect(screen.getByTestId('chat-message-bubble-assistant')).toHaveClass(
|
|
'chat-assistant-message-surface',
|
|
'bg-background',
|
|
'text-foreground',
|
|
'shadow-soft',
|
|
);
|
|
});
|
|
|
|
it('does not render a user avatar or an extra row gap', () => {
|
|
const message: RawMessage = {
|
|
role: 'user',
|
|
content: 'Please inspect this prototype.',
|
|
};
|
|
|
|
render(<ChatMessage message={message} />);
|
|
|
|
expect(screen.queryByTestId('chat-message-avatar-user')).not.toBeInTheDocument();
|
|
expect(screen.getByTestId('chat-message-bubble-user').parentElement?.parentElement).toHaveClass('gap-0');
|
|
});
|
|
});
|
|
|
|
describe('ChatMessage LaTeX rendering', () => {
|
|
it('renders the configured project Agent avatar for assistant messages', () => {
|
|
const message: RawMessage = { role: 'assistant', content: '已完成。' };
|
|
render(<ChatMessage message={message} assistantAvatarSrc="/avatar-16.png" assistantAvatarAlt="小码头像" />);
|
|
expect(screen.getByRole('img', { name: '小码头像' })).toHaveAttribute('src', '/avatar-16.png');
|
|
});
|
|
|
|
it('reserves the assistant column without painting a second avatar', () => {
|
|
const message: RawMessage = { role: 'assistant', content: '最终回答。' };
|
|
render(<ChatMessage message={message} assistantAvatarSrc="/avatar-16.png" assistantAvatarMode="reserved" />);
|
|
|
|
expect(screen.queryByTestId('chat-message-avatar-assistant')).not.toBeInTheDocument();
|
|
expect(screen.getByTestId('chat-message-avatar-reserved')).toHaveAttribute('aria-hidden', 'true');
|
|
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('compacts the assistant reply that follows an execution trace', () => {
|
|
const message: RawMessage = { role: 'assistant', content: '最终回答。' };
|
|
render(
|
|
<ChatMessage
|
|
message={message}
|
|
assistantAvatarMode="reserved-trace"
|
|
compactAssistantReply
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByTestId('chat-message-bubble-assistant')).toHaveClass('pt-1', 'pb-3');
|
|
});
|
|
|
|
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', () => {
|
|
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('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();
|
|
});
|
|
});
|