Files
iofficeai--aionui/tests/unit/chat/MessageAcpToolCall.dom.test.tsx
2026-07-13 13:20:22 +08:00

344 lines
9.4 KiB
TypeScript

/**
* @license
* Copyright 2025 AionUi (aionui.com)
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IMessageAcpToolCall } from '@/common/chat/chatLib';
import MessageAcpToolCall from '@/renderer/pages/conversation/Messages/acp/MessageAcpToolCall';
const mockDownloadFileFromPath = vi.fn().mockResolvedValue(undefined);
const mockMessageSuccess = vi.fn();
const mockMessageError = vi.fn();
vi.mock('@/renderer/components/media/LocalImageView', () => ({
__esModule: true,
default: ({ src, alt, className }: { src: string; alt: string; className?: string }) => (
<img src={src} alt={alt} className={className} data-testid='local-image' />
),
}));
vi.mock('@/renderer/utils/file/download', () => ({
downloadFileFromPath: (...args: unknown[]) => mockDownloadFileFromPath(...args),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('@arco-design/web-react', () => ({
Card: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
Tag: ({ children }: { children?: React.ReactNode }) => <span>{children}</span>,
Button: ({ children, onClick, ...props }: { children?: React.ReactNode; onClick?: () => void }) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
Message: {
useMessage: () => [{ success: mockMessageSuccess, error: mockMessageError }, null],
},
}));
vi.mock('@renderer/components/Markdown', () => ({
__esModule: true,
default: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/renderer/components/base/FileChangesPanel', () => ({
__esModule: true,
default: () => <div data-testid='file-changes-panel' />,
}));
vi.mock('@/renderer/hooks/file/useDiffPreviewHandlers', () => ({
useDiffPreviewHandlers: () => ({
handleFileClick: vi.fn(),
handleDiffClick: vi.fn(),
}),
}));
vi.mock('@/renderer/utils/file/diffUtils', () => ({
parseDiff: () => ({ fileName: 'file.ts' }),
}));
const createMessage = (update: IMessageAcpToolCall['content']['update']): IMessageAcpToolCall => ({
id: 'msg-1',
msg_id: 'msg-1',
conversation_id: 'conv-1',
type: 'acp_tool_call',
content: {
sessionId: 'sess-1',
update,
},
});
const baseUpdate: IMessageAcpToolCall['content']['update'] = {
sessionUpdate: 'tool_call_update',
tool_call_id: 'ig_test_image',
status: 'completed',
title: 'Image generation',
kind: 'execute',
};
describe('MessageAcpToolCall image output', () => {
beforeEach(() => {
mockDownloadFileFromPath.mockReset();
mockDownloadFileFromPath.mockResolvedValue(undefined);
mockMessageSuccess.mockClear();
mockMessageError.mockClear();
});
it('renders nothing when update content is missing', () => {
const { container } = render(
<MessageAcpToolCall
message={
{
id: 'msg-empty',
conversation_id: 'conv-1',
type: 'acp_tool_call',
content: undefined,
} as unknown as IMessageAcpToolCall
}
/>
);
expect(container).toBeEmptyDOMElement();
});
it('renders the image path from rawOutput.image.path', () => {
render(
<MessageAcpToolCall
message={createMessage({
...baseUpdate,
rawOutput: {
image: {
path: '/Users/test/.codex/generated_images/session/ig_test_image.png',
},
},
})}
/>
);
const image = screen.getByTestId('local-image');
expect(image).toHaveAttribute('src', '/Users/test/.codex/generated_images/session/ig_test_image.png');
expect(image).toHaveAttribute('alt', 'ig_test_image.png');
});
it('downloads the image from rawOutput.image.path', () => {
const imagePath = '/Users/test/.codex/generated_images/session/ig_test_image.png';
render(
<MessageAcpToolCall
message={createMessage({
...baseUpdate,
rawOutput: {
image: {
path: imagePath,
},
},
})}
/>
);
screen.getByLabelText('acp.image.download_aria').click();
expect(mockDownloadFileFromPath).toHaveBeenCalledWith(imagePath, 'ig_test_image.png');
});
it('shows an error when image download fails', async () => {
const imagePath = '/Users/test/.codex/generated_images/session/ig_test_image.png';
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
mockDownloadFileFromPath.mockRejectedValueOnce(new Error('denied'));
render(
<MessageAcpToolCall
message={createMessage({
...baseUpdate,
rawOutput: {
image: {
path: imagePath,
},
},
})}
/>
);
screen.getByLabelText('acp.image.download_aria').click();
await waitFor(() => {
expect(mockMessageError).toHaveBeenCalledWith('acp.image.download_error');
});
expect(consoleError).toHaveBeenCalledWith('[MessageAcpToolCall] Failed to download image:', expect.any(Error));
expect(mockMessageSuccess).not.toHaveBeenCalled();
consoleError.mockRestore();
});
it('uses i18n keys for image download labels', () => {
render(
<MessageAcpToolCall
message={createMessage({
...baseUpdate,
rawOutput: {
image: {
path: '/Users/test/.codex/generated_images/session/ig_test_image.png',
},
},
})}
/>
);
expect(screen.getByLabelText('acp.image.download_aria')).toBeInTheDocument();
});
it('falls back to raw_output.saved_path for persisted snake_case content', () => {
render(
<MessageAcpToolCall
message={createMessage({
...baseUpdate,
raw_output: {
saved_path: '/Users/test/.codex/generated_images/session/ig_legacy.webp',
},
})}
/>
);
const image = screen.getByTestId('local-image');
expect(image).toHaveAttribute('src', '/Users/test/.codex/generated_images/session/ig_legacy.webp');
expect(image).toHaveAttribute('alt', 'ig_legacy.webp');
});
it('uses the generated image fallback alt when the path has no file name', () => {
render(
<MessageAcpToolCall
message={createMessage({
...baseUpdate,
rawOutput: {
image: {
path: '/',
},
},
})}
/>
);
expect(screen.getByTestId('local-image')).toHaveAttribute('alt', 'acp.image.generated_alt');
});
it('renders kind fallback labels when title is missing', () => {
const cases: Array<[IMessageAcpToolCall['content']['update']['kind'], string]> = [
['edit', 'File Edit'],
['read', 'File Read'],
['execute', 'Shell Command'],
['custom' as IMessageAcpToolCall['content']['update']['kind'], 'custom'],
];
for (const [kind, label] of cases) {
const { unmount } = render(
<MessageAcpToolCall
message={createMessage({
...baseUpdate,
title: '',
kind,
})}
/>
);
expect(screen.getByText(label)).toBeInTheDocument();
unmount();
}
});
it('renders status labels for pending and in-progress states', () => {
const { rerender } = render(
<MessageAcpToolCall
message={createMessage({
...baseUpdate,
status: 'pending',
})}
/>
);
expect(screen.getByText('Pending')).toBeInTheDocument();
rerender(
<MessageAcpToolCall
message={createMessage({
...baseUpdate,
status: 'in_progress',
})}
/>
);
expect(screen.getByText('In Progress')).toBeInTheDocument();
});
it('renders raw input and content variants', () => {
render(
<MessageAcpToolCall
message={createMessage({
...baseUpdate,
rawInput: { prompt: '一只小猫' },
content: [
{
type: 'content',
content: {
type: 'text',
text: 'done',
},
},
{
type: 'diff',
path: '/workspace/file.ts',
old_text: 'old',
new_text: 'new',
},
{
type: 'content',
content: {
type: 'text',
text: '',
},
},
],
})}
/>
);
expect(screen.getByText(/"prompt": "一只小猫"/)).toBeInTheDocument();
expect(screen.getByText('done')).toBeInTheDocument();
expect(screen.getByTestId('file-changes-panel')).toBeInTheDocument();
});
it('renders string raw input as markdown', () => {
render(
<MessageAcpToolCall
message={createMessage({
...baseUpdate,
rawInput: 'echo hello',
})}
/>
);
expect(screen.getByText(/echo hello/)).toBeInTheDocument();
});
it('renders an empty diff content with fallback file metadata', () => {
render(
<MessageAcpToolCall
message={createMessage({
...baseUpdate,
content: [
{
type: 'diff',
},
],
})}
/>
);
expect(screen.getByTestId('file-changes-panel')).toBeInTheDocument();
});
});