fix image and html preview (#1050)

This commit is contained in:
paisley
2026-05-20 11:35:53 +08:00
committed by GitHub
parent 0fb460efca
commit fb2d9e312a
10 changed files with 499 additions and 43 deletions

View File

@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
const { gatewayState, agentsState } = vi.hoisted(() => ({
gatewayState: { status: { state: 'running', port: 18789 } },
agentsState: {
agents: [{ id: 'main', name: 'main' }] as Array<Record<string, unknown>>,
fetchAgents: vi.fn(),
},
}));
vi.mock('@/stores/gateway', () => ({
useGatewayStore: (selector: (state: typeof gatewayState) => unknown) => selector(gatewayState),
}));
vi.mock('@/stores/agents', () => ({
useAgentsStore: (selector: (state: typeof agentsState) => unknown) => selector(agentsState),
}));
vi.mock('@/lib/host-api', () => ({
hostApiFetch: vi.fn().mockResolvedValue({ success: true, messages: [] }),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, unknown> | string) => {
if (typeof params === 'string') return params;
if (key === 'executionGraph.collapsedSummary') {
return `collapsed ${String(params?.toolCount ?? '')} ${String(params?.processCount ?? '')}`.trim();
}
if (key === 'executionGraph.agentRun') return 'Main execution';
if (key === 'executionGraph.title') return 'Execution Graph';
if (key === 'executionGraph.collapseAction') return 'Collapse';
if (key === 'executionGraph.thinkingLabel') return 'Thinking';
if (key.startsWith('taskPanel.stepStatus.')) return key.split('.').at(-1) ?? key;
return key;
},
}),
}));
vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
useStickToBottomInstant: vi.fn(() => ({
contentRef: { current: null },
scrollRef: { current: null },
scrollToBottom: vi.fn(),
isAtBottom: true,
})),
}));
vi.mock('@/hooks/use-min-loading', () => ({
useMinLoading: () => false,
}));
vi.mock('@/pages/Chat/ChatToolbar', () => ({ ChatToolbar: () => null }));
vi.mock('@/pages/Chat/ChatInput', () => ({ ChatInput: () => null }));
describe('Chat history reply while sending', () => {
beforeEach(() => {
vi.resetModules();
});
it('shows assistant reply from history even when sending is still true', async () => {
const { useChatStore } = await import('@/stores/chat');
useChatStore.setState({
messages: [
{ role: 'user', id: 'u1', content: '你好' },
{ role: 'assistant', id: 'a1', content: [{ type: 'text', text: '你好,我在。' }] },
],
loading: false,
error: null,
runError: null,
sending: true,
activeRunId: 'run-1',
streamingText: '',
streamingMessage: null,
streamingTools: [],
pendingFinal: false,
lastUserMessageAt: Date.now(),
currentSessionKey: 'agent:main:main',
currentAgentId: 'main',
});
const { Chat } = await import('@/pages/Chat');
render(<Chat />);
await waitFor(() => {
expect(screen.getByText('你好,我在。')).toBeTruthy();
});
expect(screen.queryByText('Thinking')).toBeNull();
});
});

View File

@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import ImageViewer from '@/components/file-preview/ImageViewer';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (_key: string, options?: string | { defaultValue?: string; error?: string }) => {
if (typeof options === 'string') return options;
if (options?.defaultValue && options.error) {
return options.defaultValue.replace('{{error}}', options.error);
}
return options?.defaultValue ?? '';
},
}),
}));
const readBinaryFile = vi.fn();
vi.mock('@/lib/api-client', () => ({
readBinaryFile: (...args: unknown[]) => readBinaryFile(...args),
}));
describe('ImageViewer', () => {
it('loads image bytes via IPC and renders a blob URL preview', async () => {
const pngBytes = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]);
readBinaryFile.mockResolvedValueOnce({
ok: true,
data: pngBytes,
mimeType: 'image/png',
size: pngBytes.length,
readOnly: true,
});
render(<ImageViewer filePath="/tmp/demo.png" fileName="demo.png" />);
await waitFor(() => {
expect(screen.getByTestId('image-preview')).toBeVisible();
});
const img = screen.getByTestId('image-preview') as HTMLImageElement;
expect(img.src).toMatch(/^blob:/);
expect(readBinaryFile).toHaveBeenCalledWith('/tmp/demo.png', { maxBytes: 50 * 1024 * 1024 });
});
it('shows an error when binary read fails', async () => {
readBinaryFile.mockResolvedValueOnce({
ok: false,
error: 'notFound',
});
render(<ImageViewer filePath="/tmp/missing.png" fileName="missing.png" />);
await waitFor(() => {
expect(screen.getByText('Image failed to load: notFound')).toBeVisible();
});
});
});

View File

@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest';
import { buildRunSegmentMessageIndices } from '@/pages/Chat/task-visualization';
import {
buildRunSegmentMessageIndices,
findReplyMessageIndex,
getPostTriggerSegmentMessages,
getRunSegmentMessages,
hasActiveStreamingReplyInRun,
} from '@/pages/Chat/task-visualization';
import type { RawMessage } from '@/stores/chat';
describe('buildRunSegmentMessageIndices', () => {
@@ -47,3 +53,51 @@ describe('buildRunSegmentMessageIndices', () => {
expect(indices.has(2)).toBe(false);
});
});
describe('getPostTriggerSegmentMessages vs getRunSegmentMessages', () => {
const isUser = (message: RawMessage) => message.role === 'user';
it('keeps lifecycle segment empty while graph segment includes paginated orphans', () => {
const messages: RawMessage[] = [
{ role: 'assistant', content: [{ type: 'text', text: 'prior answer' }] },
{ role: 'user', content: 'follow up' },
];
expect(getPostTriggerSegmentMessages(messages, 1, -1)).toEqual([]);
expect(getRunSegmentMessages(messages, 1, -1, isUser)).toEqual([
{ role: 'assistant', content: [{ type: 'text', text: 'prior answer' }] },
]);
});
it('does not attach a prior turn assistant when an earlier user exists in the window', () => {
const messages: RawMessage[] = [
{ role: 'user', content: 'first' },
{ role: 'assistant', content: [{ type: 'text', text: 'first answer' }] },
{ role: 'user', content: 'second' },
];
expect(getPostTriggerSegmentMessages(messages, 2, -1)).toEqual([]);
expect(getRunSegmentMessages(messages, 2, -1, isUser)).toEqual([]);
});
});
describe('findReplyMessageIndex / hasActiveStreamingReplyInRun', () => {
it('protects a history reply from fold when the run is open but not streaming', () => {
const postTrigger: RawMessage[] = [
{ role: 'assistant', content: [{ type: 'text', text: '你好,我在。' }] },
];
expect(hasActiveStreamingReplyInRun(true, false, null)).toBe(false);
expect(findReplyMessageIndex(postTrigger, false)).toBe(0);
expect(findReplyMessageIndex(postTrigger, true)).toBe(-1);
});
it('folds history when a stream bubble is active', () => {
const postTrigger: RawMessage[] = [
{ role: 'assistant', content: [{ type: 'text', text: 'partial' }] },
];
expect(hasActiveStreamingReplyInRun(true, true, null)).toBe(true);
expect(findReplyMessageIndex(postTrigger, true)).toBe(-1);
});
});

View File

@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { WorkspaceBrowserBody } from '@/components/file-preview/WorkspaceBrowserBody';
import type { WorkspaceTreeNode } from '@/lib/workspace-tree';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (_key: string, options?: string | { defaultValue?: string }) => (
typeof options === 'string' ? options : options?.defaultValue ?? _key
),
}),
}));
const readTextFile = vi.fn();
const invokeIpc = vi.fn(async () => ({}));
vi.mock('@/lib/api-client', () => ({
invokeIpc: (...args: unknown[]) => invokeIpc(...args),
readTextFile: (...args: unknown[]) => readTextFile(...args),
statFile: vi.fn(),
}));
const htmlNode: WorkspaceTreeNode = {
name: 'dashboard.html',
relPath: 'dashboard.html',
absPath: '/workspace/dashboard.html',
isDir: false,
size: 10_700,
ext: '.html',
mimeType: 'text/html',
contentType: 'document',
};
vi.mock('@/lib/workspace-tree', () => ({
loadWorkspaceTree: vi.fn(async () => ({
root: {
name: 'workspace',
relPath: '',
absPath: '/workspace',
isDir: true,
children: [htmlNode],
},
truncated: false,
})),
collectInitialExpanded: vi.fn(() => new Set([''])),
findNode: vi.fn((root: WorkspaceTreeNode, relPath: string) => {
if (relPath === 'dashboard.html') return htmlNode;
return null;
}),
}));
describe('WorkspaceBrowserBody', () => {
it('renders html files as sandboxed HTML preview instead of raw source', async () => {
readTextFile.mockResolvedValueOnce({
ok: true,
content: '<!doctype html><html><body><h1 id="title">Dashboard</h1></body></html>',
size: 72,
readOnly: true,
});
render(
<WorkspaceBrowserBody
agent={{ id: 'main', name: 'Main Agent', workspace: '/workspace' }}
/>,
);
await waitFor(() => {
expect(screen.getByText('dashboard.html')).toBeVisible();
});
fireEvent.click(screen.getByText('dashboard.html'));
const frame = await screen.findByTestId('html-preview-frame');
expect(frame).toBeVisible();
expect(frame).toHaveAttribute(
'sandbox',
'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads',
);
expect(screen.queryByText('<!doctype html>')).not.toBeInTheDocument();
});
});