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:
@@ -255,3 +255,304 @@ describe.skipIf(!dbAvailable)('敏感 key — 全局层密文落盘', () => {
|
||||
expect(rawFile).toContain('metona-enc:v1:');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('isUnconfiguredGlobalKey — 边界值矩阵', () => {
|
||||
it.each([
|
||||
// [key, value, expected]
|
||||
['llm.apiKey', '', true],
|
||||
['llm.apiKey', null, true],
|
||||
['llm.apiKey', undefined, true],
|
||||
['llm.apiKey', 0, false], // 数字 0 非空串非默认值
|
||||
['llm.model', '', true],
|
||||
// 默认值 → 未配置
|
||||
['agent.maxIterations', 20, true],
|
||||
['agent.totalTimeoutMs', 600000, true],
|
||||
['agent.enableThinking', true, true],
|
||||
['llm.maxTokens', 63488, true],
|
||||
['onboarding.completed', false, true],
|
||||
['ui.theme', 'auto', true],
|
||||
// 非默认值 → 已配置
|
||||
['agent.maxIterations', 21, false],
|
||||
['agent.totalTimeoutMs', 1, false],
|
||||
['agent.enableThinking', false, false], // 用户主动关闭
|
||||
['llm.maxTokens', 1000, false],
|
||||
['onboarding.completed', true, false], // 用户已完成引导
|
||||
['ui.theme', 'dark', false],
|
||||
// 非全局 key 恒未配置判定(不被 SEED_DEFAULTS 覆盖)
|
||||
['searxng.enabled', 'anything', false],
|
||||
['tools.write_file.enabled', false, false],
|
||||
])('isUnconfiguredGlobalKey(%s, %j) → %j', (key, value, expected) => {
|
||||
expect(isUnconfiguredGlobalKey(key, value)).toBe(expected);
|
||||
});
|
||||
|
||||
it('isGlobalKey 边界:完整前缀匹配而非子串(deepseek.contextWindow 是全局 key)', () => {
|
||||
expect(isGlobalKey('deepseek.contextWindow')).toBe(true);
|
||||
expect(isGlobalKey('deepseek.apiKey')).toBe(true);
|
||||
expect(isGlobalKey('memory.consolidationEnabled')).toBe(true);
|
||||
expect(isGlobalKey('memory.custom')).toBe(true);
|
||||
// 非全局前缀
|
||||
expect(isGlobalKey('workspace.path')).toBe(false);
|
||||
expect(isGlobalKey('searxng.apiKey')).toBe(false);
|
||||
expect(isGlobalKey('mcp.autoReconnect')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('ConfigService.setBatch — 批量写入', () => {
|
||||
it('setBatch 多 key 一次写入 DB 与全局层(全局 key 双写)', () => {
|
||||
configService.setBatch([
|
||||
{ key: 'llm.model', value: 'mimo-v3' },
|
||||
{ key: 'agent.maxIterations', value: 40 },
|
||||
{ key: 'ui.theme', value: 'light' },
|
||||
]);
|
||||
expect(configService.get<string>('llm.model')).toBe('mimo-v3');
|
||||
expect(configService.get<number>('agent.maxIterations')).toBe(40);
|
||||
expect(configService.get<string>('ui.theme')).toBe('light');
|
||||
// 全局层同步
|
||||
expect(globalConfig.get<string>('llm.model')).toBe('mimo-v3');
|
||||
expect(globalConfig.get<number>('agent.maxIterations')).toBe(40);
|
||||
});
|
||||
|
||||
it('setBatch 敏感 key 加密落盘、读取解密', () => {
|
||||
configService.setBatch([{ key: 'llm.apiKey', value: 'sk-batch-secret' }]);
|
||||
const raw = db!.prepare("SELECT value FROM app_config WHERE key = 'llm.apiKey'").get() as {
|
||||
value: string;
|
||||
};
|
||||
expect(raw.value).toContain('metona-enc:v1:');
|
||||
expect(configService.get<string>('llm.apiKey')).toBe('sk-batch-secret');
|
||||
expect(globalConfig.get<string>('llm.apiKey')).toBe('sk-batch-secret');
|
||||
});
|
||||
|
||||
it('setBatch 保留已有 category(与 set 语义一致)', () => {
|
||||
db!
|
||||
.prepare(
|
||||
"INSERT INTO app_config (key, value, category) VALUES ('ui.theme', '\"blue\"', 'ui')",
|
||||
)
|
||||
.run();
|
||||
configService.setBatch([{ key: 'ui.theme', value: 'dark' }]);
|
||||
const row = db!.prepare("SELECT category FROM app_config WHERE key = 'ui.theme'").get() as {
|
||||
category: string;
|
||||
};
|
||||
expect(row.category).toBe('ui');
|
||||
});
|
||||
|
||||
it('setBatch 非全局 key 不写全局层', () => {
|
||||
configService.setBatch([{ key: 'workspace.localOnly', value: 'x' }]);
|
||||
expect(globalConfig.get<string>('workspace.localOnly')).toBeNull();
|
||||
});
|
||||
|
||||
it('setBatch 空数组不抛错且无副作用', () => {
|
||||
expect(() => configService.setBatch([])).not.toThrow();
|
||||
expect(globalConfig.getAll()).toEqual({});
|
||||
});
|
||||
|
||||
it('setBatch 部分失败(JSON.stringify 抛错)→ DB 事务整体回滚,不产生部分写入', () => {
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular; // 无法 JSON 序列化
|
||||
expect(() =>
|
||||
configService.setBatch([
|
||||
{ key: 'ui.theme', value: 'dark' },
|
||||
{ key: 'agent.maxIterations', value: circular },
|
||||
]),
|
||||
).toThrow();
|
||||
// 事务回滚:第一个 key 也不应残留
|
||||
const row = db!.prepare("SELECT value FROM app_config WHERE key = 'ui.theme'").get();
|
||||
expect(row).toBeUndefined();
|
||||
// 全局层不被触碰(事务异常中断在 DB 阶段)
|
||||
expect(globalConfig.get<string>('ui.theme')).toBeNull();
|
||||
});
|
||||
|
||||
it('setBatch 更新 updated_at 时间戳', () => {
|
||||
configService.setBatch([{ key: 'llm.model', value: 'a' }]);
|
||||
const row = db!.prepare("SELECT updated_at FROM app_config WHERE key = 'llm.model'").get() as {
|
||||
updated_at: number;
|
||||
};
|
||||
expect(row.updated_at).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('ConfigService — getByCategory / delete / 值类型', () => {
|
||||
it('getByCategory 只返回该分类下的 key', () => {
|
||||
// 直接按真实库的 category 灌入(set 默认 category=general,需先有分类)
|
||||
configService.set('ui.theme', 'dark');
|
||||
configService.set('llm.provider', 'deepseek');
|
||||
configService.set('ui.fontSize', 14);
|
||||
db!
|
||||
.prepare("UPDATE app_config SET category = 'ui' WHERE key IN ('ui.theme', 'ui.fontSize')")
|
||||
.run();
|
||||
|
||||
const ui = configService.getByCategory('ui');
|
||||
expect(Object.keys(ui).sort()).toEqual(['ui.fontSize', 'ui.theme']);
|
||||
expect(ui['ui.theme']).toBe('dark');
|
||||
expect(ui['ui.fontSize']).toBe(14);
|
||||
expect('llm.provider' in ui).toBe(false);
|
||||
});
|
||||
|
||||
it('getByCategory 不存在的分类返回空对象', () => {
|
||||
expect(configService.getByCategory('nonexistent')).toEqual({});
|
||||
});
|
||||
|
||||
it('getByCategory 返回的敏感 key 值为密文(getByCategory 未做解密 —— 实际行为契约)', () => {
|
||||
db!
|
||||
.prepare("INSERT INTO app_config (key, value, category) VALUES ('llm.apiKey', '\"\"', 'llm')")
|
||||
.run();
|
||||
configService.set('llm.apiKey', 'sk-cat-secret');
|
||||
const llm = configService.getByCategory('llm');
|
||||
expect(llm['llm.apiKey']).toContain('metona-enc:v1:');
|
||||
expect(llm['llm.apiKey']).not.toContain('sk-cat-secret');
|
||||
// 对照:get() 仍解密
|
||||
expect(configService.get<string>('llm.apiKey')).toBe('sk-cat-secret');
|
||||
});
|
||||
|
||||
it('getByCategory 容忍损坏 JSON(原样字符串返回)', () => {
|
||||
db!
|
||||
.prepare("INSERT INTO app_config (key, value, category) VALUES ('ui.raw', '{bad-json', 'ui')")
|
||||
.run();
|
||||
const ui = configService.getByCategory('ui');
|
||||
expect(ui['ui.raw']).toBe('{bad-json');
|
||||
});
|
||||
|
||||
it('delete 非全局 key:删除后 get 返回 null(无全局回退)', () => {
|
||||
configService.set('workspace.localFlag', 'x');
|
||||
expect(configService.delete('workspace.localFlag')).toBe(true);
|
||||
expect(configService.get('workspace.localFlag')).toBeNull();
|
||||
expect(configService.delete('workspace.localFlag')).toBe(false);
|
||||
expect(configService.delete('never-existed')).toBe(false);
|
||||
});
|
||||
|
||||
it('delete 全局 key:DB 行删除但 get 经全局层回退仍可读(双写不联动删除)', () => {
|
||||
configService.set('llm.model', 'mimo');
|
||||
expect(configService.delete('llm.model')).toBe(true);
|
||||
const row = db!.prepare("SELECT value FROM app_config WHERE key = 'llm.model'").get();
|
||||
expect(row).toBeUndefined();
|
||||
// 全局层保留 → get 回退命中
|
||||
expect(globalConfig.get<string>('llm.model')).toBe('mimo');
|
||||
expect(configService.get<string>('llm.model')).toBe('mimo');
|
||||
});
|
||||
|
||||
it('set/get 支持对象与数组值(JSON 序列化语义)', () => {
|
||||
configService.set('ui.pinnedSessions', ['a', 'b']);
|
||||
configService.set('agent.metadata', { enabled: true, max: 3 });
|
||||
expect(configService.get<Array<string>>('ui.pinnedSessions')).toEqual(['a', 'b']);
|
||||
expect(configService.get<{ enabled: boolean; max: number }>('agent.metadata')).toEqual({
|
||||
enabled: true,
|
||||
max: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('set 布尔与 null 值可回读', () => {
|
||||
configService.set('ui.flag', false);
|
||||
configService.set('ollama.numCtx', null);
|
||||
expect(configService.get<boolean>('ui.flag')).toBe(false);
|
||||
expect(configService.get<null>('ollama.numCtx')).toBeNull();
|
||||
});
|
||||
|
||||
it('历史明文敏感 key 平滑兼容:DB 存明文时 get 原样返回', () => {
|
||||
// 模拟旧版本未加密落盘的 apiKey
|
||||
db!
|
||||
.prepare(
|
||||
"INSERT INTO app_config (key, value, category) VALUES ('llm.apiKey', '\"sk-legacy-plain\"', 'llm')",
|
||||
)
|
||||
.run();
|
||||
expect(configService.get<string>('llm.apiKey')).toBe('sk-legacy-plain');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('migrateFromWorkspaceDB — 边界矩阵', () => {
|
||||
it('false 值且默认值非 false → 迁移(用户主动关闭)', () => {
|
||||
const migrated = globalConfig.migrateFromWorkspaceDB({ 'agent.enableThinking': false });
|
||||
expect(migrated).toBe(1);
|
||||
expect(globalConfig.get<boolean>('agent.enableThinking')).toBe(false);
|
||||
});
|
||||
|
||||
it('false 值且默认值即 false → 跳过(seedDefaults 灌入)', () => {
|
||||
expect(globalConfig.migrateFromWorkspaceDB({ 'onboarding.completed': false })).toBe(0);
|
||||
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.multimodalEnabled': false })).toBe(0);
|
||||
});
|
||||
|
||||
it('true 值且默认值 false → 迁移(用户主动开启)', () => {
|
||||
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.multimodalEnabled': true })).toBe(1);
|
||||
expect(globalConfig.get<boolean>('llm.multimodalEnabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('true 值且默认值 true → 跳过', () => {
|
||||
expect(globalConfig.migrateFromWorkspaceDB({ 'security.promptInjectionDefense': true })).toBe(
|
||||
0,
|
||||
);
|
||||
expect(globalConfig.migrateFromWorkspaceDB({ 'agent.enableThinking': true })).toBe(0);
|
||||
});
|
||||
|
||||
it('0 与空对象等非空非默认值 → 迁移', () => {
|
||||
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.temperature': 0 })).toBe(0); // 默认 0 → 跳过
|
||||
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.temperature': 0.5 })).toBe(1);
|
||||
expect(globalConfig.migrateFromWorkspaceDB({ 'ui.customFlag': 'on' })).toBe(1);
|
||||
});
|
||||
|
||||
it('敏感 key 迁移:全局 JSON 中为密文(无明文泄漏)', () => {
|
||||
const migrated = globalConfig.migrateFromWorkspaceDB({ 'llm.apiKey': 'sk-mig-secret' });
|
||||
expect(migrated).toBe(1);
|
||||
expect(globalConfig.get<string>('llm.apiKey')).toBe('sk-mig-secret');
|
||||
const rawFile = readFileSync(join(mockState.userDataDir, 'global-config.json'), 'utf-8');
|
||||
expect(rawFile).not.toContain('sk-mig-secret');
|
||||
expect(rawFile).toContain('metona-enc:v1:');
|
||||
});
|
||||
|
||||
it('数值边界:默认值 float 精确相等判定', () => {
|
||||
// deepseek.contextWindow 默认 1000000
|
||||
expect(globalConfig.migrateFromWorkspaceDB({ 'deepseek.contextWindow': 1000000 })).toBe(0);
|
||||
expect(globalConfig.migrateFromWorkspaceDB({ 'deepseek.contextWindow': 64000 })).toBe(1);
|
||||
});
|
||||
|
||||
it('迁移后新 ConfigService 读取(跨空间共享生效)', () => {
|
||||
const migrated = globalConfig.migrateFromWorkspaceDB({ 'llm.provider': 'ollama' });
|
||||
expect(migrated).toBe(1);
|
||||
const db2 = makeDb();
|
||||
const freshConfig = new ConfigService(() => db2);
|
||||
freshConfig.setGlobalConfig(globalConfig);
|
||||
expect(freshConfig.get<string>('llm.provider')).toBe('ollama');
|
||||
db2.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('配置分层 — 空值回退细节', () => {
|
||||
it('DB 值等于 seed 默认值(0)时视为未配置 → 回退全局层真实值', () => {
|
||||
// llm.temperature seed 默认 0 —— DB 写入 0 与默认值不可区分 → 回退全局层
|
||||
db!
|
||||
.prepare(
|
||||
"INSERT INTO app_config (key, value, category) VALUES ('llm.temperature', '0', 'llm')",
|
||||
)
|
||||
.run();
|
||||
globalConfig.set('llm.temperature', 1);
|
||||
expect(configService.get<number>('llm.temperature')).toBe(1);
|
||||
});
|
||||
|
||||
it('DB 值为非默认数字 0(ui.theme 默认是字符串)→ 视为已配置不回退', () => {
|
||||
db!
|
||||
.prepare("INSERT INTO app_config (key, value, category) VALUES ('ui.theme', '0', 'ui')")
|
||||
.run();
|
||||
globalConfig.set('ui.theme', 'dark');
|
||||
expect(configService.get<number>('ui.theme')).toBe(0);
|
||||
});
|
||||
|
||||
it('DB 有非空字符串但全局层为 null → 不覆盖为 null(返回 DB 值)', () => {
|
||||
configService.set('llm.model', 'local-model');
|
||||
// 全局层无该值
|
||||
expect(configService.get<string>('llm.model')).toBe('local-model');
|
||||
});
|
||||
|
||||
it('DB miss 且全局层为 null → get 返回 null', () => {
|
||||
expect(configService.get<string>('llm.provider')).toBeNull();
|
||||
});
|
||||
|
||||
it('getAll 合并时全局层未配置的 key 不进结果', () => {
|
||||
const all = configService.getAll();
|
||||
expect('llm.provider' in all).toBe(false);
|
||||
});
|
||||
|
||||
it('getAll 中 DB 未配置但全局层已配置 → 全局值补齐', () => {
|
||||
globalConfig.set('llm.provider', 'openai');
|
||||
configService.set('ui.theme', 'dark');
|
||||
const all = configService.getAll();
|
||||
expect(all['llm.provider']).toBe('openai');
|
||||
expect(all['ui.theme']).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user