feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s

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:
2026-08-30 19:19:07 +08:00
parent ebe45482b0
commit 99d0c54129
137 changed files with 25190 additions and 1792 deletions
@@ -0,0 +1,257 @@
// @vitest-environment jsdom
/**
* WorkspaceViewer 组件测试
*
* 覆盖:路径显示、核心文件列表(存在/缺失)、文件预览、自动目录、
* 刷新按钮、Agent 完成自动刷新、错误/无数据状态、文件管理器打开。
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { WorkspaceViewer } from '../WorkspaceViewer';
import { useAgentStore } from '@renderer/stores/agent-store';
const agentInitial = useAgentStore.getState();
let infoResult: MetonaWorkspaceInfo | null = null;
function makeFile(overrides: Partial<MetonaWorkspaceFileInfo> = {}): MetonaWorkspaceFileInfo {
return {
name: 'SOUL.md',
path: '/ws/SOUL.md',
exists: true,
size: 1024,
mtime: 1730000000000,
preview: '我是 SOUL',
...overrides,
};
}
function makeDir(overrides: Partial<MetonaWorkspaceDirInfo> = {}): MetonaWorkspaceDirInfo {
return {
name: 'logs',
path: '/ws/logs',
exists: true,
fileCount: 3,
...overrides,
};
}
beforeEach(() => {
useAgentStore.setState(agentInitial, true);
useAgentStore.setState({ agentStatus: 'idle' });
infoResult = null;
(
window.metona.workspace as unknown as {
getInfo: () => Promise<MetonaWorkspaceInfo | null>;
}
).getInfo = vi.fn(async () => infoResult) as unknown as () => Promise<MetonaWorkspaceInfo>;
(
window.metona.app as unknown as {
showItemInFolder: (path: string) => Promise<{ success: boolean }>;
}
).showItemInFolder = vi.fn(async () => ({ success: true }));
});
describe('WorkspaceViewer — 基础渲染', () => {
it('渲染工作空间根路径', async () => {
infoResult = {
path: '/home/user/ws',
files: [],
dirs: [],
};
render(<WorkspaceViewer />);
expect(await screen.findByText('/home/user/ws')).toBeInTheDocument();
});
it('getInfo 返回 null 时渲染"无数据"', async () => {
infoResult = null;
render(<WorkspaceViewer />);
expect(await screen.findByText('无数据')).toBeInTheDocument();
});
it('加载失败时渲染错误 Alert(显示异常消息)', async () => {
(
window.metona.workspace as unknown as {
getInfo: () => Promise<never>;
}
).getInfo = vi.fn(async () => {
throw new Error('磁盘读取失败');
});
render(<WorkspaceViewer />);
expect(await screen.findByText('磁盘读取失败')).toBeInTheDocument();
expect(document.querySelector('[role="alert"]')).toBeInTheDocument();
});
it('渲染刷新按钮并点击重新拉取', async () => {
infoResult = { path: '/ws/a', files: [], dirs: [] };
render(<WorkspaceViewer />);
await screen.findByText('/ws/a');
const refreshBtn = document
.querySelector('.lucide-refresh-cw')
?.closest('button') as HTMLElement;
fireEvent.click(refreshBtn);
await waitFor(() => {
expect(window.metona.workspace.getInfo).toHaveBeenCalledTimes(2);
});
});
});
describe('WorkspaceViewer — 核心文件', () => {
it('渲染核心文件计数与文件列表', async () => {
infoResult = {
path: '/ws',
files: [
makeFile({ name: 'SOUL.md', exists: true }),
makeFile({ name: 'MEMORY.md', exists: false }),
],
dirs: [],
};
render(<WorkspaceViewer />);
expect(await screen.findByText('核心文件(1/2')).toBeInTheDocument();
expect(screen.getByText('SOUL.md')).toBeInTheDocument();
expect(screen.getByText('MEMORY.md')).toBeInTheDocument();
});
it('存在的文件渲染大小与修改时间', async () => {
infoResult = {
path: '/ws',
files: [makeFile({ name: 'SOUL.md', size: 2048, mtime: 1730000000000 })],
dirs: [],
};
render(<WorkspaceViewer />);
// 大小与时间在同一 Typography 中拼接
expect(await screen.findByText(/2\.0 KB · \d{4}-\d{2}-\d{2}/)).toBeInTheDocument();
});
it('缺失文件展开后渲染"文件不存在"', async () => {
infoResult = {
path: '/ws',
files: [makeFile({ name: 'MEMORY.md', exists: false })],
dirs: [],
};
render(<WorkspaceViewer />);
fireEvent.click(await screen.findByText('MEMORY.md'));
expect(await screen.findByText('文件不存在')).toBeInTheDocument();
});
it('存在的文件展开渲染内容预览', async () => {
infoResult = {
path: '/ws',
files: [makeFile({ name: 'SOUL.md', preview: '# 我是 Agent' })],
dirs: [],
};
render(<WorkspaceViewer />);
fireEvent.click(await screen.findByText('SOUL.md'));
expect(await screen.findByText('# 我是 Agent')).toBeInTheDocument();
});
it('存在的空文件展开渲染"(空文件)"', async () => {
infoResult = {
path: '/ws',
files: [makeFile({ name: 'SOUL.md', preview: '' })],
dirs: [],
};
render(<WorkspaceViewer />);
fireEvent.click(await screen.findByText('SOUL.md'));
expect(await screen.findByText('(空文件)')).toBeInTheDocument();
});
});
describe('WorkspaceViewer — 自动目录', () => {
it('渲染自动目录与文件计数', async () => {
infoResult = {
path: '/ws',
files: [],
dirs: [makeDir({ name: 'logs', fileCount: 5 }), makeDir({ name: '.metona', exists: false })],
};
render(<WorkspaceViewer />);
expect(await screen.findByText('自动目录')).toBeInTheDocument();
expect(screen.getByText('logs/')).toBeInTheDocument();
expect(screen.getByText('5 个文件')).toBeInTheDocument();
});
it('缺失目录渲染"不存在"', async () => {
infoResult = {
path: '/ws',
files: [],
dirs: [makeDir({ name: '.metona', exists: false })],
};
render(<WorkspaceViewer />);
expect(await screen.findByText('.metona/')).toBeInTheDocument();
expect(screen.getByText('不存在')).toBeInTheDocument();
});
});
describe('WorkspaceViewer — 打开文件管理器', () => {
it('点击路径旁的文件夹按钮调用 showItemInFolder', async () => {
infoResult = { path: '/ws/root', files: [], dirs: [] };
render(<WorkspaceViewer />);
await screen.findByText('/ws/root');
const btn = document.querySelector('.lucide-folder-open')?.closest('button') as HTMLElement;
fireEvent.click(btn);
await waitFor(() => {
expect(window.metona.app.showItemInFolder).toHaveBeenCalledWith('/ws/root');
});
});
it('点击文件旁的打开按钮调用 showItemInFolder(文件路径)', async () => {
infoResult = {
path: '/ws',
files: [makeFile({ name: 'SOUL.md', path: '/ws/SOUL.md' })],
dirs: [],
};
render(<WorkspaceViewer />);
fireEvent.click(await screen.findByText('SOUL.md'));
const btns = document.querySelectorAll('.lucide-folder-open');
fireEvent.click(btns[btns.length - 1].closest('button') as HTMLElement);
await waitFor(() => {
expect(window.metona.app.showItemInFolder).toHaveBeenCalledWith('/ws/SOUL.md');
});
});
});
describe('WorkspaceViewer — Agent 完成自动刷新', () => {
it('Agent 从 executing 回到 idle 时自动重新加载', async () => {
infoResult = { path: '/ws/a', files: [], dirs: [] };
const { unmount } = render(<WorkspaceViewer />);
await screen.findByText('/ws/a');
const callsAfterMount = (window.metona.workspace.getInfo as ReturnType<typeof vi.fn>).mock.calls
.length;
// 触发状态流转 executing → idle(分步 act 确保 effect 逐次执行)
await act(async () => {
useAgentStore.setState({ agentStatus: 'executing' });
await Promise.resolve();
});
await act(async () => {
useAgentStore.setState({ agentStatus: 'idle' });
await Promise.resolve();
});
await waitFor(() => {
expect(
(window.metona.workspace.getInfo as ReturnType<typeof vi.fn>).mock.calls.length,
).toBeGreaterThan(callsAfterMount);
});
unmount();
});
it('非工作状态流转不触发自动刷新', async () => {
infoResult = { path: '/ws/a', files: [], dirs: [] };
render(<WorkspaceViewer />);
await screen.findByText('/ws/a');
const callsAfterMount = (window.metona.workspace.getInfo as ReturnType<typeof vi.fn>).mock.calls
.length;
await act(async () => {
useAgentStore.setState({ agentStatus: 'error' });
await Promise.resolve();
});
await act(async () => {
useAgentStore.setState({ agentStatus: 'idle' });
await Promise.resolve();
});
await new Promise((r) => setTimeout(r, 50));
expect((window.metona.workspace.getInfo as ReturnType<typeof vi.fn>).mock.calls.length).toBe(
callsAfterMount,
);
});
});