Files
metona-ai-desktop/electron/services/__tests__/config-layering.test.ts
T
thzxx 99d0c54129
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s
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)
2026-08-30 19:19:07 +08:00

559 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 配置分层测试(v0.7.2 覆盖补齐 —— config.service / global-config.service 此前零测试)
*
* 锁定跨工作空间配置层契约(v0.3.17 引入的分层机制的回归防线):
* 1. 读取顺序:工作空间 DB → 全局 JSON(仅全局 key
* 2. 空值回退:DB 被 seedDefaults 灌入空值/默认值时回退全局层真实值
* 3. 双写:set 同步写 DB 与全局 JSON;敏感 key 双侧密文
* 4. getAll 合并语义与 migrateFromWorkspaceDB 幂等迁移
* 5. 敏感 key 加密回环(P0-1
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { readFileSync, rmSync } from 'fs';
import { join } from 'path';
import Database from 'better-sqlite3';
// better-sqlite3 的 ABI 可用性在模块顶层探测(describe.skipIf 在注册期求值)
let dbAvailable = false;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const DatabaseProbe = require('better-sqlite3');
new DatabaseProbe(':memory:').close();
dbAvailable = true;
} catch {
dbAvailable = false;
}
// 可控 safeStorage(可逆 fakeenc: 前缀)+ 可定位的 userData 目录。
// 注意:mock 工厂内不允许 require(仓库 lint 禁令),os/path/fs 经参数注入。
const mockState = vi.hoisted(() => ({
encryptionAvailable: true,
userDataDir: '',
}));
vi.mock('electron', async () => {
const { mkdtempSync } = await import('fs');
const { join } = await import('path');
const { tmpdir } = await import('os');
mockState.userDataDir = mkdtempSync(join(tmpdir(), 'metona-gcfg-'));
return {
app: { getPath: () => mockState.userDataDir },
safeStorage: {
isEncryptionAvailable: () => mockState.encryptionAvailable,
encryptString: (value: string) => Buffer.from(`enc:${value}`, 'utf-8'),
decryptString: (buffer: Buffer) => {
const raw = buffer.toString('utf-8');
if (!raw.startsWith('enc:')) throw new Error('decryption failed');
return raw.slice(4);
},
},
};
});
vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { ConfigService } from '../config.service';
import {
GlobalConfigService,
isGlobalKey,
isUnconfiguredGlobalKey,
} from '../global-config.service';
// better-sqlite3 的默认导出同时是构造函数与命名空间 —— 实例类型经
// InstanceType<typeof Database> 获取,避免与命名空间冲突
type SqliteDatabase = InstanceType<typeof Database>;
let db: SqliteDatabase | undefined;
let globalConfig: GlobalConfigService;
let configService: ConfigService;
let tempRoots: string[] = [];
function makeDb(): SqliteDatabase {
const db = new Database(':memory:');
db.exec(`
CREATE TABLE app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'general',
updated_at INTEGER NOT NULL DEFAULT 0
);
`);
return db;
}
beforeEach(() => {
if (!dbAvailable) return;
// 测试隔离:清除上一个用例落盘的全局 JSONGLOBAL_CONFIG_FILE 在模块导入期
// 固化为单一路径,文件内状态会跨用例泄漏 —— 迁移用例会误判"已有 key"
rmSync(join(mockState.userDataDir, 'global-config.json'), { force: true });
db = makeDb();
globalConfig = new GlobalConfigService();
globalConfig.initialize();
configService = new ConfigService(() => db!);
configService.setGlobalConfig(globalConfig);
});
afterEach(() => {
if (db) db.close();
for (const dir of tempRoots) {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* ignore */
}
}
tempRoots = [];
});
describe.skipIf(!dbAvailable)('isGlobalKey / isUnconfiguredGlobalKey — 全局 key 判定', () => {
it('llm./agent./ui./security./onboarding. 前缀为全局 keytasks/searxng 不属于', () => {
expect(isGlobalKey('llm.provider')).toBe(true);
expect(isGlobalKey('agent.maxIterations')).toBe(true);
expect(isGlobalKey('onboarding.completed')).toBe(true);
expect(isGlobalKey('searxng.enabled')).toBe(false);
expect(isGlobalKey('tools.write_file.enabled')).toBe(false);
});
it('空串/null/默认值视为未配置(触发全局回退);非默认值视为已配置', () => {
expect(isUnconfiguredGlobalKey('llm.apiKey', '')).toBe(true);
expect(isUnconfiguredGlobalKey('llm.apiKey', null)).toBe(true);
expect(isUnconfiguredGlobalKey('agent.maxIterations', 20)).toBe(true); // seedDefaults 默认值
expect(isUnconfiguredGlobalKey('agent.maxIterations', 50)).toBe(false); // 用户主动设置
expect(isUnconfiguredGlobalKey('llm.apiKey', 'sk-real')).toBe(false);
});
});
describe.skipIf(!dbAvailable)('ConfigService — 基本读写与敏感加密', () => {
it('set/get 回环(JSON 序列化语义)', () => {
configService.set('llm.model', 'deepseek-v4-pro');
expect(configService.get<string>('llm.model')).toBe('deepseek-v4-pro');
configService.set('agent.maxIterations', 33);
expect(configService.get<number>('agent.maxIterations')).toBe(33);
});
it('敏感 key 落盘为密文、读取自动解密(P0-1)', () => {
configService.set('llm.apiKey', 'sk-plain-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:'); // DB 内是密文
expect(raw.value).not.toContain('sk-plain-secret');
expect(configService.get<string>('llm.apiKey')).toBe('sk-plain-secret'); // 读取解密
});
it('get 未配置 key 返回 null(无全局回退对象时)', () => {
expect(configService.get<string>('nonexistent.key')).toBeNull();
});
it('set 保留已有 category(不回退默认值)', () => {
db!
.prepare(
"INSERT INTO app_config (key, value, category) VALUES ('ui.theme', '\"dark\"', 'ui')",
)
.run();
configService.set('ui.theme', 'light');
const row = db!.prepare("SELECT category FROM app_config WHERE key = 'ui.theme'").get() as {
category: string;
};
expect(row.category).toBe('ui');
});
});
describe.skipIf(!dbAvailable)('配置分层 — DB 与全局 JSON 回退/双写', () => {
it('DB miss + 全局层有值 → 回退全局层', () => {
globalConfig.set('llm.provider', 'deepseek');
expect(configService.get<string>('llm.provider')).toBe('deepseek');
});
it('DB 有真实值 → 不回退(DB 优先)', () => {
globalConfig.set('llm.provider', 'deepseek');
configService.set('llm.provider', 'ollama');
expect(configService.get<string>('llm.provider')).toBe('ollama');
});
it('DB 有 seedDefaults 空值 + 全局层有真实值 → 回退全局层(新工作空间场景)', () => {
// 模拟新工作空间 DB 被 seedDefaults 灌入空 apiKey
db!
.prepare("INSERT INTO app_config (key, value, category) VALUES ('llm.apiKey', '\"\"', 'llm')")
.run();
globalConfig.set('llm.apiKey', 'sk-global-real');
expect(configService.get<string>('llm.apiKey')).toBe('sk-global-real');
});
it('set 全局 key 双写:DB + 全局 JSON(跨工作空间共享)', () => {
configService.set('llm.model', 'mimo-v2.5');
// DB 有值
expect(configService.get<string>('llm.model')).toBe('mimo-v2.5');
// 全局 JSON 有值(新 ConfigService 实例 + 空 DB 可读到)
const db2 = makeDb();
const freshConfig = new ConfigService(() => db2);
freshConfig.setGlobalConfig(globalConfig);
expect(freshConfig.get<string>('llm.model')).toBe('mimo-v2.5');
db2.close();
});
it('非全局 key 不写全局层(工作空间级数据不跨空间泄漏)', () => {
configService.set('workspace.path', '/tmp/private');
expect(globalConfig.get<string>('workspace.path')).toBeNull();
});
it('getAll 合并:DB 为主,miss 的全局 key 用全局层补齐', () => {
configService.set('ui.theme', 'dark');
globalConfig.set('llm.provider', 'openai');
const all = configService.getAll();
expect(all['ui.theme']).toBe('dark');
expect(all['llm.provider']).toBe('openai');
});
it('getAll 中敏感 key 解密返回明文(配合导出层脱敏)', () => {
configService.set('llm.apiKey', 'sk-export-test');
expect(configService.getAll()['llm.apiKey']).toBe('sk-export-test');
});
});
describe.skipIf(!dbAvailable)('migrateFromWorkspaceDB — 幂等迁移', () => {
it('仅迁移已配置的全局 key;空值/默认值/既有 key 跳过', () => {
// 构造工作空间 DB 配置快照
const workspaceConfig: Record<string, unknown> = {
'llm.provider': 'deepseek', // 已配置 → 迁移
'llm.apiKey': '', // 空值 → 跳过
'agent.maxIterations': 20, // seedDefaults 默认值 → 跳过
'agent.confirmationTimeoutMs': 300000, // 用户设置的非默认值 → 迁移
'searxng.enabled': true, // 非全局 key → 跳过
};
const migrated = globalConfig.migrateFromWorkspaceDB(workspaceConfig);
expect(migrated).toBe(2);
expect(globalConfig.get('llm.provider')).toBe('deepseek');
expect(globalConfig.get('agent.confirmationTimeoutMs')).toBe(300000);
expect(globalConfig.get('llm.apiKey')).toBeNull();
// 幂等:第二次迁移 0 条(既有 key 不覆盖)
expect(globalConfig.migrateFromWorkspaceDB(workspaceConfig)).toBe(0);
});
it('全局层已有真实值时不被迁移覆盖', () => {
globalConfig.set('llm.provider', 'anthropic');
const migrated = globalConfig.migrateFromWorkspaceDB({ 'llm.provider': 'deepseek' });
expect(migrated).toBe(0);
expect(globalConfig.get('llm.provider')).toBe('anthropic');
});
});
describe.skipIf(!dbAvailable)('敏感 key — 全局层密文落盘', () => {
it('全局 JSON 文件中敏感值为密文(无明文泄漏)', () => {
configService.set('llm.apiKey', 'sk-raw-in-global');
// GlobalConfigService flush 后,磁盘上的 global-config.json 不应包含明文
const rawFile = readFileSync(join(mockState.userDataDir, 'global-config.json'), 'utf-8');
expect(rawFile).not.toContain('sk-raw-in-global');
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 全局 keyDB 行删除但 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');
});
});