硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。
P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层
P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}
P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)
Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。
验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
561 lines
23 KiB
TypeScript
561 lines
23 KiB
TypeScript
/**
|
||
* 配置分层测试(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(可逆 fake:enc: 前缀)+ 可定位的 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;
|
||
// 测试隔离:清除上一个用例落盘的全局 JSON(GLOBAL_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. 前缀为全局 key;tasks/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 边界:完整前缀匹配而非子串(v0.8.1: provider 前缀已废除)', () => {
|
||
// v0.8.1: deepseek/agnes/mimo/openai/anthropic/ollama 前缀从全局清单移除
|
||
//(其唯一键 contextWindow/numCtx 已废除,由全局 llm.contextWindow 取代)
|
||
expect(isGlobalKey('deepseek.contextWindow')).toBe(false);
|
||
expect(isGlobalKey('deepseek.apiKey')).toBe(false);
|
||
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 精确相等判定', () => {
|
||
// v0.8.1: deepseek.contextWindow 键已废除(迁移 12)——改用存续键 llm.maxTokens(默认 63488)
|
||
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.maxTokens': 63488 })).toBe(0);
|
||
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.maxTokens': 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');
|
||
});
|
||
});
|