feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
P1 修复面收口: - 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR), 根治"真实网络超时被误报为用户中断" - 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道) - 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 + 前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死) - 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED) - DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制) - IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复 P2 安全纵深: - preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险) - CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步) - MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径) - write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏) - 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库 - ReDoS 检测共享化(search_files/file_editor 统一拦截) - run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command) - MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 + 十六进制映射解析 + 尾点剥离) P3 架构还债: - temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘 - SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async) - 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine) - i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数) - 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释) - 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 + 用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位) P4 能力演进: - 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报) - run-lock 30s 超时强制 abort(旧 run 卡死不无限排队) - RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复) - FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退) - getContextWindow 兜底 1M→128K(未知模型防 413) 测试: - 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、 工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、 纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例) - 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷 - 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 / mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性 版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload, 新增 jsdom/@testing-library(devDependencies 不打包) 回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过; 系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { mkdtempSync, mkdirSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
@@ -26,7 +26,16 @@ try {
|
||||
dbAvailable = false;
|
||||
}
|
||||
|
||||
import type { DatabaseService } from '../database.service';
|
||||
import { DatabaseService } from '../database.service';
|
||||
|
||||
/** 预置 legacy 库:先建 .metona 目录再开库(与 DatabaseService 目录布局一致) */
|
||||
function openLegacyDb(dir: string): any {
|
||||
const metonaDir = join(dir, '.metona');
|
||||
mkdirSync(metonaDir, { recursive: true });
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const raw = require('better-sqlite3');
|
||||
return new raw(join(metonaDir, 'agent.db'));
|
||||
}
|
||||
|
||||
describe.skipIf(!dbAvailable)('DatabaseService — PRAGMA user_version 版本化', () => {
|
||||
let dir: string;
|
||||
@@ -55,7 +64,8 @@ describe.skipIf(!dbAvailable)('DatabaseService — PRAGMA user_version 版本化
|
||||
|
||||
it('initialize 后 user_version 已盖章为 SCHEMA_VERSION', () => {
|
||||
const version = svc2.getDB().pragma('user_version', { simple: true }) as number;
|
||||
expect(version).toBe(1);
|
||||
// v0.7.4 P4-4: SCHEMA_VERSION 1 → 2(迁移 9 trigram 纳入版本化)
|
||||
expect(version).toBe(DatabaseService.SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
it('盖章库的探测批次被跳过(重复 initialize 不报错且配置 seed 保持存在)', () => {
|
||||
@@ -65,3 +75,338 @@ describe.skipIf(!dbAvailable)('DatabaseService — PRAGMA user_version 版本化
|
||||
expect(row.n).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('DatabaseService — 全量表结构与索引', () => {
|
||||
let dir: string;
|
||||
let svc: DatabaseService;
|
||||
let db: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { DatabaseService } = await import('../database.service');
|
||||
dir = mkdtempSync(join(tmpdir(), 'metona-migtables-'));
|
||||
svc = new DatabaseService(dir);
|
||||
svc.initialize();
|
||||
db = svc.getDB();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
svc?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('messages 表包含 FTS 所需的全部列(content 可 NULL + attachments + reasoning_content)', () => {
|
||||
const cols = db.prepare('PRAGMA table_info(messages)').all() as Array<{
|
||||
name: string;
|
||||
notnull: number;
|
||||
}>;
|
||||
const names = cols.map((c) => c.name);
|
||||
for (const expected of [
|
||||
'id',
|
||||
'session_id',
|
||||
'role',
|
||||
'content',
|
||||
'reasoning_content',
|
||||
'tool_calls',
|
||||
'tool_result',
|
||||
'attachments',
|
||||
'iteration',
|
||||
'created_at',
|
||||
]) {
|
||||
expect(names).toContain(expected);
|
||||
}
|
||||
const contentCol = cols.find((c) => c.name === 'content');
|
||||
expect(contentCol!.notnull).toBe(0); // C-6: content 允许 NULL
|
||||
});
|
||||
|
||||
it('messages_fts 虚表使用 trigram tokenizer(迁移 9 目标 schema)', () => {
|
||||
const sqlRow = db
|
||||
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
|
||||
.get() as { sql: string };
|
||||
expect(sqlRow.sql).toContain('trigram');
|
||||
});
|
||||
|
||||
it('FTS 触发器存活:insert/delete/update 三个触发器', () => {
|
||||
const triggers = db
|
||||
.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name='messages' ORDER BY name`,
|
||||
)
|
||||
.all() as Array<{ name: string }>;
|
||||
const names = triggers.map((t) => t.name);
|
||||
expect(names).toContain('messages_fts_insert');
|
||||
expect(names).toContain('messages_fts_delete');
|
||||
expect(names).toContain('messages_fts_update');
|
||||
});
|
||||
|
||||
it('FTS 实时同步:插入消息后索引可见、删除后索引同步', () => {
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('mig_s1', 's', 1, 1)`,
|
||||
).run();
|
||||
db.prepare(
|
||||
`INSERT INTO messages (id, session_id, role, content, created_at) VALUES ('mig_m1', 'mig_s1', 'user', 'migftshit 内容', 1)`,
|
||||
).run();
|
||||
const hit = db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'migftsh'`)
|
||||
.get() as { c: number };
|
||||
expect(hit.c).toBe(1);
|
||||
db.prepare(`DELETE FROM messages WHERE id = 'mig_m1'`).run();
|
||||
const miss = db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'migftsh'`)
|
||||
.get() as { c: number };
|
||||
expect(miss.c).toBe(0);
|
||||
});
|
||||
|
||||
it('audit_logs 的 INSERT-ONLY 触发器存活(audit_no_update / audit_no_delete)', () => {
|
||||
const triggers = db
|
||||
.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name='audit_logs' ORDER BY name`,
|
||||
)
|
||||
.all() as Array<{ name: string }>;
|
||||
const names = triggers.map((t) => t.name);
|
||||
expect(names).toContain('audit_no_update');
|
||||
expect(names).toContain('audit_no_delete');
|
||||
// 更新被拒绝
|
||||
db.prepare(
|
||||
`INSERT INTO audit_logs (session_id, event_type, actor, target, created_at) VALUES ('s', 'error', 'agent', 't', 1)`,
|
||||
).run();
|
||||
expect(() =>
|
||||
db.prepare(`UPDATE audit_logs SET target = 'hacked' WHERE event_type = 'error'`).run(),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('episodic/semantic/working 记忆表均含 tf_cache 列(P2-12)', () => {
|
||||
for (const table of ['episodic_memories', 'semantic_memories', 'working_memories']) {
|
||||
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||
expect(cols.map((c) => c.name)).toContain('tf_cache');
|
||||
}
|
||||
});
|
||||
|
||||
it('session_summaries 外键为 ON DELETE CASCADE(迁移 8)', () => {
|
||||
const fks = db.prepare('PRAGMA foreign_key_list(session_summaries)').all() as Array<{
|
||||
table: string;
|
||||
on_delete: string;
|
||||
}>;
|
||||
expect(fks.some((fk) => fk.table === 'sessions' && fk.on_delete === 'CASCADE')).toBe(true);
|
||||
});
|
||||
|
||||
it('mcp_servers transport CHECK 含 streamable-http(迁移 6)', () => {
|
||||
const sqlRow = db
|
||||
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='mcp_servers'`)
|
||||
.get() as { sql: string };
|
||||
expect(sqlRow.sql).toContain('streamable-http');
|
||||
});
|
||||
|
||||
it('CONFIG_DEFAULTS 全部 seed 入库(每个默认 key 一行)', async () => {
|
||||
const { CONFIG_DEFAULTS } = await import('../database.service');
|
||||
const total = db.prepare('SELECT COUNT(*) AS c FROM app_config').get() as { c: number };
|
||||
expect(total.c).toBeGreaterThanOrEqual(CONFIG_DEFAULTS.length);
|
||||
// 抽查默认 key 存在
|
||||
const keys = db.prepare('SELECT key FROM app_config').all() as Array<{ key: string }>;
|
||||
const keySet = new Set(keys.map((k) => k.key));
|
||||
for (const d of CONFIG_DEFAULTS) {
|
||||
expect(keySet.has(d.key)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('索引覆盖常用查询路径(idx_messages_session 复合索引存在)', () => {
|
||||
const idx = db.prepare(`PRAGMA index_list(messages)`).all() as Array<{ name: string }>;
|
||||
expect(idx.map((i) => i.name)).toContain('idx_messages_session');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('DatabaseService — 多版本迁移矩阵(user_version 0/1/2)', () => {
|
||||
it('user_version=0 的旧库:初始化后完整迁移并盖章为 SCHEMA_VERSION', async () => {
|
||||
const { DatabaseService } = await import('../database.service');
|
||||
const dir = mkdtempSync(join(tmpdir(), 'metona-migv0-'));
|
||||
// 预置一个"旧版"库:只有消息表 + 非 trigram FTS(模拟迁移 9 之前)
|
||||
const legacy = openLegacyDb(dir);
|
||||
legacy.exec(`
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL,
|
||||
content TEXT NOT NULL, reasoning_content TEXT, tool_calls TEXT,
|
||||
tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE VIRTUAL TABLE messages_fts USING fts5(content, content=messages, content_rowid=rowid, tokenize='unicode61');
|
||||
`);
|
||||
legacy.pragma('user_version = 0');
|
||||
legacy.close();
|
||||
|
||||
const svc = new DatabaseService(dir);
|
||||
svc.initialize();
|
||||
const db = svc.getDB();
|
||||
expect(db.pragma('user_version', { simple: true })).toBe(DatabaseService.SCHEMA_VERSION);
|
||||
// 迁移 9:FTS 重建为 trigram
|
||||
const ftsSql = db
|
||||
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
|
||||
.get() as { sql: string };
|
||||
expect(ftsSql.sql).toContain('trigram');
|
||||
// 迁移 5:messages content 允许 NULL
|
||||
const cols = db.prepare('PRAGMA table_info(messages)').all() as Array<{
|
||||
notnull: number;
|
||||
name: string;
|
||||
}>;
|
||||
const contentCol = cols.find((c) => c.name === 'content');
|
||||
expect(contentCol!.notnull).toBe(0);
|
||||
svc.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('user_version=1 的库(迁移 9 之前盖章):重新初始化补齐 trigram', async () => {
|
||||
const { DatabaseService } = await import('../database.service');
|
||||
const dir = mkdtempSync(join(tmpdir(), 'metona-migv1-'));
|
||||
const legacy = openLegacyDb(dir);
|
||||
legacy.exec(`
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL,
|
||||
content TEXT NOT NULL, reasoning_content TEXT, tool_calls TEXT,
|
||||
tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE VIRTUAL TABLE messages_fts USING fts5(content, content=messages, content_rowid=rowid, tokenize='unicode61');
|
||||
`);
|
||||
legacy.pragma('user_version = 1'); // 旧版本盖章但缺迁移 9
|
||||
legacy.close();
|
||||
|
||||
const svc = new DatabaseService(dir);
|
||||
svc.initialize();
|
||||
const db = svc.getDB();
|
||||
expect(db.pragma('user_version', { simple: true })).toBe(DatabaseService.SCHEMA_VERSION);
|
||||
const ftsSql = db
|
||||
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
|
||||
.get() as { sql: string };
|
||||
expect(ftsSql.sql).toContain('trigram');
|
||||
svc.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('user_version=2 的库:快速路径跳过迁移,版本号保持不变', async () => {
|
||||
const { DatabaseService } = await import('../database.service');
|
||||
const dir = mkdtempSync(join(tmpdir(), 'metona-migv2-'));
|
||||
const legacy = openLegacyDb(dir);
|
||||
legacy.exec(`
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL,
|
||||
content TEXT, created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE VIRTUAL TABLE messages_fts USING fts5(content, content=messages, content_rowid=rowid, tokenize='trigram');
|
||||
`);
|
||||
legacy.pragma('user_version = 2');
|
||||
legacy.close();
|
||||
|
||||
const svc = new DatabaseService(dir);
|
||||
svc.initialize();
|
||||
const db = svc.getDB();
|
||||
expect(db.pragma('user_version', { simple: true })).toBe(2);
|
||||
// 快速路径不重建 FTS(保持原有 trigram 表)
|
||||
const ftsSql = db
|
||||
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
|
||||
.get() as { sql: string };
|
||||
expect(ftsSql.sql).toContain('trigram');
|
||||
svc.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('迁移 9 幂等:重复 initialize 不重复重建(SQL 始终为 trigram 单份定义)', async () => {
|
||||
const { DatabaseService } = await import('../database.service');
|
||||
const dir = mkdtempSync(join(tmpdir(), 'metona-migidem-'));
|
||||
const svc = new DatabaseService(dir);
|
||||
svc.initialize();
|
||||
svc.close();
|
||||
const svc2 = new DatabaseService(dir);
|
||||
svc2.initialize();
|
||||
const db = svc2.getDB();
|
||||
const ftsRows = db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
|
||||
.get() as { c: number };
|
||||
expect(ftsRows.c).toBe(1);
|
||||
const sqlRow = db
|
||||
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
|
||||
.get() as { sql: string };
|
||||
expect(sqlRow.sql).toContain('trigram');
|
||||
svc2.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('迁移 7 FTS 回填:已有消息在 initialize 后进入索引(rebuild 语义)', async () => {
|
||||
const { DatabaseService } = await import('../database.service');
|
||||
const dir = mkdtempSync(join(tmpdir(), 'metona-migbackfill-'));
|
||||
const legacy = openLegacyDb(dir);
|
||||
// 旧库:messages 有数据但 messages_fts 空(无回填)
|
||||
legacy.exec(`
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL,
|
||||
content TEXT NOT NULL, reasoning_content TEXT, tool_calls TEXT,
|
||||
tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY, title TEXT DEFAULT '新会话', created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL, message_count INTEGER DEFAULT 0,
|
||||
pinned INTEGER DEFAULT 0, archived INTEGER DEFAULT 0, metadata TEXT DEFAULT '{}'
|
||||
);
|
||||
CREATE VIRTUAL TABLE messages_fts USING fts5(content, content=messages, content_rowid=rowid, tokenize='unicode61');
|
||||
`);
|
||||
legacy.prepare(`INSERT INTO sessions (id, created_at, updated_at) VALUES ('s1', 1, 1)`).run();
|
||||
legacy
|
||||
.prepare(
|
||||
`INSERT INTO messages (id, session_id, role, content, created_at) VALUES ('m1', 's1', 'user', 'backfillkeyword 数据', 1)`,
|
||||
)
|
||||
.run();
|
||||
legacy.close();
|
||||
|
||||
const svc = new DatabaseService(dir);
|
||||
svc.initialize();
|
||||
const db = svc.getDB();
|
||||
// 迁移 9 重建 + rebuild 后,存量消息应可被搜索
|
||||
const hit = db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'backfillkeyw'`)
|
||||
.get() as { c: number };
|
||||
expect(hit.c).toBe(1);
|
||||
svc.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('DatabaseService — 种子配置与连接语义', () => {
|
||||
let dir: string;
|
||||
let svc: DatabaseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { DatabaseService } = await import('../database.service');
|
||||
dir = mkdtempSync(join(tmpdir(), 'metona-migseed-'));
|
||||
svc = new DatabaseService(dir);
|
||||
svc.initialize();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
svc?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('敏感默认 key 以明文空串 seed(新用户未配置),category 正确', () => {
|
||||
const row = svc
|
||||
.getDB()
|
||||
.prepare(`SELECT value, category FROM app_config WHERE key = 'llm.apiKey'`)
|
||||
.get() as { value: string; category: string };
|
||||
expect(JSON.parse(row.value)).toBe('');
|
||||
expect(row.category).toBe('llm');
|
||||
});
|
||||
|
||||
it('数字/布尔默认值 seed 为 JSON 类型', () => {
|
||||
const row = svc
|
||||
.getDB()
|
||||
.prepare(`SELECT value FROM app_config WHERE key = 'agent.maxIterations'`)
|
||||
.get() as { value: string };
|
||||
expect(JSON.parse(row.value)).toBe(20);
|
||||
});
|
||||
|
||||
it('WAL 模式已开启(journal_mode=wal)', () => {
|
||||
const mode = svc.getDB().pragma('journal_mode', { simple: true }) as string;
|
||||
expect(mode).toBe('wal');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user