Files
Genarrative/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx
T

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');
});
});