Files
metona-ai-desktop/electron/services/__tests__/session-search.test.ts
T
thzxx 7e8b4882a0
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m38s
CI / 产物编译验证 (push) Successful in 10m15s
CI / 全量测试 (Electron ABI) (push) Failing after 5m27s
feat: v0.5.0 审计修复版 — 类型基线重建 + 会话隔离 + SubAgent 可观测性 + 三项功能补全
P0 安全与工程基线(止血):
- .npmrc 移除硬编码 Gitea npm 凭据,改为 GITEA_NPM_AUTH 环境变量注入(已验证未设变量时 401)
- 修复 typecheck 空操作缺陷:solution-style 根 tsconfig 改为双工程真检查(node + web),
  pre-commit 与 CI 门禁恢复拦截能力
- 修复 4 处 v0.4.1 遗留类型错误:confirmation-hook.test 枚举名 FILE_SYSTEM→FILESYSTEM、
  agent.ts VALIDATION 事件 severity 类型谓词收窄、ContextMenu.tsx 导出 attachments 类型
- 补装 v0.4.1 声明但未安装的 node-html-parser 依赖

P1 逻辑缺陷修复(跨模块边界):
- ConfirmationHook 会话隔离:rememberedDecisions 与 pendingConfirmations 按 sessionId 隔离,
  abortSession 只清本会话 pending(修复 A 会话中断误杀 B 会话确认、拒绝记忆跨会话污染)
- SubAgent 可观测性:orchestrator 六个事件此前全项目零消费者,现接入
  ① subagent:event 生命周期广播(AgentMonitor 新增 SubAgent 状态区)
  ② SubEngine 流事件独立 TRACE 录制(sessionId=taskId 的 JSONL 文件)
- main.ts 启动链路异常兜底:初始化失败时记录日志 + 系统错误对话框 + 退出(原为白屏挂起)

P2 工程强化:
- CI:typecheck 双工程真检查;electron-test 从 experimental(continue-on-error)转正为阻塞门禁;
  GITEA_NPM_AUTH secret 注入说明
- 渲染 bundle 代码分割:单 2630KB chunk 拆为 main 557KB + vendor-react/mui/markdown/icons
  (业务代码变更不再使 vendor 缓存失效)
- database 建表 mcp_servers CHECK 直接含 streamable-http(新库不再依赖迁移 6 立即重建)

P3 功能补全:
- DeepSeek 余额显示:新增 llm:getBalance IPC + LLMSettings 余额卡片(复用适配器原死代码 getBalance)
- FTS5 会话内容搜索:messages_fts 虚表 + INSERT/UPDATE/DELETE 触发器实时同步 +
  存量库 rebuild 迁移 + sessions:searchContent IPC + Sidebar 搜索框标题∪内容联合搜索
  (短语转义防 FTS 运算符注入,按会话聚合展示 snippet)
- 审计日志导出:audit:export IPC(JSONL / CSV RFC 4180 转义)+ LogsSettings 导出按钮

文档一致性大扫除:
- README:工具数统一为 28(原 26/27/30 三口径)、handlers.ts→ipc/、录制事件名更正、
  删除虚构的审计导出/归档宣称与 Schema 虚构字段、MCP 三种传输、配置 key 更正、
  项目结构树对齐实际(settings 10 文件/lib 6 文件/react-virtuoso)、clone 地址改为 Gitea、
  新增 GITEA_NPM_AUTH 配置说明、测试数 207
- 架构/构建指南/UI UX/IR 标准 4 份 HTML 设计文档同步修正(工具数、表数 10、
  磁盘文件 2 个现状注记、ipc/*.ts 路径)
- eslint.config.js 与开发规范.md 注释对齐零容忍基线与 better-sqlite3 选型

测试: 199→207 用例(新增 ConfirmationHook 跨会话隔离 5 用例 + FTS5 搜索/审计导出 8 用例)
验证: lint 0 problems / typecheck 双工程 0 errors / test:electron 207 全过 / build 成功
2026-08-21 21:07:01 +08:00

271 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* FTS5 会话内容搜索 + 审计日志导出 回归测试(v0.5.0)
*
* 覆盖:
* 1. messages_fts 虚表创建与触发器实时同步(INSERT / DELETE
* 2. searchMessages:短语命中、跨会话聚合、FTS 运算符输入安全、snippet 生成
* 3. 审计导出:JSONL 行格式、CSV RFC 4180 转义(逗号/引号/换行)
*
* 运行要求:better-sqlite3 为 Electron ABI 构建,需 test:electron 模式
* cross-env ELECTRON_RUN_AS_NODE=1 electron ...)执行;系统 Node 下自动跳过。
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
let dbAvailable = true;
let Database: typeof import('better-sqlite3');
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
Database = require('better-sqlite3');
const probe = new Database(':memory:');
probe.close();
} catch {
dbAvailable = false;
}
describe.skipIf(!dbAvailable)('FTS5 会话内容搜索', () => {
let db: any;
let dir: string;
let sessionService: any;
beforeAll(async () => {
const { SessionService } = await import('../session.service');
dir = mkdtempSync(join(tmpdir(), 'metona-search-'));
db = new Database(':memory:');
// 与 DatabaseService.createTables 一致的最小 schema(含 FTS 虚表 + 触发器)
db.exec(`
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 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
);
CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
END;
CREATE TRIGGER messages_fts_delete AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
END;
`);
sessionService = new SessionService(() => db);
});
afterAll(() => {
try {
db?.close();
} catch {
/* ignore */
}
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* ignore */
}
});
const insertMessage = (
id: string,
sessionId: string,
role: string,
content: string | null,
ts: number,
): void => {
db.prepare(
`INSERT INTO messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`,
).run(id, sessionId, role, content, ts);
};
it('FTS5 可用且插入消息实时进入索引', () => {
// FTS5 编译可用性(better-sqlite3 预编译含 FTS5,此处防御性验证)
insertMessage('m1', 's1', 'user', 'Hello FTS5 world', 1000);
const rows = db
.prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'hello'`)
.all() as Array<{ c: number }>;
expect(rows[0].c).toBe(1);
});
it('searchMessages 按会话聚合返回(最新匹配 + 匹配计数 + snippet', () => {
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('s1', '会话一', 1, 1)`,
).run();
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('s2', '会话二', 1, 1)`,
).run();
insertMessage('m2', 's1', 'assistant', 'uniquekeyword 出现了一次', 2000);
insertMessage('m3', 's2', 'user', 'uniquekeyword 在另一个会话', 3000);
insertMessage('m4', 's2', 'assistant', 'uniquekeyword 再次出现', 4000);
const results = sessionService.searchMessages('uniquekeyword');
expect(results).toHaveLength(2);
// s2 的最新匹配(m4)先返回(按时间倒序聚合)
expect(results[0].sessionId).toBe('s2');
expect(results[0].matchCount).toBe(2);
expect(results[0].lastMessageId).toBe('m4');
expect(results[0].sessionTitle).toBe('会话二');
expect(results[0].snippet).toContain('uniquekeyword');
// s1 单条匹配
expect(results[1].sessionId).toBe('s1');
expect(results[1].matchCount).toBe(1);
});
it('删除消息后索引同步(搜索不再命中)', () => {
db.prepare(`DELETE FROM messages WHERE id = 'm2'`).run();
const results = sessionService.searchMessages('uniquekeyword');
expect(results).toHaveLength(1);
expect(results[0].sessionId).toBe('s2');
});
it('FTS 运算符/引号输入安全(不抛错,按短语匹配)', () => {
insertMessage('m5', 's1', 'user', '包含 AND OR NOT 与 "引号" 的消息', 5000);
// 含 FTS 保留字与引号的查询不抛异常
expect(() => sessionService.searchMessages('AND OR NOT')).not.toThrow();
expect(() => sessionService.searchMessages('x"y')).not.toThrow();
// 引号内容作为短语可命中
const hit = sessionService.searchMessages('引号');
expect(hit.some((r: any) => r.lastMessageId === 'm5')).toBe(true);
});
it('空查询与无匹配返回空数组', () => {
expect(sessionService.searchMessages('')).toHaveLength(0);
expect(sessionService.searchMessages(' ')).toHaveLength(0);
expect(sessionService.searchMessages('不存在的关键词xyz')).toHaveLength(0);
});
it('NULL content 消息不进索引(assistant 仅有 tool_calls 场景)', () => {
insertMessage('m6', 's1', 'assistant', null, 6000);
const rows = db
.prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'nulltest'`)
.all() as Array<{ c: number }>;
expect(rows[0].c).toBe(0);
// FTS 行数与 messages 行数一致(NULL 行在 FTS 中占位但不索引内容)
const fts = (db.prepare('SELECT COUNT(*) AS c FROM messages_fts').get() as { c: number }).c;
const msg = (db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number }).c;
expect(fts).toBe(msg);
});
});
describe.skipIf(!dbAvailable)('审计日志导出(v0.5.0', () => {
let db: any;
let dir: string;
let auditService: any;
beforeAll(async () => {
const { AuditService } = await import('../audit.service');
dir = mkdtempSync(join(tmpdir(), 'metona-audit-export-'));
db = new Database(':memory:');
db.exec(`
CREATE TABLE 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
);
`);
auditService = new AuditService(() => db);
});
afterAll(() => {
try {
db?.close();
} catch {
/* ignore */
}
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* ignore */
}
});
it('exportJSONL 每行一条 JSON 记录(含链式哈希字段)', () => {
auditService.log({
sessionId: 's1',
eventType: 'tool_call',
actor: 'agent',
target: 'run_command',
details: { command: 'ls' },
outcome: 'success',
durationMs: 10,
});
auditService.log({
sessionId: 's1',
eventType: 'session_end',
actor: 'agent',
target: 'session',
outcome: 'success',
});
const jsonl = auditService.exportJSONL();
const lines = jsonl.trim().split('\n');
expect(lines).toHaveLength(2);
const first = JSON.parse(lines[0]);
expect(first.event_type).toBe('tool_call');
expect(first.session_id).toBe('s1');
expect(first.current_hash).toMatch(/^[0-9a-f]{64}$/);
// 第二条 prev_hash 等于第一条 current_hash(链完整保留在导出中)
const second = JSON.parse(lines[1]);
expect(second.prev_hash).toBe(first.current_hash);
});
it('exportCSV 含表头且 RFC 4180 转义(逗号/引号/换行字段)', () => {
auditService.log({
sessionId: 's2',
eventType: 'error',
actor: 'agent',
target: 'target,with,commas',
details: { msg: '含"双引号"和\n换行' },
outcome: 'error',
});
const csv = auditService.exportCSV();
const lines = csv.split('\n');
// 表头
expect(lines[0]).toBe(
'id,session_id,iteration,event_type,actor,target,details,outcome,duration_ms,created_at,prev_hash,current_hash',
);
// 含特殊字符的 target 字段被双引号包裹
expect(csv).toContain('"target,with,commas"');
// details 存储为 JSON 字符串(内部双引号为 \" 转义),CSV 再转义后为 \""(反斜杠保留 + 引号翻倍)
expect(csv).toContain('\\""双引号\\""');
// 记录数与 JSONL 一致
const parsed = auditService
.exportJSONL()
.trim()
.split('\n')
.map((l: string) => JSON.parse(l));
expect(parsed.length).toBeGreaterThanOrEqual(3);
});
});