feat: v0.7.2 安全收口 · 断链接线 · 观测补洞 — 230 用例扩充与全量回归
P1 修复面收口: /clear 全链路根治(前端清空联动 DB messages+摘要游标+TRACE 快照, IPC 语义改"操作完成"; 流式中拒绝); web_browser open 补 SSRF 校验(Chromium 旁路关闭, 与 web_fetch/http_request 同源 validateSSRF); MCP 工具结果纳入注入扫描(mcp_* 前缀 按网络来源同级 full 模式, 收敛 resolveScanMode 单点); Trace 落库/入 store 双重瘦身 (tool_result base64/超长字段剥离, metadata 防 MB 级膨胀); 文本附件 512KB 闸门 (file.slice 首段读取+truncated 标志随消息持久化+主进程附件提示感知截断); 单实例锁(requestSingleInstanceLock + second-instance 聚焦已有窗口) P2 安全纵深: ConfirmationHook 多窗口化(确认请求/超时提示改全窗口广播, getAllWindows 空时回退 mainWindow, fail-closed 判定升级双通道); mcp_servers.headers 全链路接线(safeParseHeaders 容错解析+SSE/StreamableHTTP requestInit 注入+IPC 逐项 校验+设置页 JSON 输入, 远程 MCP 鉴权头可用) P3 断链接线: llm:listModels IPC(六家 adapter 动态模型发现首次接线, 配置完整性 前置校验); Ollama pullModel IPC+设置页下载卡片(进度/取消/能力徽标, v0.7.0 死代码 激活); 后台会话运行指示(sessionRunStates 图+Sidebar 状态点, 多会话并发可见); IR 卫生(移除 THINKING_START/END 死枚举, constraints 标注预留) P4 质量与文档: i18n 第二阶段(确认弹框/侧栏/状态栏/AgentMonitor/终止原因出层, 外观设置 zh-CN/en-US 切换, ui.locale 持久化, 渲染时求值规避异步注册); README/D1 文档对齐(http_request 风险等级/用例数/实现状态注记); 版本号 0.7.2 测试: 507 → 737 用例(+230, 11 个新文件)。覆盖补齐: context-builder/consolidator/ orchestrator/workspace.service/session-recorder/config-layering/secure-config/ network-proxy + IPC mcp/tasks/memory/app/data 域 + 渲染层 store 与流事件管线纯函数。 测试驱动修复: workspace.appendMemory 中文分区 \b 词边界失效(JS \b 不含 CJK), 固化条目恒追加文件末尾产生重复分区头 → (?=\n|$) 前瞻断言根治 回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 687 通过 50 跳过; Electron ABI 全量 737/737 零跳过
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* 配置分层测试(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:');
|
||||
});
|
||||
});
|
||||
@@ -13,9 +13,13 @@ vi.mock('electron-log', () => ({
|
||||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
import { safeParseArgs, validateMcpCommand, buildSafeEnv } from '../mcp-manager.service';
|
||||
import {
|
||||
safeParseArgs,
|
||||
safeParseHeaders,
|
||||
validateMcpCommand,
|
||||
buildSafeEnv,
|
||||
} from '../mcp-manager.service';
|
||||
import { SLOMonitor, HealthChecker } from '../../utils/slo';
|
||||
import type { Database } from 'better-sqlite3';
|
||||
|
||||
// better-sqlite3 的 ABI 可用性在模块顶层探测(describe.skipIf 在注册期求值)
|
||||
let dbAvailable = false;
|
||||
@@ -43,6 +47,34 @@ describe('safeParseArgs — JSON args 解析', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ===== MCP:safeParseHeaders(v0.7.2 P2-8 headers 列接线) =====
|
||||
|
||||
describe('safeParseHeaders — headers 列解析', () => {
|
||||
it('合法 JSON 对象(键值均为字符串)→ Record<string,string>', () => {
|
||||
expect(safeParseHeaders('{"Authorization":"Bearer t1","X-Route":"a"}')).toEqual({
|
||||
Authorization: 'Bearer t1',
|
||||
'X-Route': 'a',
|
||||
});
|
||||
});
|
||||
|
||||
it('空输入(null/undefined/空串)→ undefined(匿名连接)', () => {
|
||||
expect(safeParseHeaders(null)).toBeUndefined();
|
||||
expect(safeParseHeaders(undefined)).toBeUndefined();
|
||||
expect(safeParseHeaders('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('非对象/数组/坏 JSON → undefined(不阻断 server 连接)', () => {
|
||||
expect(safeParseHeaders('["a","b"]')).toBeUndefined();
|
||||
expect(safeParseHeaders('"plain"')).toBeUndefined();
|
||||
expect(safeParseHeaders('not json')).toBeUndefined();
|
||||
expect(safeParseHeaders('{}')).toBeUndefined(); // 空对象等价匿名
|
||||
});
|
||||
|
||||
it('非字符串值逐项丢弃,字符串项保留(部分损坏只损失损坏项)', () => {
|
||||
expect(safeParseHeaders('{"ok":"v1","bad":123,"worse":{"x":1}}')).toEqual({ ok: 'v1' });
|
||||
});
|
||||
});
|
||||
|
||||
// ===== MCP:validateMcpCommand =====
|
||||
|
||||
describe('validateMcpCommand — stdio 命令白名单防线', () => {
|
||||
@@ -51,7 +83,9 @@ describe('validateMcpCommand — stdio 命令白名单防线', () => {
|
||||
expect(() => validateMcpCommand(cmd, [])).not.toThrow();
|
||||
}
|
||||
// 目录前缀被剥除 → basename 命中白名单
|
||||
expect(() => validateMcpCommand('/usr/local/bin/npx', ['-y', '@modelcontextprotocol/server'])).not.toThrow();
|
||||
expect(() =>
|
||||
validateMcpCommand('/usr/local/bin/npx', ['-y', '@modelcontextprotocol/server']),
|
||||
).not.toThrow();
|
||||
// 现状锁定(比预期更严):扩展名不参与剥除 —— 'npx.cmd' 不在名单,直接拒绝。
|
||||
// 这是当前安全基线的一部分:宁可收紧也不放过任何可执行变体。
|
||||
expect(() => validateMcpCommand('C:\tools\npx.cmd', [])).toThrow(/allowed list/);
|
||||
@@ -92,7 +126,7 @@ describe('buildSafeEnv — 子进程环境净化', () => {
|
||||
ACCESS_TOKEN: 'tok',
|
||||
DB_PASSWORD: 'p@ss',
|
||||
AWS_SECRET_ACCESS_KEY2: 'k',
|
||||
DEPLOY_PRIVATE_KEY: '----', // 后缀 _PRIVATE_KEY 命中
|
||||
DEPLOY_PRIVATE_KEY: '----', // 后缀 _PRIVATE_KEY 命中
|
||||
GITEA_CREDENTIALS: '{"u":"x"}', // 后缀 _CREDENTIALS 命中(真实 CI/部署泄漏形态)
|
||||
SAFE_NAME: 'keepme',
|
||||
};
|
||||
@@ -113,7 +147,12 @@ describe('buildSafeEnv — 子进程环境净化', () => {
|
||||
|
||||
describe('SLOMonitor — 窗口指标 / 分位数 / 燃烧率', () => {
|
||||
function makeMonitor(): SLOMonitor {
|
||||
return new SLOMonitor({ target: 0.99, windowMs: 60_000, latencyPercentiles: [0.5, 0.95], latencyThresholdMs: 5_000 });
|
||||
return new SLOMonitor({
|
||||
target: 0.99,
|
||||
windowMs: 60_000,
|
||||
latencyPercentiles: [0.5, 0.95],
|
||||
latencyThresholdMs: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
it('空窗口:totalRequests=0、errorRate=0、violated=false、burnRate=0', () => {
|
||||
@@ -146,7 +185,12 @@ describe('SLOMonitor — 窗口指标 / 分位数 / 燃烧率', () => {
|
||||
});
|
||||
|
||||
it('错误率超预算 → burnRate>1 且 violated=true(全错样本:burnRate=100)', async () => {
|
||||
const m = new SLOMonitor({ target: 0.99, windowMs: 60_000, latencyPercentiles: [0.5], latencyThresholdMs: 10_000 });
|
||||
const m = new SLOMonitor({
|
||||
target: 0.99,
|
||||
windowMs: 60_000,
|
||||
latencyPercentiles: [0.5],
|
||||
latencyThresholdMs: 10_000,
|
||||
});
|
||||
for (let i = 0; i < 4; i++) m.recordRequest(50 + i, false);
|
||||
const s = m.getStatus();
|
||||
expect(s.errorRate).toBe(1);
|
||||
@@ -155,7 +199,12 @@ describe('SLOMonitor — 窗口指标 / 分位数 / 燃烧率', () => {
|
||||
});
|
||||
|
||||
it('窗口外记录被淘汰:回到基线 totalRequests=0(真实短窗口计时)', async () => {
|
||||
const m = new SLOMonitor({ target: 0.99, windowMs: 50, latencyPercentiles: [0.5], latencyThresholdMs: 10_000 });
|
||||
const m = new SLOMonitor({
|
||||
target: 0.99,
|
||||
windowMs: 50,
|
||||
latencyPercentiles: [0.5],
|
||||
latencyThresholdMs: 10_000,
|
||||
});
|
||||
m.recordRequest(100, true);
|
||||
m.recordRequest(120, false);
|
||||
expect(m.getStatus().totalRequests).toBe(2);
|
||||
@@ -173,11 +222,12 @@ describe.skipIf(!dbAvailable)('HealthChecker — 三项健康检查', () => {
|
||||
// 完整 DatabaseService.initialize() 覆盖;此处仅保留纯依赖注入的确定性用例。
|
||||
|
||||
it('DB ping 失败 → database check 不健康、healthy=false', async () => {
|
||||
const broken = { prepare: () => { throw new Error('disk I/O error'); } } as unknown as import('better-sqlite3').Database;
|
||||
const hc = new HealthChecker(
|
||||
() => broken,
|
||||
':memory:',
|
||||
);
|
||||
const broken = {
|
||||
prepare: () => {
|
||||
throw new Error('disk I/O error');
|
||||
},
|
||||
} as unknown as import('better-sqlite3').Database;
|
||||
const hc = new HealthChecker(() => broken, ':memory:');
|
||||
const report = await hc.check();
|
||||
const dbCheck = report.checks.find((c) => c.name === 'database');
|
||||
expect(dbCheck?.healthy).toBe(false);
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* SessionRecorder 测试(v0.7.2 覆盖补齐 —— 此前零测试)
|
||||
*
|
||||
* 锁定 TRACE 层录制契约(P1-6 重构的回归防线):
|
||||
* 1. startRecording 建文件并写 session_start;stopRecording 同步 flush + session_end
|
||||
* 2. 多会话隔离(P1-6:每会话独立文件/seq,并发录制互不串扰)
|
||||
* 3. setEnabled(false) 总开关丢弃事件(F-8 接通契约)
|
||||
* 4. 缓冲上限(MAX_BUFFER_SIZE)强制同步落盘防 OOM
|
||||
* 5. 事件 seq 递增与 9 类事件的载荷形状
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, readFileSync, existsSync, rmSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
vi.mock('electron-log', () => ({
|
||||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
import { SessionRecorder } from '../session-recorder.service';
|
||||
|
||||
let wsRoot: string;
|
||||
let recorder: SessionRecorder;
|
||||
|
||||
beforeEach(() => {
|
||||
wsRoot = mkdtempSync(join(tmpdir(), 'metona-rec-'));
|
||||
recorder = new SessionRecorder(wsRoot);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
rmSync(wsRoot, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
function readLines(sessionId: string): Array<Record<string, unknown>> {
|
||||
const dir = join(wsRoot, 'logs');
|
||||
const files = readdirSync(dir) as string[];
|
||||
const target = files.filter((f) => f.includes(`session_${sessionId}_`));
|
||||
expect(target.length).toBeGreaterThan(0);
|
||||
const content = readFileSync(join(dir, target[0]), 'utf-8');
|
||||
return content
|
||||
.split('\n')
|
||||
.filter((l) => l.trim())
|
||||
.map((l) => JSON.parse(l) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
describe('SessionRecorder — 基本录制链路', () => {
|
||||
it('startRecording 会话开启后首条事件即 session_start(seq 0,经 flush 落盘)', () => {
|
||||
recorder.startRecording('s1');
|
||||
// 事件先进缓冲(100ms 定时 flush),stopRecording 的 flushSync 保证落盘后断言
|
||||
recorder.stopRecording('s1', {
|
||||
totalIterations: 0,
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
const lines = readLines('s1');
|
||||
expect(lines).toHaveLength(2); // session_start + session_end
|
||||
expect(lines[0]).toMatchObject({ event: 'session_start', sessionId: 's1' });
|
||||
expect(lines[0].ts).toBeDefined();
|
||||
expect(lines[0].seq).toBe(0);
|
||||
expect(lines[1].event).toBe('session_end');
|
||||
});
|
||||
|
||||
it('stopRecording 同步 flush 全部缓冲并写 session_end(#35 契约)', () => {
|
||||
recorder.startRecording('s1');
|
||||
recorder.recordToolCall({
|
||||
sessionId: 's1',
|
||||
iteration: 1,
|
||||
toolName: 'read_file',
|
||||
args: { p: 'x' },
|
||||
});
|
||||
recorder.recordIterationStart('s1', 1);
|
||||
recorder.stopRecording('s1', {
|
||||
totalIterations: 1,
|
||||
totalTokens: 100,
|
||||
durationMs: 50,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
|
||||
const lines = readLines('s1');
|
||||
const events = lines.map((l) => l.event);
|
||||
expect(events).toEqual(['session_start', 'tool_call', 'iteration_start', 'session_end']);
|
||||
const end = lines[lines.length - 1];
|
||||
expect(end).toMatchObject({
|
||||
totalIterations: 1,
|
||||
totalTokens: 100,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
});
|
||||
|
||||
it('事件 seq 在会话内单调递增', () => {
|
||||
recorder.startRecording('s1');
|
||||
recorder.recordIterationStart('s1', 1);
|
||||
recorder.recordIterationStart('s1', 2);
|
||||
recorder.stopRecording('s1', {
|
||||
totalIterations: 2,
|
||||
totalTokens: 0,
|
||||
durationMs: 1,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
|
||||
// session_start(0) + iteration_start×2(1,2) + session_end(3)
|
||||
const seqs = readLines('s1').map((l) => l.seq as number);
|
||||
expect(seqs).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it('recordLLMResponse 载荷:content 截断 200 字符', () => {
|
||||
recorder.startRecording('s1');
|
||||
recorder.recordLLMResponse({
|
||||
sessionId: 's1',
|
||||
iteration: 1,
|
||||
content: 'y'.repeat(500),
|
||||
finishReason: 'stop',
|
||||
tokenUsage: { input: 10, output: 20, total: 30 },
|
||||
});
|
||||
recorder.stopRecording('s1', {
|
||||
totalIterations: 1,
|
||||
totalTokens: 30,
|
||||
durationMs: 1,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
|
||||
const llm = readLines('s1').find((l) => l.event === 'llm_response') as Record<string, unknown>;
|
||||
expect((llm.contentPreview as string).length).toBe(200);
|
||||
expect(llm.tokenUsage).toEqual({ input: 10, output: 20, total: 30 });
|
||||
});
|
||||
|
||||
it('recordToolResult 载荷:success/durationMs/resultPreview 截断 500/error', () => {
|
||||
recorder.startRecording('s1');
|
||||
recorder.recordToolResult({
|
||||
sessionId: 's1',
|
||||
iteration: 1,
|
||||
toolName: 'web_fetch',
|
||||
success: false,
|
||||
durationMs: 42,
|
||||
resultPreview: 'z'.repeat(800),
|
||||
error: 'HTTP 403',
|
||||
});
|
||||
recorder.stopRecording('s1', {
|
||||
totalIterations: 1,
|
||||
totalTokens: 0,
|
||||
durationMs: 1,
|
||||
terminationReason: 'error',
|
||||
});
|
||||
|
||||
const tr = readLines('s1').find((l) => l.event === 'tool_result') as Record<string, unknown>;
|
||||
expect(tr.success).toBe(false);
|
||||
expect(tr.durationMs).toBe(42);
|
||||
expect((tr.resultPreview as string).length).toBe(500);
|
||||
expect(tr.error).toBe('HTTP 403');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SessionRecorder — 多会话隔离(P1-6)', () => {
|
||||
it('并发录制:每会话独立文件与独立 seq,互不串扰', () => {
|
||||
recorder.startRecording('s1');
|
||||
recorder.startRecording('s2');
|
||||
|
||||
recorder.recordToolCall({ sessionId: 's1', iteration: 1, toolName: 'tool_a', args: {} });
|
||||
recorder.recordToolCall({ sessionId: 's2', iteration: 1, toolName: 'tool_b', args: {} });
|
||||
|
||||
recorder.stopRecording('s1', {
|
||||
totalIterations: 1,
|
||||
totalTokens: 0,
|
||||
durationMs: 1,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
recorder.stopRecording('s2', {
|
||||
totalIterations: 1,
|
||||
totalTokens: 0,
|
||||
durationMs: 1,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
|
||||
const s1Tools = readLines('s1').filter((l) => l.event === 'tool_call');
|
||||
const s2Tools = readLines('s2').filter((l) => l.event === 'tool_call');
|
||||
expect(s1Tools[0].tool).toBe('tool_a');
|
||||
expect(s2Tools[0].tool).toBe('tool_b');
|
||||
// seq 各自从 0 起算(s1: start=0, tool=1, end=2;s2 同构)
|
||||
expect(s1Tools[0].seq).toBe(1);
|
||||
expect(s2Tools[0].seq).toBe(1);
|
||||
});
|
||||
|
||||
it('未 startRecording 的会话事件被静默丢弃', () => {
|
||||
recorder.recordToolCall({ sessionId: 'ghost', iteration: 1, toolName: 'x', args: {} });
|
||||
expect(recorder.getFilePath('ghost')).toBeNull();
|
||||
});
|
||||
|
||||
it('stopRecording 幂等安全(未开始也会话状态不崩)', () => {
|
||||
expect(() =>
|
||||
recorder.stopRecording('ghost', {
|
||||
totalIterations: 0,
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
terminationReason: 'error',
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SessionRecorder — 总开关与缓冲上限', () => {
|
||||
it('setEnabled(false) 后事件全部丢弃(logging.traceEnabled 契约)', () => {
|
||||
recorder.setEnabled(false);
|
||||
recorder.startRecording('s1');
|
||||
recorder.recordToolCall({ sessionId: 's1', iteration: 1, toolName: 'x', args: {} });
|
||||
recorder.stopRecording('s1', {
|
||||
totalIterations: 1,
|
||||
totalTokens: 0,
|
||||
durationMs: 1,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
|
||||
const dir = join(wsRoot, 'logs');
|
||||
const files = existsSync(dir) ? readdirSync(dir) : [];
|
||||
expect(files.filter((f) => f.startsWith('session_s1_'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('缓冲超过 MAX_BUFFER_SIZE 强制同步落盘(防 OOM)', () => {
|
||||
recorder.startRecording('s1');
|
||||
// MAX_BUFFER_SIZE = 1000 —— 写入超限触发 flushSync(文件应提前出现在磁盘)
|
||||
for (let i = 0; i < 1001; i++) {
|
||||
recorder.recordIterationStart('s1', i);
|
||||
}
|
||||
recorder.stopRecording('s1', {
|
||||
totalIterations: 1001,
|
||||
totalTokens: 0,
|
||||
durationMs: 1,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
|
||||
const lines = readLines('s1');
|
||||
expect(lines.length).toBe(1003); // 1001 iterations + session_start + session_end
|
||||
});
|
||||
|
||||
it('getFilePath 返回活动会话的录制文件路径;stop 后清除', () => {
|
||||
recorder.startRecording('s1');
|
||||
expect(recorder.getFilePath('s1')).toContain('session_s1_');
|
||||
recorder.stopRecording('s1', {
|
||||
totalIterations: 0,
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
expect(recorder.getFilePath('s1')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -336,7 +336,9 @@ describe.skipIf(!dbAvailable)('clearMessages × 摘要游标交互(B-2)', ()
|
||||
// 用户在长会话里攒到 summarized_until_rowid=500 后执行"清空消息")
|
||||
for (let i = 1; i <= 5; i++) ins(`old_m${i}`, `旧消息 ${i}`);
|
||||
const maxRowIdBefore = (
|
||||
db.prepare(`SELECT MAX(rowid) AS r FROM messages WHERE session_id='s_clear'`).get() as { r: number }
|
||||
db.prepare(`SELECT MAX(rowid) AS r FROM messages WHERE session_id='s_clear'`).get() as {
|
||||
r: number;
|
||||
}
|
||||
).r;
|
||||
expect(maxRowIdBefore).toBe(5);
|
||||
summaryService.saveSummary('s_clear', '覆盖全部旧消息的摘要', 500);
|
||||
@@ -349,7 +351,9 @@ describe.skipIf(!dbAvailable)('clearMessages × 摘要游标交互(B-2)', ()
|
||||
expect(summaryService.buildHistoryMessages('s_clear').length).toBe(0);
|
||||
|
||||
// 阶段二:用户点击"清空消息"
|
||||
expect(sessionService.clearMessages('s_clear')).toBe(true);
|
||||
// v0.7.2 A1: clearMessages 语义改为 void(成功判定是"操作完成"而非"有行被删除",
|
||||
// 空会话清空同样成功 —— IPC 层据此统一返回 success:true)
|
||||
expect(() => sessionService.clearMessages('s_clear')).not.toThrow();
|
||||
expect(sessionService.getMessages('s_clear')).toHaveLength(0);
|
||||
|
||||
// 核心契约 1:摘要记录被同步删除(原实现此处残留 cursor=5)
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* WorkspaceService 测试(v0.7.2 覆盖补齐 —— 此前零测试)
|
||||
*
|
||||
* 锁定工作空间生命周期契约:
|
||||
* 1. initialize 创建目录结构(logs/.metona)与必需文件(SOUL.md/MEMORY.md)
|
||||
* 2. MEMORY.md 模板元数据头 + 5 分区结构
|
||||
* 3. appendMemory 分区追加语义(含未知 section 兜底)
|
||||
* 4. validateMemoryFormat 自动修正(缺元数据头/缺分区)
|
||||
* 5. updateMemoryTimestamp / reload(外部编辑同步)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
import { WorkspaceService } from '../workspace.service';
|
||||
|
||||
let wsRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
wsRoot = mkdtempSync(join(tmpdir(), 'metona-ws-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
rmSync(wsRoot, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
describe('WorkspaceService — initialize', () => {
|
||||
it('创建工作空间目录结构 + 两个必需文件 + 自动目录', () => {
|
||||
const wsPath = join(wsRoot, 'ws');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
const info = svc.initialize();
|
||||
|
||||
expect(info.isValid).toBe(true);
|
||||
expect(info.path).toBe(wsPath);
|
||||
expect(existsSync(join(wsPath, 'SOUL.md'))).toBe(true);
|
||||
expect(existsSync(join(wsPath, 'MEMORY.md'))).toBe(true);
|
||||
expect(existsSync(join(wsPath, 'logs'))).toBe(true);
|
||||
expect(existsSync(join(wsPath, '.metona'))).toBe(true);
|
||||
// 本次启动自动创建的文件计入 missingFiles
|
||||
expect(info.missingFiles).toEqual(['SOUL.md', 'MEMORY.md']);
|
||||
});
|
||||
|
||||
it('已有完整文件的工作空间:missingFiles 为空且内容不被覆盖', () => {
|
||||
const wsPath = join(wsRoot, 'ws');
|
||||
mkdirSync(wsPath, { recursive: true });
|
||||
writeFileSync(join(wsPath, 'SOUL.md'), '# 自定义人格', 'utf-8');
|
||||
writeFileSync(
|
||||
join(wsPath, 'MEMORY.md'),
|
||||
'# MEMORY.md\n> 创建时间: x\n> 最后更新: y\n> 工作空间: z\n',
|
||||
'utf-8',
|
||||
);
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
const info = svc.initialize();
|
||||
expect(info.missingFiles).toEqual([]);
|
||||
expect(svc.getFiles().soul).toBe('# 自定义人格');
|
||||
});
|
||||
|
||||
it('MEMORY.md 模板包含元数据头与核心分区', () => {
|
||||
const wsPath = join(wsRoot, 'ws');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
svc.initialize();
|
||||
const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
|
||||
expect(content).toContain('# MEMORY.md');
|
||||
expect(content).toContain('> 创建时间:');
|
||||
expect(content).toContain('> 最后更新:');
|
||||
expect(content).toContain('> 工作空间:');
|
||||
for (const section of [
|
||||
'## 用户偏好',
|
||||
'## 项目上下文',
|
||||
'## 重要决策',
|
||||
'## 待办事项',
|
||||
'## 已知问题',
|
||||
]) {
|
||||
expect(content).toContain(section);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceService — appendMemory', () => {
|
||||
it('追加到指定 section 末尾(不侵入下一分区)', () => {
|
||||
const wsPath = join(wsRoot, 'ws');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
svc.initialize();
|
||||
|
||||
svc.appendMemory('用户偏好', '偏好深色主题');
|
||||
svc.appendMemory('用户偏好', '偏好简洁回复');
|
||||
|
||||
const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
|
||||
const sectionStart = content.indexOf('## 用户偏好');
|
||||
const sectionEnd = content.indexOf('## 项目上下文');
|
||||
const section = content.slice(sectionStart, sectionEnd);
|
||||
expect(section).toContain('- 偏好深色主题');
|
||||
expect(section).toContain('- 偏好简洁回复');
|
||||
});
|
||||
|
||||
it('未知 section 追加到文件末尾并自动创建分区头', () => {
|
||||
const wsPath = join(wsRoot, 'ws');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
svc.initialize();
|
||||
|
||||
svc.appendMemory('自定义分区', '条目内容');
|
||||
const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
|
||||
expect(content).toContain('## 自定义分区');
|
||||
expect(content).toContain('- 条目内容');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceService — validateMemoryFormat 自动修正', () => {
|
||||
it('缺失元数据头与核心分区被自动补齐,内容不丢失', () => {
|
||||
const wsPath = join(wsRoot, 'ws');
|
||||
mkdirSync(wsPath, { recursive: true });
|
||||
writeFileSync(join(wsPath, 'MEMORY.md'), '# MEMORY.md\n- 用户留下的重要内容', 'utf-8');
|
||||
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
expect(svc.validateMemoryFormat()).toBe(false); // false = 有修正
|
||||
|
||||
const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
|
||||
expect(content).toContain('> 创建时间:');
|
||||
expect(content).toContain('> 工作空间:');
|
||||
for (const section of ['## 用户偏好', '## 项目上下文', '## 重要决策']) {
|
||||
expect(content).toContain(section);
|
||||
}
|
||||
// 原内容保留
|
||||
expect(content).toContain('- 用户留下的重要内容');
|
||||
});
|
||||
|
||||
it('格式完整时返回 true 且不触发写盘', () => {
|
||||
const wsPath = join(wsRoot, 'ws');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
svc.initialize();
|
||||
expect(svc.validateMemoryFormat()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceService — 时间戳与 reload', () => {
|
||||
it('updateMemoryTimestamp 更新"最后更新"行', () => {
|
||||
const wsPath = join(wsRoot, 'ws');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
svc.initialize();
|
||||
|
||||
svc.updateMemoryTimestamp();
|
||||
const content = svc.getFiles().memory;
|
||||
expect(content).toMatch(/> 最后更新: \d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it('reload 同步外部编辑(用户手动改文件后)', () => {
|
||||
const wsPath = join(wsRoot, 'ws');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
svc.initialize();
|
||||
|
||||
writeFileSync(join(wsPath, 'SOUL.md'), '# 外部修改后的人格', 'utf-8');
|
||||
const files = svc.reload();
|
||||
expect(files.soul).toBe('# 外部修改后的人格');
|
||||
});
|
||||
|
||||
it('getFiles 返回副本(外部修改引用不影响内部状态)', () => {
|
||||
const wsPath = join(wsRoot, 'ws');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
svc.initialize();
|
||||
const files = svc.getFiles();
|
||||
files.soul = 'tampered';
|
||||
expect(svc.getFiles().soul).not.toBe('tampered');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user