feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
P1 修复面收口: - 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR), 根治"真实网络超时被误报为用户中断" - 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道) - 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 + 前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死) - 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED) - DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制) - IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复 P2 安全纵深: - preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险) - CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步) - MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径) - write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏) - 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库 - ReDoS 检测共享化(search_files/file_editor 统一拦截) - run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command) - MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 + 十六进制映射解析 + 尾点剥离) P3 架构还债: - temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘 - SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async) - 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine) - i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数) - 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释) - 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 + 用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位) P4 能力演进: - 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报) - run-lock 30s 超时强制 abort(旧 run 卡死不无限排队) - RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复) - FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退) - getContextWindow 兜底 1M→128K(未知模型防 413) 测试: - 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、 工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、 纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例) - 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷 - 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 / mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性 版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload, 新增 jsdom/@testing-library(devDependencies 不打包) 回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过; 系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ChatInput 组件测试(v0.7.4 引入 jsdom 组件测试)
|
||||
*
|
||||
* 覆盖:输入渲染、发送调用、IME 合成回车不发送(P1-7)、
|
||||
* Ctrl+Enter 发送、Enter 发送、/clear 命令、附件按钮存在。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { useUIStore } from '@renderer/stores/ui-store';
|
||||
import { useSessionStore } from '@renderer/stores/session-store';
|
||||
import { ChatInput } from '../ChatInput';
|
||||
|
||||
function resetStore(): void {
|
||||
useAgentStore.setState({
|
||||
currentSessionId: 's_test',
|
||||
messages: [],
|
||||
isStreaming: false,
|
||||
configLoaded: true,
|
||||
toolsReady: true,
|
||||
agentStatus: 'idle',
|
||||
currentIteration: 0,
|
||||
tokenUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
lastInputTokens: 0,
|
||||
lastCompressedSaved: 0,
|
||||
},
|
||||
traceSteps: [],
|
||||
currentRunId: null,
|
||||
sessionRunStates: {},
|
||||
modelVisionCaps: null,
|
||||
} as never);
|
||||
useUIStore.setState({
|
||||
settingsOpen: false,
|
||||
detailTab: 'trace',
|
||||
detailVisible: true,
|
||||
sidebarVisible: true,
|
||||
focusMode: false,
|
||||
preFocusSidebarVisible: true,
|
||||
preFocusDetailVisible: true,
|
||||
} as never);
|
||||
useSessionStore.setState({ currentSessionId: null, sessions: [], searchQuery: '' });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetStore();
|
||||
});
|
||||
|
||||
describe('ChatInput — 输入与发送', () => {
|
||||
it('渲染输入框与发送按钮', () => {
|
||||
render(<ChatInput />);
|
||||
expect(screen.getByPlaceholderText(/输入/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('输入文字后 Enter 触发 sendMessage', () => {
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '你好' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', shiftKey: false });
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith('你好', undefined, undefined);
|
||||
});
|
||||
|
||||
it('空输入 Enter 不发送', () => {
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Ctrl+Enter 触发发送', () => {
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '测试' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', ctrlKey: true });
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith('测试', undefined, undefined);
|
||||
});
|
||||
|
||||
it('Shift+Enter 换行不发送', () => {
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '多行' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true });
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('流式中 Enter 不发送', () => {
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
useAgentStore.setState({ isStreaming: true } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '内容' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatInput — IME 保护(P1-7)', () => {
|
||||
// fireEvent.keyDown 的 nativeEvent 无法可靠携带 isComposing;
|
||||
// 用真实 KeyboardEvent + dispatchEvent 模拟 IME 合成事件。
|
||||
function dispatchEnterWithComposing(
|
||||
input: HTMLElement,
|
||||
composing: boolean,
|
||||
ctrlKey = false,
|
||||
): void {
|
||||
const ev = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
ctrlKey,
|
||||
});
|
||||
Object.defineProperty(ev, 'isComposing', { value: composing });
|
||||
input.dispatchEvent(ev);
|
||||
}
|
||||
|
||||
it('IME 合成中 Enter 不发送(isComposing=true)', () => {
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: 'nihao' } });
|
||||
dispatchEnterWithComposing(input, true);
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('IME 合成中 Ctrl+Enter 也不发送(P1-7 修正)', () => {
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: 'nihao' } });
|
||||
dispatchEnterWithComposing(input, true, true);
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('IME 合成结束(isComposing=false)Enter 正常发送', () => {
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '你好' } });
|
||||
dispatchEnterWithComposing(input, false);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith('你好', undefined, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatInput — / 命令', () => {
|
||||
it('输入 /clear 清空会话', async () => {
|
||||
const clearMessages = vi.fn().mockResolvedValue(undefined);
|
||||
useAgentStore.setState({ clearMessages: clearMessages } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '/clear' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(clearMessages).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('输入 / 显示斜杠菜单', () => {
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '/' } });
|
||||
// 斜杠菜单命令(clear/export/tool/memory)
|
||||
expect(screen.getAllByText(/^\//).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatInput — 斜杠菜单(v0.7.4 P3-7)', () => {
|
||||
it('输入 / 显示全部 4 个命令', () => {
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '/' } });
|
||||
expect(screen.getByText('/clear')).toBeInTheDocument();
|
||||
expect(screen.getByText('/export')).toBeInTheDocument();
|
||||
expect(screen.getByText('/tool')).toBeInTheDocument();
|
||||
expect(screen.getByText('/memory')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('输入 /cl 过滤菜单只显示 /clear', () => {
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '/cl' } });
|
||||
expect(screen.getByText('/clear')).toBeInTheDocument();
|
||||
expect(screen.queryByText('/export')).toBeNull();
|
||||
});
|
||||
|
||||
it('输入普通文本隐藏斜杠菜单', () => {
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '/' } });
|
||||
expect(screen.getByText('/clear')).toBeInTheDocument();
|
||||
fireEvent.change(input, { target: { value: 'hello' } });
|
||||
expect(screen.queryByText('/clear')).toBeNull();
|
||||
});
|
||||
|
||||
it('点击菜单项插入命令文本并聚焦输入框', () => {
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '/' } });
|
||||
fireEvent.click(screen.getByText('/memory'));
|
||||
expect((input as HTMLTextAreaElement).value).toBe('/memory ');
|
||||
});
|
||||
|
||||
it('菜单显示时按 Escape 关闭', () => {
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '/' } });
|
||||
expect(screen.getByText('/clear')).toBeInTheDocument();
|
||||
fireEvent.keyDown(input, { key: 'Escape' });
|
||||
expect(screen.queryByText('/clear')).toBeNull();
|
||||
});
|
||||
|
||||
it('输入 /tool 执行打开设置面板', () => {
|
||||
useUIStore.setState({ settingsOpen: false } as never);
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '/tool' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(useUIStore.getState().settingsOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('输入 /memory 切换到详情面板 Memory 标签', () => {
|
||||
useUIStore.setState({ detailTab: 'trace', detailVisible: false, focusMode: false } as never);
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '/memory' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(useUIStore.getState().detailTab).toBe('memory');
|
||||
expect(useUIStore.getState().detailVisible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatInput — 快捷键与按钮状态', () => {
|
||||
it('Ctrl+Shift+Enter 插入换行而不发送', () => {
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '第一行' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', ctrlKey: true, shiftKey: true });
|
||||
expect((input as HTMLTextAreaElement).value).toBe('第一行\n');
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('空输入时发送按钮禁用', () => {
|
||||
render(<ChatInput />);
|
||||
const sendBtn = screen.getByText('发送').closest('button') as HTMLButtonElement;
|
||||
expect(sendBtn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('有内容时发送按钮可用', () => {
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: 'hello' } });
|
||||
const sendBtn = screen.getByText('发送').closest('button') as HTMLButtonElement;
|
||||
expect(sendBtn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('点击发送按钮调用 sendMessage 并清空输入', () => {
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '按钮发送' } });
|
||||
fireEvent.click(screen.getByText('发送').closest('button') as HTMLElement);
|
||||
expect(sendMessage).toHaveBeenCalledWith('按钮发送', undefined, undefined);
|
||||
expect((input as HTMLTextAreaElement).value).toBe('');
|
||||
});
|
||||
|
||||
it('流式中显示中断按钮,点击调用 abort', () => {
|
||||
const abort = vi.fn();
|
||||
useAgentStore.setState({ abort: abort } as never);
|
||||
useAgentStore.setState({ isStreaming: true } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const abortBtn = screen.getByText('中断').closest('button') as HTMLElement;
|
||||
expect(abortBtn).not.toBeNull();
|
||||
fireEvent.click(abortBtn);
|
||||
expect(abort).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('流式中发送按钮被替换为中断按钮', () => {
|
||||
useAgentStore.setState({ isStreaming: true } as never);
|
||||
render(<ChatInput />);
|
||||
expect(screen.getByText('中断')).toBeInTheDocument();
|
||||
expect(screen.queryByText('发送')).toBeNull();
|
||||
});
|
||||
|
||||
it('configLoaded=false 时输入框禁用且占位符为"正在加载配置..."', () => {
|
||||
useAgentStore.setState({ configLoaded: false, toolsReady: true } as never);
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText('正在加载配置...');
|
||||
expect((input as HTMLTextAreaElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('toolsReady=false 时占位符为"工具加载中..."', () => {
|
||||
useAgentStore.setState({ configLoaded: true, toolsReady: false } as never);
|
||||
render(<ChatInput />);
|
||||
expect(screen.getByPlaceholderText('工具加载中...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('configLoaded=false 时即使有内容发送按钮也禁用', () => {
|
||||
useAgentStore.setState({ configLoaded: false, toolsReady: true } as never);
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/正在加载配置/);
|
||||
fireEvent.change(input, { target: { value: '内容' } });
|
||||
const sendBtn = screen.getByText('发送').closest('button') as HTMLButtonElement;
|
||||
expect(sendBtn.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatInput — 附件入口', () => {
|
||||
it('渲染附件按钮(回形针图标)', () => {
|
||||
render(<ChatInput />);
|
||||
const attachBtn = document.querySelector('.lucide-paperclip')?.closest('button');
|
||||
expect(attachBtn).not.toBeNull();
|
||||
});
|
||||
|
||||
it('存在隐藏的文件输入框(multiple)', () => {
|
||||
render(<ChatInput />);
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
expect(fileInput).not.toBeNull();
|
||||
expect(fileInput.multiple).toBe(true);
|
||||
expect(fileInput.style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('点击附件按钮触发隐藏文件输入框 click', () => {
|
||||
render(<ChatInput />);
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const clickSpy = vi.spyOn(fileInput, 'click');
|
||||
const attachBtn = document.querySelector('.lucide-paperclip')?.closest('button') as HTMLElement;
|
||||
fireEvent.click(attachBtn);
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatInput — 草稿自动保存', () => {
|
||||
it('输入内容后写入 sessionStorage 草稿', () => {
|
||||
useSessionStore.setState({ currentSessionId: 's_test' });
|
||||
sessionStorage.removeItem('draft-s_test');
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '草稿内容' } });
|
||||
expect(sessionStorage.getItem('draft-s_test')).toBe('草稿内容');
|
||||
});
|
||||
|
||||
it('挂载时从 sessionStorage 恢复草稿', () => {
|
||||
useSessionStore.setState({ currentSessionId: 's_test' });
|
||||
sessionStorage.setItem('draft-s_test', '恢复的草稿');
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/) as HTMLTextAreaElement;
|
||||
expect(input.value).toBe('恢复的草稿');
|
||||
});
|
||||
|
||||
it('发送后清除草稿', () => {
|
||||
useSessionStore.setState({ currentSessionId: 's_test' });
|
||||
sessionStorage.setItem('draft-s_test', '待清除');
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '内容' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(sendMessage).toHaveBeenCalled();
|
||||
expect(sessionStorage.getItem('draft-s_test')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,475 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* MessageList / MessageItem / AssistantMessage 组件测试
|
||||
*
|
||||
* 覆盖:
|
||||
* - MessageList 空列表 / 有消息渲染 / 流式跟随(StreamingIndicator 显隐)
|
||||
* - MessageItem 消息路由(user / assistant / system / tool)
|
||||
* - AssistantMessage 思考块、工具卡片、Markdown 渲染(代码块/列表)、
|
||||
* 流式纯文本渲染、时间戳、右键菜单、复制按钮
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MessageList } from '../MessageList';
|
||||
import { MessageItem } from '../MessageItem';
|
||||
import { AssistantMessage } from '../AssistantMessage';
|
||||
import { useAgentStore, type ChatMessage, type ToolCallInfo } from '@renderer/stores/agent-store';
|
||||
|
||||
// react-virtuoso 在 jsdom 下无真实测量能力,mock 为渲染全部条目的简单列表
|
||||
vi.mock('react-virtuoso', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
Virtuoso: (props: {
|
||||
data: ChatMessage[];
|
||||
itemContent: (index: number, msg: ChatMessage) => React.ReactNode;
|
||||
components?: { Footer?: React.ComponentType };
|
||||
}) =>
|
||||
React.createElement(
|
||||
React.Fragment,
|
||||
null,
|
||||
props.data.map((msg, index) =>
|
||||
React.createElement(React.Fragment, { key: msg.id }, props.itemContent(index, msg)),
|
||||
),
|
||||
props.components?.Footer ? React.createElement(props.components.Footer) : null,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const agentInitial = useAgentStore.getState();
|
||||
|
||||
function makeMessage(overrides: Partial<ChatMessage> & { id: string }): ChatMessage {
|
||||
return {
|
||||
role: 'user',
|
||||
content: '',
|
||||
timestamp: 1730000000000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeToolCall(overrides: Partial<ToolCallInfo> = {}): ToolCallInfo {
|
||||
return {
|
||||
id: 'tc_1',
|
||||
name: 'read_file',
|
||||
args: { path: '/tmp/a.txt' },
|
||||
status: 'success',
|
||||
durationMs: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAgentStore.setState(agentInitial, true);
|
||||
});
|
||||
|
||||
describe('MessageList — 空列表', () => {
|
||||
it('无消息且非流式时渲染空状态(标题/副标题/提示)', () => {
|
||||
useAgentStore.setState({ messages: [], isStreaming: false });
|
||||
render(<MessageList />);
|
||||
expect(screen.getByText('MetonaAI Desktop')).toBeInTheDocument();
|
||||
expect(screen.getByText('生产级通用 AI Agent 智能体桌面应用')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Agent 就绪/)).toBeInTheDocument();
|
||||
// logo
|
||||
expect(document.querySelector('img[alt="Metona"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('空列表时消息区无 role=log', () => {
|
||||
useAgentStore.setState({ messages: [], isStreaming: false });
|
||||
render(<MessageList />);
|
||||
expect(document.querySelector('[role="log"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessageList — 有消息渲染', () => {
|
||||
it('渲染 user 与 assistant 消息内容', () => {
|
||||
useAgentStore.setState({
|
||||
messages: [
|
||||
makeMessage({ id: 'm1', role: 'user', content: '你好' }),
|
||||
makeMessage({ id: 'm2', role: 'assistant', content: '我是助手' }),
|
||||
],
|
||||
isStreaming: false,
|
||||
currentSessionId: 's1',
|
||||
});
|
||||
render(<MessageList />);
|
||||
expect(screen.getByText('你好')).toBeInTheDocument();
|
||||
expect(screen.getByText('我是助手')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('消息区为 live region', () => {
|
||||
useAgentStore.setState({
|
||||
messages: [makeMessage({ id: 'm1', role: 'user', content: 'x' })],
|
||||
isStreaming: false,
|
||||
currentSessionId: 's1',
|
||||
});
|
||||
render(<MessageList />);
|
||||
const log = document.querySelector('[role="log"]');
|
||||
expect(log).toBeInTheDocument();
|
||||
expect(log?.getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
|
||||
it('渲染 system 消息', () => {
|
||||
useAgentStore.setState({
|
||||
messages: [makeMessage({ id: 'm1', role: 'system', content: '系统提示' })],
|
||||
isStreaming: false,
|
||||
});
|
||||
render(<MessageList />);
|
||||
expect(screen.getByText('系统提示')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessageList — 流式', () => {
|
||||
it('流式且最后一条是 user 消息时显示 StreamingIndicator', () => {
|
||||
useAgentStore.setState({
|
||||
messages: [makeMessage({ id: 'm1', role: 'user', content: '请回答' })],
|
||||
isStreaming: true,
|
||||
});
|
||||
render(<MessageList />);
|
||||
expect(screen.getByText('正在连接 AI...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('流式且最后一条是 assistant 消息时隐藏 StreamingIndicator', () => {
|
||||
useAgentStore.setState({
|
||||
messages: [
|
||||
makeMessage({ id: 'm1', role: 'user', content: '请回答' }),
|
||||
makeMessage({ id: 'm2', role: 'assistant', content: '回复中' }),
|
||||
],
|
||||
isStreaming: true,
|
||||
});
|
||||
render(<MessageList />);
|
||||
expect(screen.queryByText('正在连接 AI...')).toBeNull();
|
||||
});
|
||||
|
||||
it('流式时最后一条 assistant 消息使用纯文本渲染(无 markdown 解析)', () => {
|
||||
useAgentStore.setState({
|
||||
messages: [
|
||||
makeMessage({ id: 'm1', role: 'user', content: 'q' }),
|
||||
makeMessage({ id: 'm2', role: 'assistant', content: '# 标题\n\n正文' }),
|
||||
],
|
||||
isStreaming: true,
|
||||
agentStatus: 'thinking',
|
||||
});
|
||||
const { container } = render(<MessageList />);
|
||||
// prose-streaming 用于流式纯文本
|
||||
expect(container.querySelector('.prose-streaming')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('流式时非最后一条消息仍正常渲染 markdown', () => {
|
||||
useAgentStore.setState({
|
||||
messages: [
|
||||
makeMessage({ id: 'm1', role: 'user', content: 'q' }),
|
||||
makeMessage({ id: 'm2', role: 'assistant', content: '**历史回复**' }),
|
||||
makeMessage({ id: 'm3', role: 'assistant', content: '新内容' }),
|
||||
],
|
||||
isStreaming: true,
|
||||
});
|
||||
render(<MessageList />);
|
||||
expect(screen.getByText('历史回复')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessageItem — 消息路由', () => {
|
||||
it('user 消息渲染 UserMessage', () => {
|
||||
const { container } = render(
|
||||
<MessageItem message={makeMessage({ id: 'm1', role: 'user', content: '用户内容' })} />,
|
||||
);
|
||||
expect(screen.getByText('用户内容')).toBeInTheDocument();
|
||||
expect(container.querySelector('.lucide-user')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('assistant 消息渲染 AssistantMessage', () => {
|
||||
render(
|
||||
<MessageItem message={makeMessage({ id: 'm1', role: 'assistant', content: '助手内容' })} />,
|
||||
);
|
||||
expect(screen.getByText('助手内容')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('system 消息渲染 SystemMessage(居中胶囊)', () => {
|
||||
const { container } = render(
|
||||
<MessageItem message={makeMessage({ id: 'm1', role: 'system', content: '公告' })} />,
|
||||
);
|
||||
expect(screen.getByText('公告')).toBeInTheDocument();
|
||||
// system 无头像
|
||||
expect(container.querySelector('.lucide-bot')).toBeNull();
|
||||
});
|
||||
|
||||
it('tool 消息不渲染(返回 null)', () => {
|
||||
const { container } = render(
|
||||
<MessageItem message={makeMessage({ id: 'm1', role: 'tool', content: 'tool result' })} />,
|
||||
);
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('isStreaming 且 isLast 时向 AssistantMessage 传递流式状态(使用纯文本渲染)', () => {
|
||||
render(
|
||||
<MessageItem
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '流' })}
|
||||
isLast
|
||||
isStreaming
|
||||
/>,
|
||||
);
|
||||
// 流式路径使用 prose-streaming 纯文本 pre(非 markdown 解析)
|
||||
expect(document.querySelector('pre.prose-streaming')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssistantMessage — 内容与 Markdown 渲染', () => {
|
||||
it('渲染普通文本内容', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '普通回复' })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('普通回复')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染 Markdown 标题与加粗', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '# 大标题\n\n**加粗** 文本' })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('heading', { level: 1, name: '大标题' })).toBeInTheDocument();
|
||||
expect(screen.getByText('加粗')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染 Markdown 无序列表', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '- 项目一\n- 项目二' })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('项目一')).toBeInTheDocument();
|
||||
expect(screen.getByText('项目二')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染 Markdown 有序列表', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '1. 第一步\n2. 第二步' })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('第一步')).toBeInTheDocument();
|
||||
expect(screen.getByText('第二步')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染代码块:显示语言标签与代码内容', () => {
|
||||
const { container } = render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '```ts\nconst x = 1;\n```' })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('ts')).toBeInTheDocument();
|
||||
expect(container.querySelector('pre')?.textContent).toContain('const x = 1;');
|
||||
});
|
||||
|
||||
it('行内代码不带语言标签时渲染为普通 code', () => {
|
||||
const { container } = render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '使用 `inline()` 调用' })}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector('code')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('复制按钮点击后复制代码并切换为已复制图标', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
|
||||
try {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '```js\nlet a = 1;\n```' })}
|
||||
/>,
|
||||
);
|
||||
const copyBtn = document.querySelector('.copy-btn') as HTMLElement;
|
||||
expect(copyBtn).not.toBeNull();
|
||||
// 初始为复制图标
|
||||
expect(copyBtn.querySelector('.lucide-copy')).not.toBeNull();
|
||||
fireEvent.click(copyBtn);
|
||||
expect(writeText).toHaveBeenCalledWith('let a = 1;');
|
||||
// 点击后切换为已复制图标(Tooltip 文本需 hover 才渲染,用图标断言)
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.copy-btn .lucide-check')).not.toBeNull();
|
||||
});
|
||||
} finally {
|
||||
// v0.7.4 回归修复: 还原 clipboard 桩,避免跨用例污染
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('渲染链接文本(GFM autolink 不生成跳转外链 DOM 问题)', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
content: '访问 [官网](https://example.com)',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const link = screen.getByRole('link', { name: '官网' });
|
||||
expect(link.getAttribute('href')).toBe('https://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssistantMessage — 思考过程', () => {
|
||||
it('有 reasoningContent 时显示思考标签', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
content: '回复',
|
||||
reasoningContent: '推理中...',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('思考过程')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无 reasoningContent 时不渲染思考区', () => {
|
||||
const { container } = render(
|
||||
<AssistantMessage message={makeMessage({ id: 'm1', role: 'assistant', content: '回复' })} />,
|
||||
);
|
||||
expect(container.textContent).not.toContain('思考过程');
|
||||
});
|
||||
|
||||
it('流式且 agentStatus=thinking 时思考标签变为"正在思考..."且可展开思考内容', () => {
|
||||
useAgentStore.setState({ agentStatus: 'thinking' });
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
reasoningContent: '正在思考的内容',
|
||||
})}
|
||||
isStreaming
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('正在思考...')).toBeInTheDocument();
|
||||
expect(screen.getByText('正在思考的内容')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssistantMessage — 工具调用', () => {
|
||||
it('有 toolCalls 时显示工具调用标签与工具名', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
content: '回复',
|
||||
toolCalls: [makeToolCall({ name: 'list_directory' })],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
// 标签带 🔧 emoji 前缀,用正则匹配
|
||||
expect(screen.getByText(/工具调用/)).toBeInTheDocument();
|
||||
expect(screen.getByText('list_directory')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('工具成功且有结果时渲染 ToolResultBlock', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
content: '回复',
|
||||
toolCalls: [makeToolCall({ status: 'success', result: { ok: true } })],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/read_file 结果/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('工具失败时不渲染结果块', () => {
|
||||
const { container } = render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
content: '回复',
|
||||
toolCalls: [makeToolCall({ status: 'error', error: 'boom' })],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent).toContain('boom');
|
||||
expect(screen.queryByText(/read_file 结果/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssistantMessage — 流式状态与时间戳', () => {
|
||||
it('流式时隐藏时间戳', () => {
|
||||
const { container } = render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: 'x' })}
|
||||
isStreaming
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent).not.toMatch(/\d{4}-\d{2}-\d{2}/);
|
||||
});
|
||||
|
||||
it('非流式时显示格式化时间戳', () => {
|
||||
const { container } = render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
content: 'x',
|
||||
timestamp: 1730000000000,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent).toMatch(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
it('流式且有内容时使用纯文本 pre 并在其后渲染打字光标', () => {
|
||||
const { container } = render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '正在输出' })}
|
||||
isStreaming
|
||||
/>,
|
||||
);
|
||||
// 流式路径:内容渲染为 prose-streaming pre
|
||||
const pre = container.querySelector('pre.prose-streaming');
|
||||
expect(pre).not.toBeNull();
|
||||
expect(pre?.textContent).toBe('正在输出');
|
||||
// 打字光标:紧随 pre 的空 span
|
||||
expect(pre?.nextElementSibling?.tagName).toBe('SPAN');
|
||||
});
|
||||
|
||||
it('流式但无内容时显示"正在生成回复..."', () => {
|
||||
useAgentStore.setState({ agentStatus: 'idle' });
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '' })}
|
||||
isStreaming
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('正在生成回复...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssistantMessage — 右键菜单', () => {
|
||||
it('右键弹出菜单,包含复制与引用回复', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '可复制内容' })}
|
||||
/>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByText('可复制内容'));
|
||||
expect(screen.getByText('复制')).toBeInTheDocument();
|
||||
expect(screen.getByText('引用回复')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('assistant 消息右键菜单包含"重新生成"', () => {
|
||||
render(
|
||||
<AssistantMessage message={makeMessage({ id: 'm1', role: 'assistant', content: '内容' })} />,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByText('内容'));
|
||||
expect(screen.getByText('重新生成')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* StreamingIndicator 组件测试
|
||||
*
|
||||
* 覆盖:非流式不渲染、流式且无消息渲染、流式最后一条 user 消息渲染、
|
||||
* 流式最后一条 assistant 消息不渲染。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { StreamingIndicator } from '../StreamingIndicator';
|
||||
import { useAgentStore, type ChatMessage } from '@renderer/stores/agent-store';
|
||||
|
||||
const agentInitial = useAgentStore.getState();
|
||||
|
||||
function makeMsg(overrides: Partial<ChatMessage> & { id: string }): ChatMessage {
|
||||
return { role: 'user', content: '', timestamp: 1730000000000, ...overrides };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAgentStore.setState(agentInitial, true);
|
||||
});
|
||||
|
||||
describe('StreamingIndicator — 显示/隐藏条件', () => {
|
||||
it('非流式时不渲染', () => {
|
||||
useAgentStore.setState({ isStreaming: false, messages: [] });
|
||||
render(<StreamingIndicator />);
|
||||
expect(screen.queryByText('正在连接 AI...')).toBeNull();
|
||||
});
|
||||
|
||||
it('流式且无消息时渲染指示器', () => {
|
||||
useAgentStore.setState({ isStreaming: true, messages: [] });
|
||||
render(<StreamingIndicator />);
|
||||
expect(screen.getByText('正在连接 AI...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('流式且最后一条为 user 消息时渲染指示器', () => {
|
||||
useAgentStore.setState({
|
||||
isStreaming: true,
|
||||
messages: [makeMsg({ id: 'm1', role: 'user', content: 'q' })],
|
||||
});
|
||||
render(<StreamingIndicator />);
|
||||
expect(screen.getByText('正在连接 AI...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('流式且最后一条为 assistant 消息时不渲染(内容已在卡片内展示)', () => {
|
||||
useAgentStore.setState({
|
||||
isStreaming: true,
|
||||
messages: [makeMsg({ id: 'm2', role: 'assistant', content: '回答' })],
|
||||
});
|
||||
render(<StreamingIndicator />);
|
||||
expect(screen.queryByText('正在连接 AI...')).toBeNull();
|
||||
});
|
||||
|
||||
it('流式但最后一条为 system 消息时渲染(非 assistant)', () => {
|
||||
useAgentStore.setState({
|
||||
isStreaming: true,
|
||||
messages: [makeMsg({ id: 'm3', role: 'system', content: '通知' })],
|
||||
});
|
||||
render(<StreamingIndicator />);
|
||||
expect(screen.getByText('正在连接 AI...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ThoughtBlock 组件测试
|
||||
*
|
||||
* 覆盖:默认折叠、defaultExpanded 展开、点击切换、空内容不渲染、
|
||||
* 展开状态随 defaultExpanded 变化同步。
|
||||
*
|
||||
* 注:MUI Collapse 折叠时内容仍在 DOM 中(height 0),状态判定用
|
||||
* chevron 图标类名(chevron-right=折叠 / chevron-down=展开)。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThoughtBlock } from '../ThoughtBlock';
|
||||
|
||||
describe('ThoughtBlock — 折叠/展开', () => {
|
||||
it('默认折叠:显示折叠指示图标,无展开指示图标', () => {
|
||||
const { container } = render(<ThoughtBlock content="推理内容" />);
|
||||
expect(screen.getByText('思考过程')).toBeInTheDocument();
|
||||
expect(container.querySelector('.lucide-chevron-right')).not.toBeNull();
|
||||
expect(container.querySelector('.lucide-chevron-down')).toBeNull();
|
||||
});
|
||||
|
||||
it('defaultExpanded=true 时展开并渲染内容', () => {
|
||||
const { container } = render(<ThoughtBlock content="推理内容" defaultExpanded />);
|
||||
expect(screen.getByText('推理内容')).toBeInTheDocument();
|
||||
expect(container.querySelector('.lucide-chevron-down')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('点击切换展开/折叠', () => {
|
||||
const { container } = render(<ThoughtBlock content="推理内容" />);
|
||||
fireEvent.click(screen.getByText('思考过程'));
|
||||
expect(container.querySelector('.lucide-chevron-down')).not.toBeNull();
|
||||
expect(screen.getByText('推理内容')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('思考过程'));
|
||||
expect(container.querySelector('.lucide-chevron-right')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('内容变化时 defaultExpanded 同步到展开状态', () => {
|
||||
const { rerender } = render(<ThoughtBlock content="A" defaultExpanded={false} />);
|
||||
rerender(<ThoughtBlock content="B" defaultExpanded />);
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(document.querySelector('.lucide-chevron-down')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('空内容时不渲染任何节点', () => {
|
||||
const { container } = render(<ThoughtBlock content="" />);
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ToolCallCard 组件测试(v0.7.4 引入 jsdom 组件测试)
|
||||
*
|
||||
* 覆盖:五状态渲染(pending/executing/success/error/blocked)、
|
||||
* 工具名/参数/耗时/错误信息的条件渲染。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ToolCallCard } from '../ToolCallCard';
|
||||
|
||||
function makeToolCall(overrides: Partial<Parameters<typeof ToolCallCard>[0]['toolCall']> = {}) {
|
||||
return {
|
||||
id: 'tc_1',
|
||||
name: 'read_file',
|
||||
args: {},
|
||||
status: 'success' as const,
|
||||
durationMs: 120,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ToolCallCard — 状态渲染', () => {
|
||||
it.each([
|
||||
['pending', '等待中'],
|
||||
['executing', '执行中'],
|
||||
['success', '成功'],
|
||||
['error', '失败'],
|
||||
['blocked', '已阻止'],
|
||||
] as const)('%s 状态显示对应文案', (status, label) => {
|
||||
const { container } = render(<ToolCallCard toolCall={makeToolCall({ status })} />);
|
||||
// Chip 的 label 可能在 DOM 中拆分,用 textContent 匹配
|
||||
expect(container.textContent).toContain(label);
|
||||
});
|
||||
|
||||
it('显示工具名', () => {
|
||||
render(<ToolCallCard toolCall={makeToolCall({ name: 'list_directory' })} />);
|
||||
expect(screen.getByText('list_directory')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('显示耗时(formatDuration 格式化)', () => {
|
||||
render(<ToolCallCard toolCall={makeToolCall({ durationMs: 1500 })} />);
|
||||
expect(screen.getByText('1.5s')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无耗时字段时不渲染耗时', () => {
|
||||
const { container } = render(
|
||||
<ToolCallCard toolCall={makeToolCall({ durationMs: undefined })} />,
|
||||
);
|
||||
expect(container.textContent).not.toMatch(/s$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToolCallCard — 参数与错误', () => {
|
||||
it('有参数时渲染 JSON 格式参数', () => {
|
||||
const args = { file_path: 'a.ts', offset: 10 };
|
||||
render(<ToolCallCard toolCall={makeToolCall({ args })} />);
|
||||
expect(screen.getByText(/"file_path"/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/"a\.ts"/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无参数时不渲染参数区', () => {
|
||||
const { container } = render(<ToolCallCard toolCall={makeToolCall({ args: {} })} />);
|
||||
expect(container.querySelector('pre')).toBeNull();
|
||||
});
|
||||
|
||||
it('error 状态且有错误信息时渲染错误文本', () => {
|
||||
render(
|
||||
<ToolCallCard toolCall={makeToolCall({ status: 'error', error: 'Permission denied' })} />,
|
||||
);
|
||||
expect(screen.getByText('Permission denied')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('error 状态但无错误信息时不渲染错误区', () => {
|
||||
const { container } = render(
|
||||
<ToolCallCard toolCall={makeToolCall({ status: 'error', error: undefined })} />,
|
||||
);
|
||||
expect(container.textContent).not.toContain('undefined');
|
||||
});
|
||||
|
||||
it('非 error 状态即使有 error 字段也不渲染', () => {
|
||||
const { container } = render(
|
||||
<ToolCallCard toolCall={makeToolCall({ status: 'success', error: 'should not show' })} />,
|
||||
);
|
||||
expect(container.textContent).not.toContain('should not show');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ToolResultBlock 组件测试
|
||||
*
|
||||
* 覆盖:结果渲染、dataUrl 剥离(toDisplayResult 摘要 + _displayNote)、
|
||||
* 非 success 状态不渲染、null result 不渲染、耗时显示、字符串结果直接渲染。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ToolResultBlock } from '../ToolResultBlock';
|
||||
import type { ToolCallInfo } from '@renderer/stores/agent-store';
|
||||
|
||||
function makeToolCall(overrides: Partial<ToolCallInfo> = {}): ToolCallInfo {
|
||||
return {
|
||||
id: 'tc_1',
|
||||
name: 'view_image',
|
||||
args: {},
|
||||
status: 'success',
|
||||
result: { ok: true },
|
||||
durationMs: 200,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ToolResultBlock — 结果渲染', () => {
|
||||
it('渲染工具名与结果标题', () => {
|
||||
render(<ToolResultBlock toolCall={makeToolCall({ name: 'read_file' })} />);
|
||||
expect(screen.getByText(/read_file 结果/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染字符串结果', () => {
|
||||
render(<ToolResultBlock toolCall={makeToolCall({ result: '文件内容' })} />);
|
||||
expect(screen.getByText('文件内容')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染对象结果(JSON 格式化)', () => {
|
||||
const { container } = render(
|
||||
<ToolResultBlock toolCall={makeToolCall({ result: { files: ['a.txt'] } })} />,
|
||||
);
|
||||
expect(container.querySelector('pre')?.textContent).toContain('a.txt');
|
||||
});
|
||||
|
||||
it('渲染耗时(formatDuration)', () => {
|
||||
render(<ToolResultBlock toolCall={makeToolCall({ durationMs: 1500 })} />);
|
||||
expect(screen.getByText('1.5s')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无耗时时不渲染耗时', () => {
|
||||
const { container } = render(
|
||||
<ToolResultBlock toolCall={makeToolCall({ durationMs: undefined })} />,
|
||||
);
|
||||
expect(container.textContent).not.toMatch(/s$/);
|
||||
});
|
||||
|
||||
it('非 success 状态不渲染', () => {
|
||||
const { container } = render(<ToolResultBlock toolCall={makeToolCall({ status: 'error' })} />);
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('result 为 null 时不渲染', () => {
|
||||
const { container } = render(<ToolResultBlock toolCall={makeToolCall({ result: null })} />);
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToolResultBlock — dataUrl 剥离', () => {
|
||||
it('含 dataUrl 字段时剥离并显示 _displayNote', () => {
|
||||
const result = {
|
||||
dataUrl: 'data:image/png;base64,' + 'A'.repeat(5000),
|
||||
path: '/tmp/screenshot.png',
|
||||
size: 1234,
|
||||
};
|
||||
const { container } = render(<ToolResultBlock toolCall={makeToolCall({ result })} />);
|
||||
const text = container.querySelector('pre')?.textContent ?? '';
|
||||
expect(text).not.toContain('data:image/png;base64');
|
||||
expect(text).toContain('[image base64 omitted for display — visible to LLM only]');
|
||||
expect(text).toContain('/tmp/screenshot.png');
|
||||
});
|
||||
|
||||
it('普通大字符串被截断显示(防御裁剪)', () => {
|
||||
const result = { content: 'x'.repeat(20000) };
|
||||
const { container } = render(<ToolResultBlock toolCall={makeToolCall({ result })} />);
|
||||
const text = container.querySelector('pre')?.textContent ?? '';
|
||||
expect(text).toContain('[truncated for display]');
|
||||
});
|
||||
|
||||
it('store 原始 result 不被修改(纯函数不变异入参)', () => {
|
||||
const result = {
|
||||
dataUrl: 'data:image/png;base64,QQ',
|
||||
path: '/a.png',
|
||||
};
|
||||
const originalDataUrl = result.dataUrl;
|
||||
render(<ToolResultBlock toolCall={makeToolCall({ result })} />);
|
||||
expect(result.dataUrl).toBe(originalDataUrl);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* UserMessage 组件测试(v0.7.4 引入 jsdom 组件测试)
|
||||
*
|
||||
* 覆盖:内容渲染、附件预览(图片/文本/其他)、双击编辑、
|
||||
* 仅保存落库(P3-10:IPC 成功后更新 store)、保存并重发、取消、Escape 退出。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { UserMessage } from '../UserMessage';
|
||||
import type { ChatMessage, AttachmentInfo } from '@renderer/stores/agent-store';
|
||||
|
||||
function makeMessage(overrides: Partial<ChatMessage> = {}): ChatMessage {
|
||||
return {
|
||||
id: 'm1',
|
||||
role: 'user',
|
||||
content: '帮我看看这段代码',
|
||||
timestamp: 1700000000000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAttachment(
|
||||
type: AttachmentInfo['type'],
|
||||
name: string,
|
||||
extra: Partial<AttachmentInfo> = {},
|
||||
): AttachmentInfo {
|
||||
return {
|
||||
id: `att_${name}`,
|
||||
name,
|
||||
type,
|
||||
size: 1024,
|
||||
...extra,
|
||||
} as AttachmentInfo;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// 重置 agent-store 到最小状态(含 currentSessionId 供 P3-10 落库)
|
||||
useAgentStore.setState({
|
||||
currentSessionId: 's_test',
|
||||
messages: [],
|
||||
isStreaming: false,
|
||||
configLoaded: true,
|
||||
toolsReady: true,
|
||||
agentStatus: 'idle',
|
||||
currentIteration: 0,
|
||||
tokenUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
lastInputTokens: 0,
|
||||
lastCompressedSaved: 0,
|
||||
},
|
||||
traceSteps: [],
|
||||
currentRunId: null,
|
||||
sessionRunStates: {},
|
||||
modelVisionCaps: null,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe('UserMessage — 内容渲染', () => {
|
||||
it('渲染用户消息文本', () => {
|
||||
render(<UserMessage message={makeMessage({ content: '你好,世界' })} />);
|
||||
expect(screen.getByText('你好,世界')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染用户头像图标', () => {
|
||||
const { container } = render(<UserMessage message={makeMessage()} />);
|
||||
expect(container.querySelector('svg')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('空内容时只显示时间戳', () => {
|
||||
const { container } = render(<UserMessage message={makeMessage({ content: '' })} />);
|
||||
expect(container.textContent).not.toContain('帮我看看');
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserMessage — 附件预览', () => {
|
||||
it('图片附件渲染缩略图', () => {
|
||||
const attachments = [
|
||||
makeAttachment('image', 'photo.png', { preview: 'data:image/png;base64,AAA' }),
|
||||
];
|
||||
render(<UserMessage message={makeMessage({ attachments })} />);
|
||||
const img = document.querySelector('img');
|
||||
expect(img).not.toBeNull();
|
||||
expect(img?.getAttribute('src')).toContain('data:image/png');
|
||||
});
|
||||
|
||||
it('文本附件渲染文件名与大小', () => {
|
||||
const attachments = [makeAttachment('text', 'readme.md', { textContent: '# 标题' })];
|
||||
render(<UserMessage message={makeMessage({ attachments })} />);
|
||||
expect(screen.getByText('readme.md')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('其他文件渲染通用卡片', () => {
|
||||
const attachments = [makeAttachment('other', 'archive.zip')];
|
||||
render(<UserMessage message={makeMessage({ attachments })} />);
|
||||
expect(screen.getByText('archive.zip')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserMessage — 编辑与保存(P3-10)', () => {
|
||||
it('双击进入编辑态并显示按钮', () => {
|
||||
render(<UserMessage message={makeMessage()} />);
|
||||
fireEvent.doubleClick(screen.getByText('帮我看看这段代码'));
|
||||
expect(screen.getByText('仅保存')).toBeInTheDocument();
|
||||
expect(screen.getByText('保存并重发')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('仅保存:IPC 落库成功后更新 store 并退出编辑', async () => {
|
||||
const updateMessageContent = vi.fn().mockResolvedValue({ success: true });
|
||||
(
|
||||
window.metona as unknown as {
|
||||
sessions: { updateMessageContent: typeof updateMessageContent };
|
||||
}
|
||||
).sessions.updateMessageContent = updateMessageContent;
|
||||
// store 预置该消息(updateMessage 才能命中)
|
||||
useAgentStore.setState({ messages: [makeMessage()] } as never);
|
||||
|
||||
render(<UserMessage message={makeMessage()} />);
|
||||
fireEvent.doubleClick(screen.getByText('帮我看看这段代码'));
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '修改后的内容' } });
|
||||
fireEvent.click(screen.getByText('仅保存'));
|
||||
|
||||
// 等待异步 IPC + store 更新
|
||||
await vi.waitFor(() => {
|
||||
expect(updateMessageContent).toHaveBeenCalledWith('s_test', 'm1', '修改后的内容');
|
||||
});
|
||||
// store 已更新
|
||||
expect(useAgentStore.getState().messages.find((m) => m.id === 'm1')?.content).toBe(
|
||||
'修改后的内容',
|
||||
);
|
||||
});
|
||||
|
||||
it('仅保存:内容未变时直接退出编辑不调 IPC', async () => {
|
||||
const updateMessageContent = vi.fn();
|
||||
(
|
||||
window.metona as unknown as {
|
||||
sessions: { updateMessageContent: typeof updateMessageContent };
|
||||
}
|
||||
).sessions.updateMessageContent = updateMessageContent;
|
||||
|
||||
render(<UserMessage message={makeMessage()} />);
|
||||
fireEvent.doubleClick(screen.getByText('帮我看看这段代码'));
|
||||
fireEvent.click(screen.getByText('仅保存'));
|
||||
expect(updateMessageContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('仅保存:IPC 失败保留编辑态并显示错误 toast', async () => {
|
||||
const updateMessageContent = vi.fn().mockResolvedValue({ success: false, error: 'db error' });
|
||||
(
|
||||
window.metona as unknown as {
|
||||
sessions: { updateMessageContent: typeof updateMessageContent };
|
||||
}
|
||||
).sessions.updateMessageContent = updateMessageContent;
|
||||
|
||||
render(<UserMessage message={makeMessage()} />);
|
||||
fireEvent.doubleClick(screen.getByText('帮我看看这段代码'));
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '新内容' } });
|
||||
fireEvent.click(screen.getByText('仅保存'));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// 编辑态保留(保存按钮仍在)
|
||||
expect(screen.getByText('仅保存')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('保存并重发调用 editAndResend', () => {
|
||||
const editAndResend = vi.fn();
|
||||
// 注入 store action
|
||||
useAgentStore.setState({ editAndResend: editAndResend } as never);
|
||||
|
||||
render(<UserMessage message={makeMessage()} />);
|
||||
fireEvent.doubleClick(screen.getByText('帮我看看这段代码'));
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '重发内容' } });
|
||||
fireEvent.click(screen.getByText('保存并重发'));
|
||||
expect(editAndResend).toHaveBeenCalledWith('m1', '重发内容');
|
||||
});
|
||||
|
||||
it('Escape 取消编辑恢复原文', () => {
|
||||
render(<UserMessage message={makeMessage()} />);
|
||||
fireEvent.doubleClick(screen.getByText('帮我看看这段代码'));
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '改动' } });
|
||||
fireEvent.keyDown(textarea, { key: 'Escape' });
|
||||
expect(screen.queryByText('仅保存')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user