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

P1 修复面收口:
- 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR),
  根治"真实网络超时被误报为用户中断"
- 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道)
- 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 +
  前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死)
- 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED)
- DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制)
- IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复

P2 安全纵深:
- preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险)
- CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步)
- MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径)
- write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏)
- 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库
- ReDoS 检测共享化(search_files/file_editor 统一拦截)
- run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command)
- MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 +
  十六进制映射解析 + 尾点剥离)

P3 架构还债:
- temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘
- SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async)
- 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine)
- i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数)
- 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释)
- 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 +
  用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位)

P4 能力演进:
- 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报)
- run-lock 30s 超时强制 abort(旧 run 卡死不无限排队)
- RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复)
- FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退)
- getContextWindow 兜底 1M→128K(未知模型防 413)

测试:
- 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、
  工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、
  纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例)
- 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷
- 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 /
  mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性

版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload,
新增 jsdom/@testing-library(devDependencies 不打包)

回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过;
系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
This commit is contained in:
2026-08-30 19:19:07 +08:00
parent ebe45482b0
commit 99d0c54129
137 changed files with 25190 additions and 1792 deletions
@@ -1,15 +1,17 @@
/**
* AuditService 单元测试(P1-14 测试基线)
* 覆盖:链式哈希生成与验证、篡改检测
* AuditService 单元测试(P1-14 测试基线扩充
* 覆盖:链式哈希生成与验证、篡改检测、resetChainCache、query 过滤矩阵、
* CSV/JSONL 导出转义、logToolCall/logLLMRequest/logSessionEnd 等便捷方法。
*
* 注意:better-sqlite3 为原生模块——若本机安装的构建与系统 Node ABI 不匹配,
* 测试自动跳过(skipIf),避免误报失败。
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { createHash } from 'crypto';
import log from 'electron-log';
// better-sqlite3 加载失败时(ABI 不匹配)自动跳过整个套件
@@ -32,14 +34,12 @@ log.transports.file.level = false;
describe.skipIf(!dbAvailable)('AuditService 链式哈希', () => {
let dir: string;
// eslint-disable 是失效指令(测试文件 overrides 已关闭 no-explicit-any),已移除
let db: any;
let service: any;
beforeAll(async () => {
const { AuditService } = await import('../../services/audit.service');
dir = mkdtempSync(join(tmpdir(), 'metona-audit-'));
// 手动建库(绕过 DatabaseService 的 electron 依赖)
db = new Database(join(dir, 'test.db'));
db.exec(`
CREATE TABLE IF NOT EXISTS audit_logs (
@@ -65,7 +65,11 @@ describe.skipIf(!dbAvailable)('AuditService 链式哈希', () => {
});
afterAll(() => {
try { db?.close(); } catch { /* ignore */ }
try {
db?.close();
} catch {
/* ignore */
}
rmSync(dir, { recursive: true, force: true });
});
@@ -103,6 +107,8 @@ describe.skipIf(!dbAvailable)('AuditService 链式哈希', () => {
const result = service.verifyChain();
expect(result.valid).toBe(true);
expect(result.totalRecords).toBe(2);
expect(result.tamperedId).toBeNull();
expect(result.verifiedRecords).toBe(2);
});
it('篡改记录后 verifyChain 检测到 tampered', () => {
@@ -115,3 +121,663 @@ describe.skipIf(!dbAvailable)('AuditService 链式哈希', () => {
expect(result.valid).toBe(false);
});
});
describe.skipIf(!dbAvailable)('AuditService — 链式哈希更多场景', () => {
let dir: string;
let db: any;
let service: any;
beforeEach(async () => {
const { AuditService } = await import('../../services/audit.service');
dir = mkdtempSync(join(tmpdir(), 'metona-audit2-'));
db = new Database(join(dir, 'test.db'));
db.exec(`
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
iteration INTEGER,
event_type TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT 'system',
target TEXT NOT NULL,
details TEXT,
outcome TEXT,
duration_ms INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
prev_hash TEXT,
current_hash TEXT
);
`);
service = new AuditService(() => db);
});
afterEach(() => {
try {
db?.close();
} catch {
/* ignore */
}
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* ignore */
}
});
it('verifyChain 空表返回 valid 且 totalRecords=0', () => {
const result = service.verifyChain();
expect(result.valid).toBe(true);
expect(result.totalRecords).toBe(0);
expect(result.verifiedRecords).toBe(0);
});
it('verifyChain 检测断链位置:中间记录被篡改时 tamperedId 指向该行', () => {
// 写入 3 条
for (let i = 0; i < 3; i++) {
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: `t${i}`,
outcome: 'error',
});
}
// 篡改第 2 条的 target
db.prepare(`UPDATE audit_logs SET target = 'hacked' WHERE id = 2`).run();
const result = service.verifyChain();
expect(result.valid).toBe(false);
expect(result.tamperedId).toBe(2);
// 只有第 1 条被验证通过
expect(result.verifiedRecords).toBe(1);
});
it('verifyChain 检测到首条被篡改(tamperedId=1', () => {
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 't0',
outcome: 'error',
});
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 't1',
outcome: 'error',
});
db.prepare(`UPDATE audit_logs SET session_id = 'x' WHERE id = 1`).run();
const result = service.verifyChain();
expect(result.valid).toBe(false);
expect(result.tamperedId).toBe(1);
});
it('verifyChain 兼容旧算法(JSON.stringify 插入序生成的 legacy hash', () => {
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 'legacy',
outcome: 'error',
details: { b: 1, a: 2 },
});
const row = db.prepare('SELECT * FROM audit_logs WHERE id = 1').get();
// 旧算法:JSON.stringify 按字段插入序(与 computeHash 传入顺序一致),
// 对象 key 不排序 → 对含嵌套 details 的行与 stableStringify 结果不同。
const legacyContent = JSON.stringify({
prevHash: row.prev_hash,
sessionId: row.session_id,
iteration: row.iteration,
eventType: row.event_type,
actor: row.actor,
target: row.target,
details: row.details, // 已是序列化字符串
outcome: row.outcome,
durationMs: row.duration_ms,
createdAt: row.created_at,
});
const legacyHash = createHash('sha256').update(legacyContent, 'utf-8').digest('hex');
// 用旧算法 hash 覆盖 current_hash,模拟 #36 之前落盘的旧数据
db.prepare('UPDATE audit_logs SET current_hash = ? WHERE id = 1').run(legacyHash);
// 若 legacy 兼容分支未实现 → 判定 tampered;实现则 valid
const result = service.verifyChain();
expect(result.valid).toBe(true);
expect(result.tamperedId).toBeNull();
});
it('verifyChain 对新数据仍用 stableStringify 校验(旧算法 hash 无法通过新校验)', () => {
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 'modern',
outcome: 'error',
details: { b: 1, a: 2 },
});
const row = db.prepare('SELECT * FROM audit_logs WHERE id = 1').get();
const legacyContent = JSON.stringify({
prevHash: row.prev_hash,
sessionId: row.session_id,
iteration: row.iteration,
eventType: row.event_type,
actor: row.actor,
target: row.target,
details: row.details,
outcome: row.outcome,
durationMs: row.duration_ms,
createdAt: row.created_at,
});
const legacyHash = createHash('sha256').update(legacyContent, 'utf-8').digest('hex');
// 旧算法 hash 与 stable hash 不同(details 含多 key 对象时 key 顺序差异)
expect(legacyHash).not.toBe(row.current_hash);
// 但旧算法 hash 对当前行可被 legacy 分支接受 → verifyChain 仍 valid。
// 该分支的存在是设计意图(兼容旧数据),此处锁定"两算法结果不同"的区分度。
expect(row.current_hash).toMatch(/^[0-9a-f]{64}$/);
expect(service.verifyChain().valid).toBe(true);
});
it('verifyChain 跳过 current_hash 为 NULL 的旧行(未回填哈希)', () => {
db.prepare(
`INSERT INTO audit_logs (session_id, event_type, actor, target, created_at, prev_hash, current_hash)
VALUES ('s', 'error', 'agent', 'old-no-hash', 1, NULL, NULL)`,
).run();
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 'new-hash',
outcome: 'error',
});
const result = service.verifyChain();
expect(result.valid).toBe(true);
// 新行验证通过 1 条
expect(result.verifiedRecords).toBe(1);
});
it('resetChainCache 后首条 prev_hash 回到零哈希(P1-5 修复)', () => {
// 写两条产生链
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 'a',
outcome: 'error',
});
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 'b',
outcome: 'error',
});
// 清空表(测试环境无 INSERT-ONLY 触发器)
db.prepare('DELETE FROM audit_logs').run();
service.resetChainCache();
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 'first-after-reset',
outcome: 'error',
});
const row = db.prepare('SELECT prev_hash FROM audit_logs').get();
expect(row.prev_hash).toBe('0'.repeat(64));
expect(service.verifyChain().valid).toBe(true);
});
it('未 resetChainCache 时清空表后新首条 prev_hash 引用旧哈希(P1-5 缺陷复现语义)', () => {
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 'old',
outcome: 'error',
});
const oldHash = (
db.prepare('SELECT current_hash FROM audit_logs').get() as { current_hash: string }
).current_hash;
db.prepare('DELETE FROM audit_logs').run();
// 不调用 resetChainCache —— 缓存仍持有 oldHash
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 'new',
outcome: 'error',
});
const row = db.prepare('SELECT prev_hash FROM audit_logs').get();
expect(row.prev_hash).toBe(oldHash); // 缺陷行为:引用已删除行的 hash
// 验证链因此断裂
expect(service.verifyChain().valid).toBe(false);
});
it('current_hash 是 64 位十六进制 SHA-256', () => {
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 'hash-format',
outcome: 'error',
});
const row = db.prepare('SELECT current_hash FROM audit_logs').get();
expect(row.current_hash).toMatch(/^[0-9a-f]{64}$/);
});
it('相同 details 对象不同 key 顺序产生相同 hash#36 stableStringify 契约)', () => {
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 'stable',
details: { b: 1, a: 2 },
outcome: 'error',
});
const row = db.prepare('SELECT current_hash FROM audit_logs').get();
// stableStringify 排序 key 后与显式构造的 {a:2,b:1} 一致
expect(row.current_hash).toMatch(/^[0-9a-f]{64}$/);
// 再写一条同语义不同顺序的 → hash 与仅按顺序不同无法直接对比(created_at 参与),
// 但确认不抛错且链验证通过
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 'stable2',
details: { a: 2, b: 1 },
outcome: 'error',
});
expect(service.verifyChain().valid).toBe(true);
});
it('log 写入 details JSON 字符串(null 存 NULL', () => {
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 'details-null',
});
const row = db.prepare('SELECT details FROM audit_logs').get();
expect(row.details).toBeNull();
});
it('log 失败不抛错(写库异常时静默记录 WARN)', () => {
// 关闭连接后写入 → prepare 抛错 → log 捕获
const closed = new Database(':memory:');
closed.close();
const broken = new service.constructor(() => closed);
expect(() =>
broken.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 'boom',
}),
).not.toThrow();
});
it('logToolCall 写入 tool_call 事件且 long result 截断 1000 字符', () => {
service.logToolCall({
sessionId: 's',
iteration: 1,
toolName: 'read_file',
args: { path: '/a' },
outcome: 'success',
result: 'r'.repeat(2000),
durationMs: 5,
});
const row = db.prepare('SELECT * FROM audit_logs').get();
expect(row.event_type).toBe('tool_call');
expect(row.target).toBe('read_file');
expect(row.iteration).toBe(1);
const details = JSON.parse(row.details);
expect(details.args).toEqual({ path: '/a' });
expect(details.result).toHaveLength(1000);
expect(row.outcome).toBe('success');
expect(row.duration_ms).toBe(5);
});
it('logLLMRequest 写入 llm_request 事件(target=provider/model', () => {
service.logLLMRequest({
sessionId: 's',
iteration: 2,
provider: 'deepseek',
model: 'v4',
tokenCount: 1000,
});
const row = db.prepare('SELECT * FROM audit_logs').get();
expect(row.event_type).toBe('llm_request');
expect(row.target).toBe('deepseek/v4');
expect(JSON.parse(row.details)).toEqual({ tokenCount: 1000 });
});
it('logLLMResponse 写入 llm_response 事件含 tokenUsage 与 outcome=success', () => {
service.logLLMResponse({
sessionId: 's',
iteration: 2,
provider: 'deepseek',
model: 'v4',
tokenUsage: { input: 10, output: 20, total: 30 },
durationMs: 42,
});
const row = db.prepare('SELECT * FROM audit_logs').get();
expect(row.event_type).toBe('llm_response');
expect(row.outcome).toBe('success');
expect(row.duration_ms).toBe(42);
expect(JSON.parse(row.details)).toEqual({ tokenUsage: { input: 10, output: 20, total: 30 } });
});
it('logSessionStart 写入 session_start 事件(actor=user', () => {
service.logSessionStart('sess-1');
const row = db.prepare('SELECT * FROM audit_logs').get();
expect(row.event_type).toBe('session_start');
expect(row.actor).toBe('user');
expect(row.target).toBe('session');
expect(row.session_id).toBe('sess-1');
});
it('logSessionEnd 写入 session_end 事件(details 含迭代/token/终止原因)', () => {
service.logSessionEnd({
sessionId: 's',
totalIterations: 5,
totalTokens: 999,
durationMs: 60000,
terminationReason: 'completed',
});
const row = db.prepare('SELECT * FROM audit_logs').get();
expect(row.event_type).toBe('session_end');
expect(row.outcome).toBe('success');
expect(JSON.parse(row.details)).toEqual({
totalIterations: 5,
totalTokens: 999,
terminationReason: 'completed',
});
});
});
describe.skipIf(!dbAvailable)('AuditService — query 过滤矩阵', () => {
let dir: string;
let db: any;
let service: any;
beforeEach(async () => {
const { AuditService } = await import('../../services/audit.service');
dir = mkdtempSync(join(tmpdir(), 'metona-audit-query-'));
db = new Database(join(dir, 'test.db'));
db.exec(`
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
iteration INTEGER,
event_type TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT 'system',
target TEXT NOT NULL,
details TEXT,
outcome TEXT,
duration_ms INTEGER,
created_at INTEGER NOT NULL,
prev_hash TEXT,
current_hash TEXT
);
`);
service = new AuditService(() => db);
// 预置 4 条不同 session/type/actor 的日志
const seed = [
{ sessionId: 'sA', eventType: 'tool_call', actor: 'agent', target: 't1', created: 100 },
{ sessionId: 'sA', eventType: 'error', actor: 'agent', target: 't2', created: 200 },
{ sessionId: 'sB', eventType: 'tool_call', actor: 'user', target: 't3', created: 300 },
{
sessionId: 'sB',
eventType: 'permission_check',
actor: 'system',
target: 't4',
created: 400,
},
];
for (const e of seed) {
service.log({
sessionId: e.sessionId,
eventType: e.eventType,
actor: e.actor,
target: e.target,
});
db.prepare(
'UPDATE audit_logs SET created_at = ? WHERE id = (SELECT MAX(id) FROM audit_logs)',
).run(e.created);
}
});
afterEach(() => {
try {
db?.close();
} catch {
/* ignore */
}
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* ignore */
}
});
it('无过滤返回全部记录且按 created_at 倒序', () => {
const rows = service.query();
expect(rows).toHaveLength(4);
// created_at 倒序:400,300,200,100
expect(rows[0].target).toBe('t4');
expect(rows[3].target).toBe('t1');
});
it('sessionId 过滤只返回该会话', () => {
const rows = service.query({ sessionId: 'sA' });
expect(rows).toHaveLength(2);
expect(rows.every((r: any) => r.session_id === 'sA')).toBe(true);
});
it('eventType 过滤只返回该事件类型', () => {
const rows = service.query({ eventType: 'tool_call' });
expect(rows).toHaveLength(2);
expect(rows.every((r: any) => r.event_type === 'tool_call')).toBe(true);
});
it('sessionId + eventType 组合过滤', () => {
const rows = service.query({ sessionId: 'sB', eventType: 'tool_call' });
expect(rows).toHaveLength(1);
expect(rows[0].target).toBe('t3');
});
it('limit 限制返回条数(按时间倒序取前 N)', () => {
const rows = service.query({ limit: 2 });
expect(rows).toHaveLength(2);
expect(rows[0].target).toBe('t4');
expect(rows[1].target).toBe('t3');
});
it('limit 与过滤组合生效', () => {
const rows = service.query({ sessionId: 'sB', limit: 1 });
expect(rows).toHaveLength(1);
expect(rows[0].target).toBe('t4');
});
it('actor 字段随行返回(query 无 actor 过滤参数 —— 按 actor 由调用方筛选)', () => {
const rows = service.query();
const actors = rows.map((r: any) => r.actor);
expect(actors).toContain('user');
expect(actors).toContain('system');
expect(actors).toContain('agent');
});
it('不存在的 sessionId 过滤返回空数组', () => {
expect(service.query({ sessionId: 'ghost' })).toHaveLength(0);
});
});
describe.skipIf(!dbAvailable)('AuditService — 导出与脱敏', () => {
let dir: string;
let db: any;
let service: any;
beforeEach(async () => {
const { AuditService } = await import('../../services/audit.service');
dir = mkdtempSync(join(tmpdir(), 'metona-audit-export2-'));
db = new Database(join(dir, 'test.db'));
db.exec(`
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
iteration INTEGER,
event_type TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT 'system',
target TEXT NOT NULL,
details TEXT,
outcome TEXT,
duration_ms INTEGER,
created_at INTEGER NOT NULL,
prev_hash TEXT,
current_hash TEXT
);
`);
service = new AuditService(() => db);
});
afterEach(() => {
try {
db?.close();
} catch {
/* ignore */
}
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* ignore */
}
});
it('exportJSONL 空表返回空串', () => {
expect(service.exportJSONL()).toBe('');
});
it('exportJSONL 每行一条 JSON 且末尾换行', () => {
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 't',
outcome: 'error',
});
const jsonl = service.exportJSONL();
expect(jsonl.endsWith('\n')).toBe(true);
const lines = jsonl.trim().split('\n');
expect(lines).toHaveLength(1);
const parsed = JSON.parse(lines[0]);
expect(parsed.session_id).toBe('s');
expect(parsed.current_hash).toMatch(/^[0-9a-f]{64}$/);
});
it('exportCSV 空表仅表头', () => {
const csv = service.exportCSV();
const lines = csv.split('\n').filter((l: string) => l.length > 0);
expect(lines).toHaveLength(1);
expect(lines[0]).toContain('id,session_id,iteration,event_type');
});
it('CSV 转义:逗号字段被双引号包裹', () => {
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 'a,b,c',
outcome: 'error',
});
const csv = service.exportCSV();
expect(csv).toContain('"a,b,c"');
});
it('CSV 转义:字段内双引号翻倍(RFC 4180)', () => {
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 'say "hi"',
outcome: 'error',
});
const csv = service.exportCSV();
expect(csv).toContain('"say ""hi"""');
});
it('CSV 转义:字段内换行保留且整字段双引号包裹(RFC 4180 多行字段)', () => {
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 'line1\nline2',
outcome: 'error',
});
const csv = service.exportCSV();
// 换行在双引号字段内 → 物理行 = 表头 + 记录(跨 2 行)+ 末尾空行(尾部 \n)
expect(csv.split('\n')).toHaveLength(4);
// 引号包裹的字段内保留字面换行
expect(csv).toContain('"line1\nline2"');
});
it('CSV 数值与空值字段不包裹', () => {
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 'plain',
outcome: 'error',
durationMs: 123,
});
const csv = service.exportCSV();
const row = csv.split('\n')[1];
// duration_ms 字段为纯数字不被引号包裹
expect(row).toContain(',123,');
// outcome 字段 plain 不带引号
expect(csv).toContain(',plain,');
});
it('exportJSONL 与 exportCSV 记录数一致', () => {
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 't',
outcome: 'error',
});
service.log({
sessionId: 's',
eventType: 'error',
actor: 'agent',
target: 'u',
outcome: 'error',
});
const jsonlCount = service.exportJSONL().trim().split('\n').length;
const csvCount =
service
.exportCSV()
.split('\n')
.filter((l: string) => l.length > 0).length - 1;
expect(jsonlCount).toBe(csvCount);
});
it('CSV 表头字段序固定(导出 schema 稳定性)', () => {
service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 't' });
const header = service.exportCSV().split('\n')[0];
expect(header).toBe(
'id,session_id,iteration,event_type,actor,target,details,outcome,duration_ms,created_at,prev_hash,current_hash',
);
});
it('JSONL 行含 prev_hash 与 current_hash 完整链字段', () => {
service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'a' });
service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'b' });
const parsed = service
.exportJSONL()
.trim()
.split('\n')
.map((l: string) => JSON.parse(l));
expect(parsed[1].prev_hash).toBe(parsed[0].current_hash);
});
});