592 lines
18 KiB
TypeScript
592 lines
18 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { EditorAgentMessage } from '@/packages/shared/src/contracts';
|
|
import { readAssetBytes } from '@/src/services/assetReadUrlService.ts';
|
|
import { copyTextToClipboard } from '@/src/services/clipboard.ts';
|
|
import {
|
|
resetHostRuntimeCacheForTest,
|
|
setHostRuntimeCacheForTest,
|
|
} from '@/src/services/host-bridge/hostBridge.ts';
|
|
import { resetNativeAppHostBridgeForTest } from '@/src/services/host-bridge/nativeAppHostBridge.ts';
|
|
|
|
import { MessageBubble } from './MessageBubble.tsx';
|
|
|
|
vi.mock('@/src/services/assetReadUrlService.ts', () => ({
|
|
getSignedAssetReadUrl: vi.fn().mockResolvedValue('https://asset.test/signed'),
|
|
readAssetBytes: vi.fn(),
|
|
resolveAssetReadUrl: vi.fn().mockResolvedValue('https://asset.test/signed'),
|
|
shouldResolveAssetReadUrl: vi.fn(() => false),
|
|
}));
|
|
|
|
vi.mock('@/src/services/clipboard.ts', () => ({
|
|
copyTextToClipboard: vi.fn(),
|
|
}));
|
|
|
|
function renderMessage(
|
|
message: EditorAgentMessage,
|
|
onReferenceImage?: Parameters<typeof MessageBubble>[0]['onReferenceImage'],
|
|
) {
|
|
return render(
|
|
<MessageBubble
|
|
message={message}
|
|
busyAction={null}
|
|
onConfirmToolCall={vi.fn()}
|
|
onCancelToolCall={vi.fn()}
|
|
onReferenceImage={onReferenceImage}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
describe('MessageBubble', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(copyTextToClipboard).mockResolvedValue(true);
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete window.__TAURI__;
|
|
resetNativeAppHostBridgeForTest();
|
|
resetHostRuntimeCacheForTest();
|
|
});
|
|
|
|
it('shows prefixed system errors as red Agent errors without the wire prefix', () => {
|
|
renderMessage({
|
|
id: 2,
|
|
role: 'system',
|
|
text: 'ERROR planning failed',
|
|
attachments: [],
|
|
toolCall: null,
|
|
createdAt: '2026-07-16T00:00:00Z',
|
|
});
|
|
|
|
const error = screen.getByLabelText('Agent错误');
|
|
expect(error.textContent).toContain('planning failed');
|
|
expect(error.textContent).not.toContain('ERROR');
|
|
expect(error.firstElementChild?.classList.contains('bg-red-50')).toBe(true);
|
|
expect(error.firstElementChild?.classList.contains('text-red-700')).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('continues to hide internal system messages without the error prefix', () => {
|
|
const { container } = renderMessage({
|
|
id: 3,
|
|
role: 'system',
|
|
text: 'internal attachment bookkeeping',
|
|
attachments: [],
|
|
toolCall: null,
|
|
createdAt: '2026-07-16T00:00:00Z',
|
|
});
|
|
|
|
expect(container.childElementCount).toBe(0);
|
|
});
|
|
|
|
it('copies the complete visible message and only exposes the enum action', async () => {
|
|
renderMessage({
|
|
id: 4,
|
|
role: 'assistant',
|
|
text: '第一行\n第二行',
|
|
attachments: [],
|
|
toolCall: null,
|
|
createdAt: '2026-07-20T00:00:00Z',
|
|
});
|
|
|
|
fireEvent.contextMenu(screen.getByLabelText('Agent消息'), {
|
|
clientX: 30,
|
|
clientY: 40,
|
|
});
|
|
expect(
|
|
screen.getByRole('menu', { name: '消息右键菜单' }).style.zIndex,
|
|
).toBe('60');
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '复制文本' }));
|
|
|
|
await waitFor(() =>
|
|
expect(copyTextToClipboard).toHaveBeenCalledWith('第一行\n第二行'),
|
|
);
|
|
await waitFor(() =>
|
|
expect(screen.queryByRole('menu', { name: '消息右键菜单' })).toBeNull(),
|
|
);
|
|
});
|
|
|
|
it('closes the previous menu before another right click opens a new one', () => {
|
|
render(
|
|
<>
|
|
<MessageBubble
|
|
message={{
|
|
id: 40,
|
|
role: 'assistant',
|
|
text: '第一条消息',
|
|
attachments: [],
|
|
toolCall: null,
|
|
createdAt: '2026-07-20T00:00:00Z',
|
|
}}
|
|
busyAction={null}
|
|
onConfirmToolCall={vi.fn()}
|
|
onCancelToolCall={vi.fn()}
|
|
/>
|
|
<MessageBubble
|
|
message={{
|
|
id: 41,
|
|
role: 'assistant',
|
|
text: '第二条消息',
|
|
attachments: [],
|
|
toolCall: null,
|
|
createdAt: '2026-07-20T00:00:00Z',
|
|
}}
|
|
busyAction={null}
|
|
onConfirmToolCall={vi.fn()}
|
|
onCancelToolCall={vi.fn()}
|
|
/>
|
|
</>,
|
|
);
|
|
|
|
const messages = screen.getAllByLabelText('Agent消息');
|
|
fireEvent.contextMenu(messages[0]!, { clientX: 30, clientY: 40 });
|
|
fireEvent.contextMenu(messages[1]!, { clientX: 70, clientY: 80 });
|
|
|
|
const menus = screen.getAllByRole('menu', { name: '消息右键菜单' });
|
|
expect(menus).toHaveLength(1);
|
|
expect(menus[0]?.style.left).toBe('70px');
|
|
expect(menus[0]?.style.top).toBe('80px');
|
|
});
|
|
|
|
it('closes an open menu when the next right click has no menu target', () => {
|
|
renderMessage({
|
|
id: 42,
|
|
role: 'assistant',
|
|
text: '右键后应可收起',
|
|
attachments: [],
|
|
toolCall: null,
|
|
createdAt: '2026-07-20T00:00:00Z',
|
|
});
|
|
|
|
fireEvent.contextMenu(screen.getByLabelText('Agent消息'), {
|
|
clientX: 30,
|
|
clientY: 40,
|
|
});
|
|
expect(screen.getByRole('menu', { name: '消息右键菜单' })).toBeTruthy();
|
|
|
|
fireEvent.contextMenu(document.body, { clientX: 70, clientY: 80 });
|
|
|
|
expect(screen.queryByRole('menu', { name: '消息右键菜单' })).toBeNull();
|
|
});
|
|
|
|
it('copies an attached WebP as PNG without opening the message text menu', async () => {
|
|
let resolveAssetRead!: (response: Response) => void;
|
|
const pendingAssetRead = new Promise<Response>((resolve) => {
|
|
resolveAssetRead = resolve;
|
|
});
|
|
const clipboardWrite = vi.fn(
|
|
async (
|
|
items: Array<{ items: Record<string, Blob | PromiseLike<Blob>> }>,
|
|
) => {
|
|
await items[0]?.items['image/png'];
|
|
},
|
|
);
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: { write: clipboardWrite },
|
|
});
|
|
const ClipboardItemMock = vi.fn(function (
|
|
this: { items: Record<string, Blob | PromiseLike<Blob>> },
|
|
items: Record<string, Blob | PromiseLike<Blob>>,
|
|
) {
|
|
this.items = items;
|
|
});
|
|
Object.defineProperty(globalThis, 'ClipboardItem', {
|
|
configurable: true,
|
|
value: ClipboardItemMock,
|
|
});
|
|
const bitmapClose = vi.fn();
|
|
Object.defineProperty(globalThis, 'createImageBitmap', {
|
|
configurable: true,
|
|
value: vi.fn().mockResolvedValue({
|
|
width: 16,
|
|
height: 9,
|
|
close: bitmapClose,
|
|
}),
|
|
});
|
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
|
drawImage: vi.fn(),
|
|
} as unknown as CanvasRenderingContext2D);
|
|
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
|
(callback) => callback(new Blob(['png'], { type: 'image/png' })),
|
|
);
|
|
vi.mocked(readAssetBytes).mockReturnValue(pendingAssetRead);
|
|
const { container } = renderMessage({
|
|
id: 5,
|
|
role: 'user',
|
|
text: '参考这张图',
|
|
attachments: [
|
|
{
|
|
source: 'canvas_resource',
|
|
referenceId: 'resource-1',
|
|
objectKey: 'editor/private.webp',
|
|
imageSrc: '/generated-editor-images/private.webp',
|
|
label: '参考图.webp',
|
|
},
|
|
],
|
|
toolCall: null,
|
|
createdAt: '2026-07-20T00:00:00Z',
|
|
});
|
|
|
|
fireEvent.contextMenu(container.querySelector('.group')!, {
|
|
clientX: 30,
|
|
clientY: 40,
|
|
});
|
|
|
|
expect(screen.getByRole('menu', { name: '消息素材右键菜单' })).toBeTruthy();
|
|
expect(screen.queryByRole('menuitem', { name: '复制文本' })).toBeNull();
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '复制图片' }));
|
|
|
|
// The clipboard write starts synchronously, before private asset loading completes.
|
|
expect(clipboardWrite).toHaveBeenCalledTimes(1);
|
|
expect(readAssetBytes).toHaveBeenCalledWith(
|
|
'/generated-editor-images/private.webp',
|
|
{ objectKey: 'editor/private.webp' },
|
|
);
|
|
expect(ClipboardItemMock).toHaveBeenCalledWith({
|
|
'image/png': expect.any(Promise),
|
|
});
|
|
resolveAssetRead({
|
|
blob: vi
|
|
.fn()
|
|
.mockResolvedValue(new Blob(['webp'], { type: 'image/webp' })),
|
|
} as unknown as Response);
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.queryByRole('menu', { name: '消息素材右键菜单' }),
|
|
).toBeNull(),
|
|
);
|
|
expect(bitmapClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('references a historical attachment through the existing asset menu', async () => {
|
|
const attachment = {
|
|
source: 'canvas_resource' as const,
|
|
referenceId: 'resource-reference-1',
|
|
objectKey: 'editor/reference.png',
|
|
imageSrc: '/generated-editor-images/reference.png',
|
|
label: '历史参考图',
|
|
};
|
|
const onReferenceImage = vi.fn(() => true);
|
|
const { container } = renderMessage(
|
|
{
|
|
id: 7,
|
|
role: 'user',
|
|
text: '参考这张图',
|
|
attachments: [attachment],
|
|
toolCall: null,
|
|
createdAt: '2026-07-20T00:00:00Z',
|
|
},
|
|
onReferenceImage,
|
|
);
|
|
|
|
fireEvent.contextMenu(container.querySelector('.group')!, {
|
|
clientX: 30,
|
|
clientY: 40,
|
|
});
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
|
|
|
|
expect(onReferenceImage).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
kind: 'attachment',
|
|
source: 'canvas_resource',
|
|
referenceId: 'resource-reference-1',
|
|
imageSrc: '/generated-editor-images/reference.png',
|
|
objectKey: 'editor/reference.png',
|
|
}),
|
|
);
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.queryByRole('menu', { name: '消息素材右键菜单' }),
|
|
).toBeNull(),
|
|
);
|
|
});
|
|
|
|
it('offers reference for generated images using their media lookup fields', async () => {
|
|
const message = {
|
|
id: 8,
|
|
role: 'system' as const,
|
|
text: 'internal tool result',
|
|
attachments: [],
|
|
toolCall: {
|
|
toolName: 'generate_image',
|
|
status: 'completed' as const,
|
|
args: {},
|
|
displayArgs: {
|
|
stringArgs: [],
|
|
imageArgs: [],
|
|
extras: { priceMudPoints: 1 },
|
|
},
|
|
images: [
|
|
{
|
|
resourceId: 'resource-generated-1',
|
|
objectKey: 'editor/generated.png',
|
|
imageSrc: '/generated-editor-images/generated.png',
|
|
width: 512,
|
|
height: 512,
|
|
},
|
|
{
|
|
objectKey: 'editor/legacy.png',
|
|
imageSrc: '/generated-editor-images/legacy.png',
|
|
},
|
|
],
|
|
},
|
|
createdAt: '2026-07-20T00:00:00Z',
|
|
};
|
|
const onReferenceImage = vi.fn(() => true);
|
|
const { container } = renderMessage(message, onReferenceImage);
|
|
await waitFor(() =>
|
|
expect(
|
|
container.querySelectorAll('.grid.grid-cols-3 > div'),
|
|
).toHaveLength(2),
|
|
);
|
|
const imageCards = container.querySelectorAll('.grid.grid-cols-3 > div');
|
|
|
|
fireEvent.contextMenu(imageCards[0]!, { clientX: 30, clientY: 40 });
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
|
|
expect(onReferenceImage).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
kind: 'generated_media',
|
|
mediaSrc: '/generated-editor-images/generated.png',
|
|
objectKey: 'editor/generated.png',
|
|
}),
|
|
);
|
|
|
|
fireEvent.contextMenu(imageCards[1]!, { clientX: 50, clientY: 60 });
|
|
expect(screen.getByRole('menuitem', { name: '引用' })).toBeTruthy();
|
|
expect(screen.getByRole('menuitem', { name: '复制图片' })).toBeTruthy();
|
|
expect(screen.getByRole('menuitem', { name: '下载图片' })).toBeTruthy();
|
|
});
|
|
|
|
it('exports right-click images through the native HostBridge', async () => {
|
|
const invoke = vi.fn(
|
|
async (_command: string, args?: Record<string, unknown>) => {
|
|
const request = (args as { request: { id: string } }).request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: true,
|
|
result: {
|
|
action: 'saved',
|
|
fileName: '参考图.png',
|
|
bytes: 5,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
setHostRuntimeCacheForTest({
|
|
shell: 'tauri_desktop',
|
|
platform: 'linux',
|
|
hostVersion: '0.1.0',
|
|
bridgeVersion: 1,
|
|
capabilities: ['file.exportImage'],
|
|
});
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: async <Result,>(
|
|
command: string,
|
|
args?: Record<string, unknown>,
|
|
) => (await invoke(command, args)) as Result,
|
|
},
|
|
};
|
|
const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, 'click');
|
|
vi.mocked(readAssetBytes).mockResolvedValue({
|
|
blob: vi
|
|
.fn()
|
|
.mockResolvedValue(new Blob(['image'], { type: 'image/png' })),
|
|
} as unknown as Response);
|
|
const { container } = renderMessage({
|
|
id: 9,
|
|
role: 'user',
|
|
text: '保存参考图',
|
|
attachments: [
|
|
{
|
|
source: 'canvas_resource',
|
|
referenceId: 'resource-download-image',
|
|
objectKey: 'editor/reference.png',
|
|
imageSrc: '/generated-editor-images/reference.png',
|
|
label: '参考图.webp',
|
|
},
|
|
],
|
|
toolCall: null,
|
|
createdAt: '2026-07-20T00:00:00Z',
|
|
});
|
|
|
|
fireEvent.contextMenu(container.querySelector('.group')!, {
|
|
clientX: 30,
|
|
clientY: 40,
|
|
});
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '下载图片' }));
|
|
|
|
await waitFor(() =>
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'file.exportImage',
|
|
payload: {
|
|
fileName: '参考图.png',
|
|
base64Data: 'aW1hZ2U=',
|
|
mimeType: 'image/png',
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
expect(anchorClick).not.toHaveBeenCalled();
|
|
expect(screen.queryByRole('menu', { name: '消息素材右键菜单' })).toBeNull();
|
|
});
|
|
|
|
it('exports right-click audio through the native HostBridge', async () => {
|
|
const invoke = vi.fn(
|
|
async (_command: string, args?: Record<string, unknown>) => {
|
|
const request = (args as { request: { id: string } }).request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: true,
|
|
result: {
|
|
action: 'saved',
|
|
fileName: 'Agent生成音频-1.wav',
|
|
bytes: 5,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
setHostRuntimeCacheForTest({
|
|
shell: 'tauri_desktop',
|
|
platform: 'linux',
|
|
hostVersion: '0.1.0',
|
|
bridgeVersion: 1,
|
|
capabilities: ['file.exportAudio'],
|
|
});
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: async <Result,>(
|
|
command: string,
|
|
args?: Record<string, unknown>,
|
|
) => (await invoke(command, args)) as Result,
|
|
},
|
|
};
|
|
const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, 'click');
|
|
vi.mocked(readAssetBytes).mockResolvedValue({
|
|
blob: vi
|
|
.fn()
|
|
.mockResolvedValue(new Blob(['audio'], { type: 'audio/wav' })),
|
|
} as unknown as Response);
|
|
const { container } = renderMessage({
|
|
id: 10,
|
|
role: 'system',
|
|
text: 'internal tool result',
|
|
attachments: [],
|
|
toolCall: {
|
|
toolName: 'generate_sound_effect',
|
|
status: 'completed',
|
|
args: {},
|
|
displayArgs: {
|
|
stringArgs: [],
|
|
imageArgs: [],
|
|
extras: { priceMudPoints: 1 },
|
|
},
|
|
images: [],
|
|
videos: [],
|
|
audios: [
|
|
{
|
|
resourceId: 'audio-1',
|
|
objectKey: 'editor/audio.wav',
|
|
audioSrc: '/generated-editor-audios/audio.wav',
|
|
},
|
|
],
|
|
},
|
|
createdAt: '2026-07-20T00:00:00Z',
|
|
});
|
|
await waitFor(() => expect(container.querySelector('audio')).toBeTruthy());
|
|
|
|
fireEvent.contextMenu(container.querySelector('audio')!.parentElement!, {
|
|
clientX: 30,
|
|
clientY: 40,
|
|
});
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '下载音频' }));
|
|
|
|
await waitFor(() =>
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'file.exportAudio',
|
|
payload: {
|
|
fileName: 'Agent生成音频-1.wav',
|
|
base64Data: 'YXVkaW8=',
|
|
mimeType: 'audio/wav',
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
expect(anchorClick).not.toHaveBeenCalled();
|
|
expect(screen.queryByRole('menu', { name: '消息素材右键菜单' })).toBeNull();
|
|
});
|
|
|
|
it('downloads generated video with the inferred extension', async () => {
|
|
const createObjectURL = vi.fn(() => 'blob:agent-video');
|
|
const revokeObjectURL = vi.fn();
|
|
Object.defineProperty(URL, 'createObjectURL', {
|
|
configurable: true,
|
|
value: createObjectURL,
|
|
});
|
|
Object.defineProperty(URL, 'revokeObjectURL', {
|
|
configurable: true,
|
|
value: revokeObjectURL,
|
|
});
|
|
const anchorClick = vi
|
|
.spyOn(HTMLAnchorElement.prototype, 'click')
|
|
.mockImplementation(() => undefined);
|
|
vi.mocked(readAssetBytes).mockResolvedValue({
|
|
blob: vi
|
|
.fn()
|
|
.mockResolvedValue(new Blob(['video'], { type: 'video/mp4' })),
|
|
} as unknown as Response);
|
|
const { container } = renderMessage({
|
|
id: 6,
|
|
role: 'system',
|
|
text: 'internal tool result',
|
|
attachments: [],
|
|
toolCall: {
|
|
toolName: 'generate_video',
|
|
status: 'completed',
|
|
args: {},
|
|
displayArgs: {
|
|
stringArgs: [],
|
|
imageArgs: [],
|
|
extras: { priceMudPoints: 1 },
|
|
},
|
|
images: [],
|
|
videos: [
|
|
{
|
|
resourceId: 'video-1',
|
|
objectKey: 'editor/video.mp4',
|
|
videoSrc: '/generated-editor-videos/video.mp4',
|
|
},
|
|
],
|
|
audios: [],
|
|
},
|
|
createdAt: '2026-07-20T00:00:00Z',
|
|
});
|
|
await waitFor(() => expect(container.querySelector('video')).toBeTruthy());
|
|
const video = container.querySelector('video');
|
|
|
|
fireEvent.contextMenu(video!.parentElement!, {
|
|
clientX: 30,
|
|
clientY: 40,
|
|
});
|
|
expect(screen.queryByRole('menuitem', { name: '复制图片' })).toBeNull();
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '下载视频' }));
|
|
|
|
await waitFor(() => expect(anchorClick).toHaveBeenCalledTimes(1));
|
|
expect(screen.queryByRole('menu', { name: '消息素材右键菜单' })).toBeNull();
|
|
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
|
|
expect(revokeObjectURL).toHaveBeenCalledWith('blob:agent-video');
|
|
});
|
|
});
|