fix image and html preview (#1050)
This commit is contained in:
91
tests/unit/chat-history-reply-while-sending.test.tsx
Normal file
91
tests/unit/chat-history-reply-while-sending.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
57
tests/unit/image-viewer.test.tsx
Normal file
57
tests/unit/image-viewer.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
81
tests/unit/workspace-browser-body.test.tsx
Normal file
81
tests/unit/workspace-browser-body.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user