Files
2026-07-13 13:20:22 +08:00

434 lines
13 KiB
TypeScript

/**
* @license
* Copyright 2025 AionUi (aionui.com)
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IMessageText } from '@/common/chat/chatLib';
import { ipcBridge } from '@/common';
import { ConversationProvider } from '@/renderer/hooks/context/ConversationContext';
import MessageText from '@/renderer/pages/conversation/Messages/components/MessageText';
import {
LARGE_TEXT_PREVIEW_MAX_LENGTH,
LARGE_TEXT_PREVIEW_THRESHOLD,
} from '@/renderer/pages/conversation/Preview/constants';
const previewMocks = vi.hoisted(() => ({
openPreview: vi.fn(),
}));
const localFileLinkMocks = vi.hoisted(() => ({
payload: {
path: '/missing/report.xlsx',
reference: undefined as
| {
filePath: string;
rawReference: string;
line?: number;
column?: number;
endLine?: number;
}
| undefined,
},
}));
const mockFilePreview = vi.fn(({ path }: { path: string }) => <div data-testid='file-preview'>{path}</div>);
vi.mock('@/common', () => ({
ipcBridge: {
fs: {
getFileMetadata: { invoke: vi.fn() },
getImageBase64: { invoke: vi.fn() },
readFile: { invoke: vi.fn() },
},
},
}));
vi.mock('@/renderer/pages/conversation/Preview/context/PreviewContext', () => ({
usePreviewContext: () => ({
openPreview: previewMocks.openPreview,
}),
}));
vi.mock('@/renderer/components/chat/CollapsibleContent', () => ({
__esModule: true,
default: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
}));
vi.mock('@/renderer/components/media/FilePreview', () => ({
__esModule: true,
default: (props: { path: string }) => mockFilePreview(props),
}));
vi.mock('@/renderer/components/media/HorizontalFileList', () => ({
__esModule: true,
default: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/renderer/components/Markdown', () => ({
__esModule: true,
default: ({
children,
onLocalFileLink,
}: {
children?: React.ReactNode;
onLocalFileLink?: (
path: string,
reference?: {
filePath: string;
rawReference: string;
line?: number;
column?: number;
endLine?: number;
}
) => void | Promise<void>;
}) => (
<div>
{children}
{onLocalFileLink && (
<button
type='button'
onClick={() => void onLocalFileLink(localFileLinkMocks.payload.path, localFileLinkMocks.payload.reference)}
>
open local file
</button>
)}
</div>
),
}));
vi.mock('@/renderer/utils/chat/skillSuggestParser', () => ({
hasSkillSuggest: () => false,
stripSkillSuggest: (content: string) => content,
}));
vi.mock('@/renderer/utils/chat/thinkTagFilter', () => ({
hasThinkTags: () => false,
stripThinkTags: (content: string) => content,
}));
vi.mock('@/renderer/utils/model/agentLogo', () => ({
useAgentLogos: () => ({}),
resolveAgentLogo: () => null,
}));
vi.mock('@/renderer/utils/ui/clipboard', () => ({
copyText: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@arco-design/web-react', () => ({
Alert: () => null,
Message: {
error: vi.fn(),
},
Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
}));
vi.mock('@icon-park/react', () => ({
Copy: () => <span data-testid='copy-icon' />,
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? key,
}),
}));
const fileMetadata = (path: string) => ({
name: path.split(/[\\/]/).pop() || path,
path,
size: 128,
type: 'file',
lastModified: 1_717_000_000,
});
describe('MessageText attachment paths', () => {
beforeEach(() => {
previewMocks.openPreview.mockClear();
localFileLinkMocks.payload = {
path: '/missing/report.xlsx',
reference: undefined,
};
vi.mocked(ipcBridge.fs.getFileMetadata.invoke).mockReset();
vi.mocked(ipcBridge.fs.getImageBase64.invoke).mockReset();
vi.mocked(ipcBridge.fs.readFile.invoke).mockReset();
});
const renderMessageWithLocalLink = (content = '[report](/missing/report.xlsx)') => {
const message: IMessageText = {
id: 'msg-local-link',
msg_id: 'msg-local-link',
conversation_id: 'conv-1',
type: 'text',
position: 'left',
createdAt: Date.now(),
content: {
content,
},
};
render(
<ConversationProvider value={{ conversationId: 'conv-1', workspace: '/workspace/demo', type: 'acp' }}>
<MessageText message={message} />
</ConversationProvider>
);
};
it('resolves relative attachment paths against the current workspace before previewing', () => {
const message: IMessageText = {
id: 'msg-1',
msg_id: 'msg-1',
conversation_id: 'conv-1',
type: 'text',
position: 'right',
createdAt: Date.now(),
content: {
content: 'look at this\n\n[[AION_FILES]]\nuploads/photo.png',
},
};
render(
<ConversationProvider value={{ conversationId: 'conv-1', workspace: '/workspace/demo', type: 'acp' }}>
<MessageText message={message} />
</ConversationProvider>
);
expect(screen.getByTestId('file-preview')).toHaveTextContent('/workspace/demo/uploads/photo.png');
});
it('lets text message content use the available row width on desktop', () => {
const message: IMessageText = {
id: 'msg-width',
msg_id: 'msg-width',
conversation_id: 'conv-1',
type: 'text',
position: 'left',
createdAt: Date.now(),
content: {
content: 'wide content',
},
};
render(
<ConversationProvider value={{ conversationId: 'conv-1', workspace: '/workspace/demo', type: 'acp' }}>
<MessageText message={message} />
</ConversationProvider>
);
const content = screen.getByTestId('message-text-content');
expect(content.parentElement?.className).toContain('min-w-0');
expect(content.parentElement?.className).not.toContain('max-w-780px');
});
it('keeps absolute attachment paths unchanged before previewing', () => {
const message: IMessageText = {
id: 'msg-2',
msg_id: 'msg-2',
conversation_id: 'conv-1',
type: 'text',
position: 'right',
createdAt: Date.now(),
content: {
content: 'look at this\n\n[[AION_FILES]]\n/Users/demo/Desktop/photo.png',
},
};
render(
<ConversationProvider value={{ conversationId: 'conv-1', workspace: '/workspace/demo', type: 'acp' }}>
<MessageText message={message} />
</ConversationProvider>
);
expect(screen.getByTestId('file-preview')).toHaveTextContent('/Users/demo/Desktop/photo.png');
});
it('opens a missing-file preview when a local markdown link no longer exists', async () => {
vi.mocked(ipcBridge.fs.getFileMetadata.invoke).mockResolvedValue(null);
localFileLinkMocks.payload = {
path: '/missing/report.xlsx',
reference: {
filePath: '/missing/report.xlsx',
rawReference: '/missing/report.xlsx:10:2',
line: 10,
column: 2,
},
};
renderMessageWithLocalLink();
fireEvent.click(screen.getByRole('button', { name: 'open local file' }));
await waitFor(() => {
expect(previewMocks.openPreview).toHaveBeenCalledWith(
'',
'excel',
expect.objectContaining({
file_name: 'report.xlsx',
file_path: '/missing/report.xlsx',
missingFile: true,
editable: false,
targetLine: 10,
targetColumn: 2,
}),
{ replace: true }
);
});
});
it('opens an existing code local markdown link with read content and target location', async () => {
const filePath = '/workspace/demo/src/app.ts';
localFileLinkMocks.payload = {
path: filePath,
reference: {
filePath,
rawReference: `${filePath}:42:7`,
line: 42,
column: 7,
},
};
vi.mocked(ipcBridge.fs.getFileMetadata.invoke).mockResolvedValue(fileMetadata(filePath));
vi.mocked(ipcBridge.fs.readFile.invoke).mockResolvedValue('const value = 1;\n');
renderMessageWithLocalLink('[app.ts](/workspace/demo/src/app.ts:42:7)');
fireEvent.click(screen.getByRole('button', { name: 'open local file' }));
await waitFor(() => {
expect(previewMocks.openPreview).toHaveBeenCalledWith(
'const value = 1;\n',
'code',
expect.objectContaining({
file_name: 'app.ts',
file_path: filePath,
workspace: '/workspace/demo',
language: 'ts',
targetLine: 42,
targetColumn: 7,
truncated: false,
}),
{ replace: true }
);
});
});
it('opens hash range local markdown links with only the start line in preview metadata', async () => {
const filePath = '/workspace/demo/src/app.ts';
localFileLinkMocks.payload = {
path: filePath,
reference: {
filePath,
rawReference: `${filePath}#L10-L20`,
line: 10,
endLine: 20,
},
};
vi.mocked(ipcBridge.fs.getFileMetadata.invoke).mockResolvedValue(fileMetadata(filePath));
vi.mocked(ipcBridge.fs.readFile.invoke).mockResolvedValue('const value = 1;\n');
renderMessageWithLocalLink('[app.ts](/workspace/demo/src/app.ts#L10-L20)');
fireEvent.click(screen.getByRole('button', { name: 'open local file' }));
await waitFor(() => {
expect(previewMocks.openPreview).toHaveBeenCalledWith(
'const value = 1;\n',
'code',
expect.objectContaining({
file_name: 'app.ts',
file_path: filePath,
workspace: '/workspace/demo',
language: 'ts',
targetLine: 10,
targetColumn: undefined,
truncated: false,
}),
{ replace: true }
);
});
const metadata = previewMocks.openPreview.mock.calls[0]?.[2];
expect(metadata).not.toHaveProperty('endLine');
expect(metadata).not.toHaveProperty('targetEndLine');
});
it('opens office and pdf local markdown links without reading file content', async () => {
const filePath = '/workspace/demo/reports/q2.pdf';
localFileLinkMocks.payload = { path: filePath, reference: undefined };
vi.mocked(ipcBridge.fs.getFileMetadata.invoke).mockResolvedValue(fileMetadata(filePath));
renderMessageWithLocalLink('[q2.pdf](/workspace/demo/reports/q2.pdf)');
fireEvent.click(screen.getByRole('button', { name: 'open local file' }));
await waitFor(() => {
expect(previewMocks.openPreview).toHaveBeenCalledWith(
'',
'pdf',
expect.objectContaining({
file_name: 'q2.pdf',
file_path: filePath,
workspace: '/workspace/demo',
language: 'pdf',
}),
{ replace: true }
);
});
expect(ipcBridge.fs.readFile.invoke).not.toHaveBeenCalled();
expect(ipcBridge.fs.getImageBase64.invoke).not.toHaveBeenCalled();
});
it('opens image local markdown links from base64 content without reading text content', async () => {
const filePath = '/workspace/demo/assets/chart.png';
localFileLinkMocks.payload = { path: filePath, reference: undefined };
vi.mocked(ipcBridge.fs.getFileMetadata.invoke).mockResolvedValue(fileMetadata(filePath));
vi.mocked(ipcBridge.fs.getImageBase64.invoke).mockResolvedValue('data:image/png;base64,abc123');
renderMessageWithLocalLink('[chart.png](/workspace/demo/assets/chart.png)');
fireEvent.click(screen.getByRole('button', { name: 'open local file' }));
await waitFor(() => {
expect(previewMocks.openPreview).toHaveBeenCalledWith(
'data:image/png;base64,abc123',
'image',
expect.objectContaining({
file_name: 'chart.png',
file_path: filePath,
workspace: '/workspace/demo',
language: 'png',
editable: false,
}),
{ replace: true }
);
});
expect(ipcBridge.fs.readFile.invoke).not.toHaveBeenCalled();
});
it('opens large code local markdown links with truncated read content', async () => {
const filePath = '/workspace/demo/logs/app.log';
const content = 'a'.repeat(LARGE_TEXT_PREVIEW_THRESHOLD + 1);
localFileLinkMocks.payload = { path: filePath, reference: undefined };
vi.mocked(ipcBridge.fs.getFileMetadata.invoke).mockResolvedValue(fileMetadata(filePath));
vi.mocked(ipcBridge.fs.readFile.invoke).mockResolvedValue(content);
renderMessageWithLocalLink('[app.log](/workspace/demo/logs/app.log)');
fireEvent.click(screen.getByRole('button', { name: 'open local file' }));
await waitFor(() => {
expect(previewMocks.openPreview).toHaveBeenCalledWith(
content.slice(0, LARGE_TEXT_PREVIEW_MAX_LENGTH),
'code',
expect.objectContaining({
file_name: 'app.log',
file_path: filePath,
truncated: true,
editable: false,
}),
{ replace: true }
);
});
});
});