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:
@@ -376,12 +376,16 @@ describe('data:clear* — 危险操作事务语义', () => {
|
||||
|
||||
it('clearAuditLogs:先删防篡改触发器再清表,最后重建触发器(INSERT-ONLY 契约)', async () => {
|
||||
const { prepare, exec } = makeDbMock();
|
||||
const resetChainCache = vi.fn();
|
||||
registerDataHandlers({
|
||||
sessionService: { getDB: () => ({ prepare, exec }) },
|
||||
// v0.7.4 P1-5: 清空后重置链式哈希缓存(防 verifyChain 误报 TAMPERED)
|
||||
auditService: { resetChainCache },
|
||||
} as unknown as IPCContext);
|
||||
|
||||
const result = (await getHandler('data:clearAuditLogs')(null)) as { success: boolean };
|
||||
expect(result.success).toBe(true);
|
||||
expect(resetChainCache).toHaveBeenCalled();
|
||||
const execCalls = exec.mock.calls.map((c) => c[0] as string);
|
||||
expect(execCalls[0]).toBe('BEGIN');
|
||||
expect(execCalls.some((c) => c.includes('DROP TRIGGER IF EXISTS audit_no_delete'))).toBe(true);
|
||||
@@ -390,4 +394,477 @@ describe('data:clear* — 危险操作事务语义', () => {
|
||||
);
|
||||
expect(execCalls[execCalls.length - 1]).toBe('COMMIT');
|
||||
});
|
||||
|
||||
it('clearMemories 返回三张表各自的删除计数', async () => {
|
||||
const { prepare, exec } = makeDbMock();
|
||||
registerDataHandlers({
|
||||
sessionService: { getDB: () => ({ prepare, exec }) },
|
||||
} as unknown as IPCContext);
|
||||
|
||||
const result = (await getHandler('data:clearMemories')(null)) as {
|
||||
deletedEpisodic: number;
|
||||
deletedSemantic: number;
|
||||
deletedWorking: number;
|
||||
};
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
deletedEpisodic: 2,
|
||||
deletedSemantic: 2,
|
||||
deletedWorking: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('clearMemories 中途失败 → ROLLBACK(三表一致性)', async () => {
|
||||
const prepare = vi.fn(() => ({
|
||||
run: vi.fn(),
|
||||
get: vi.fn(() => ({ c: 1 })),
|
||||
all: vi.fn(() => []),
|
||||
}));
|
||||
const exec = vi.fn((sql: string) => {
|
||||
if (sql.includes('DELETE FROM working_memories')) {
|
||||
throw new Error('db error');
|
||||
}
|
||||
});
|
||||
registerDataHandlers({
|
||||
sessionService: { getDB: () => ({ prepare, exec }) },
|
||||
} as unknown as IPCContext);
|
||||
|
||||
const result = (await getHandler('data:clearMemories')(null)) as { success: boolean };
|
||||
expect(result.success).toBe(false);
|
||||
const execCalls = exec.mock.calls.map((c) => c[0] as string);
|
||||
expect(execCalls).toContain('ROLLBACK');
|
||||
});
|
||||
|
||||
it('clearAuditLogs 失败 → ROLLBACK 且不重置链缓存', async () => {
|
||||
const prepare = vi.fn(() => ({
|
||||
run: vi.fn(),
|
||||
get: vi.fn(() => ({ c: 1 })),
|
||||
all: vi.fn(() => []),
|
||||
}));
|
||||
const exec = vi.fn((sql: string) => {
|
||||
if (sql.includes('DELETE FROM audit_logs')) throw new Error('locked');
|
||||
});
|
||||
const resetChainCache = vi.fn();
|
||||
registerDataHandlers({
|
||||
sessionService: { getDB: () => ({ prepare, exec }) },
|
||||
auditService: { resetChainCache },
|
||||
} as unknown as IPCContext);
|
||||
|
||||
const result = (await getHandler('data:clearAuditLogs')(null)) as { success: boolean };
|
||||
expect(result.success).toBe(false);
|
||||
expect(resetChainCache).not.toHaveBeenCalled();
|
||||
const execCalls = exec.mock.calls.map((c) => c[0] as string);
|
||||
expect(execCalls).toContain('ROLLBACK');
|
||||
});
|
||||
|
||||
it('clearSessions 删除计数准确(session/message 各行)', async () => {
|
||||
const prepare = vi.fn((sql: string) => ({
|
||||
run: vi.fn(),
|
||||
get: vi.fn(() => (sql.includes('FROM messages') ? { c: 5 } : { c: 3 })),
|
||||
all: vi.fn(() => []),
|
||||
}));
|
||||
const exec = vi.fn();
|
||||
registerDataHandlers({
|
||||
sessionService: { getDB: () => ({ prepare, exec }) },
|
||||
} as unknown as IPCContext);
|
||||
|
||||
const result = (await getHandler('data:clearSessions')(null)) as {
|
||||
deletedSessions: number;
|
||||
deletedMessages: number;
|
||||
};
|
||||
expect(result).toEqual({ success: true, deletedSessions: 3, deletedMessages: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 追加:app 域补充 =====
|
||||
|
||||
describe('app:openExternal — 协议白名单扩展(M-12)', () => {
|
||||
function makeCtx(): IPCContext {
|
||||
return { configService: { get: vi.fn(() => null) } } as unknown as IPCContext;
|
||||
}
|
||||
|
||||
it.each([
|
||||
'chrome://settings',
|
||||
'about:blank',
|
||||
'ws://host',
|
||||
'ftp://host',
|
||||
'file:///etc',
|
||||
'smb://x',
|
||||
'javascript:void(0)',
|
||||
])('%s → 协议不在白名单拒绝', async (url) => {
|
||||
registerAppHandlers(makeCtx());
|
||||
const result = (await getHandler('app:openExternal')(null, url)) as { success: boolean };
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('大小写协议(HTTPS)→ 解析为 https: 放行', async () => {
|
||||
registerAppHandlers(makeCtx());
|
||||
expect(await getHandler('app:openExternal')(null, 'HTTPS://example.com')).toMatchObject({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('shell.openExternal 抛错 → 返回失败', async () => {
|
||||
const shellMock = (await import('electron')).shell as unknown as {
|
||||
openExternal: Mock;
|
||||
};
|
||||
shellMock.openExternal.mockRejectedValueOnce(new Error('no browser'));
|
||||
registerAppHandlers(makeCtx());
|
||||
const result = (await getHandler('app:openExternal')(null, 'https://example.com')) as {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('no browser');
|
||||
});
|
||||
|
||||
it('app:showItemInFolder 校验 path 类型', async () => {
|
||||
registerAppHandlers(makeCtx());
|
||||
expect(await getHandler('app:showItemInFolder')(null, '')).toMatchObject({ success: false });
|
||||
expect(await getHandler('app:showItemInFolder')(null, 42)).toMatchObject({ success: false });
|
||||
expect(await getHandler('app:showItemInFolder')(null, '/tmp/x')).toMatchObject({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('app:getVersion / app:getAppDataPath 透传', async () => {
|
||||
registerAppHandlers(makeCtx());
|
||||
expect(await getHandler('app:getVersion')(null)).toBe('0.7.2');
|
||||
expect(await getHandler('app:getAppDataPath')(null)).toBe('/tmp/userdata');
|
||||
});
|
||||
});
|
||||
|
||||
describe('searxng:testConnection — 高危 URL 拒绝(v0.7.4 P2-9)', () => {
|
||||
function makeCtx(): IPCContext {
|
||||
return { configService: { get: vi.fn(() => null) } } as unknown as IPCContext;
|
||||
}
|
||||
|
||||
it('云元数据 169.254.169.254 拒绝', async () => {
|
||||
registerAppHandlers(makeCtx());
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
const result = (await getHandler('searxng:testConnection')(
|
||||
null,
|
||||
'http://169.254.169.254/latest/meta-data',
|
||||
'',
|
||||
'',
|
||||
)) as { success: boolean; error?: string };
|
||||
expect(result.success).toBe(false);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('metadata.google.internal 拒绝', async () => {
|
||||
registerAppHandlers(makeCtx());
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
const result = (await getHandler('searxng:testConnection')(
|
||||
null,
|
||||
'http://metadata.google.internal/',
|
||||
'',
|
||||
'',
|
||||
)) as { success: boolean };
|
||||
expect(result.success).toBe(false);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('本地回环 SearXNG 实例放行(自建实例合法)', async () => {
|
||||
registerAppHandlers(makeCtx());
|
||||
const fetchSpy = vi.fn(async (_url: string, _init: { headers: Record<string, string> }) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
const result = (await getHandler('searxng:testConnection')(
|
||||
null,
|
||||
'http://127.0.0.1:8080',
|
||||
'',
|
||||
'',
|
||||
)) as { success: boolean; statusCode?: number };
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.statusCode).toBe(200);
|
||||
// 测试 URL 拼接 /search?q=test
|
||||
expect(fetchSpy.mock.calls[0][0]).toContain('/search?q=test');
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('非 2xx 响应 → 返回 statusCode + HTTP 错误信息', async () => {
|
||||
registerAppHandlers(makeCtx());
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({ ok: false, status: 500, statusText: 'Internal Server Error' })),
|
||||
);
|
||||
const result = (await getHandler('searxng:testConnection')(
|
||||
null,
|
||||
'https://sx.local',
|
||||
'',
|
||||
'',
|
||||
)) as { success: boolean; statusCode?: number; error?: string };
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.statusCode).toBe(500);
|
||||
expect(result.error).toContain('HTTP 500');
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('无认证信息时不携带 Authorization 头', async () => {
|
||||
registerAppHandlers(makeCtx());
|
||||
const fetchSpy = vi.fn(async (_url: string, _init: { headers: Record<string, string> }) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
await getHandler('searxng:testConnection')(null, 'https://sx.local', '', '');
|
||||
const headers = (fetchSpy.mock.calls[0][1] as { headers: Record<string, string> }).headers;
|
||||
expect(headers).toEqual({});
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('Bearer 认证 → Authorization: Bearer <key>', async () => {
|
||||
registerAppHandlers(makeCtx());
|
||||
const fetchSpy = vi.fn(async (_url: string, _init: { headers: Record<string, string> }) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
await getHandler('searxng:testConnection')(null, 'https://sx.local', 'tok-abc', 'bearer');
|
||||
const headers = (fetchSpy.mock.calls[0][1] as { headers: Record<string, string> }).headers;
|
||||
expect(headers['Authorization']).toBe('Bearer tok-abc');
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('未知 authType → 不附加认证头(仅接受 bearer/basic)', async () => {
|
||||
registerAppHandlers(makeCtx());
|
||||
const fetchSpy = vi.fn(async (_url: string, _init: { headers: Record<string, string> }) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
await getHandler('searxng:testConnection')(null, 'https://sx.local', 'secret', 'oauth');
|
||||
const headers = (fetchSpy.mock.calls[0][1] as { headers: Record<string, string> }).headers;
|
||||
expect(headers['Authorization']).toBeUndefined();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error:report — 截断与审计补充', () => {
|
||||
it('type 字段截断到 100 字符', () => {
|
||||
const log = vi.fn();
|
||||
registerAppHandlers({ auditService: { log } } as unknown as IPCContext);
|
||||
const listener = getListener('error:report');
|
||||
|
||||
listener(null, { type: 'T'.repeat(300), error: 'e', stack: '', timestamp: Date.now() });
|
||||
const entry = log.mock.calls[0][0] as { details: { type: string } };
|
||||
expect(entry.details.type.length).toBe(100);
|
||||
});
|
||||
|
||||
it('日志处理自身抛错不向外抛出(单向通道安全)', () => {
|
||||
const log = vi.fn(() => {
|
||||
throw new Error('audit db down');
|
||||
});
|
||||
registerAppHandlers({ auditService: { log } } as unknown as IPCContext);
|
||||
const listener = getListener('error:report');
|
||||
expect(() => listener(null, { type: 'x', error: 'e' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('数组载荷通过 typeof 守卫被记录(仅 null/原始类型被忽略;数组字段为 undefined)', () => {
|
||||
const log = vi.fn();
|
||||
registerAppHandlers({ auditService: { log } } as unknown as IPCContext);
|
||||
const listener = getListener('error:report');
|
||||
listener(null, [1, 2, 3]);
|
||||
// 源码守卫 `typeof payload !== 'object'` 对数组放行 —— 数组被当作对象记录(字段 undefined)
|
||||
expect(log).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('audit 域 — 补充校验', () => {
|
||||
function makeCtx(): { ctx: IPCContext; query: Mock } {
|
||||
const query = vi.fn(() => []);
|
||||
return {
|
||||
ctx: {
|
||||
auditService: {
|
||||
query,
|
||||
log: vi.fn(),
|
||||
verifyChain: vi.fn(() => ({
|
||||
valid: true,
|
||||
totalRecords: 0,
|
||||
verifiedRecords: 0,
|
||||
tamperedId: null,
|
||||
})),
|
||||
},
|
||||
} as unknown as IPCContext,
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
it('audit:query sessionId 过滤透传;空 sessionId 被忽略', async () => {
|
||||
const { ctx, query } = makeCtx();
|
||||
registerAppHandlers(ctx);
|
||||
const handler = getHandler('audit:query');
|
||||
|
||||
await handler(null, { sessionId: 's1', limit: 10 });
|
||||
expect(query).toHaveBeenCalledWith({ sessionId: 's1', limit: 10 });
|
||||
await handler(null, { sessionId: '' });
|
||||
expect(query).toHaveBeenLastCalledWith({});
|
||||
});
|
||||
|
||||
it('audit:query 合法 eventType 白名单透传', async () => {
|
||||
const { ctx, query } = makeCtx();
|
||||
registerAppHandlers(ctx);
|
||||
await getHandler('audit:query')(null, { eventType: 'tool_call' });
|
||||
expect(query).toHaveBeenCalledWith({ eventType: 'tool_call' });
|
||||
});
|
||||
|
||||
it('audit:export 默认 jsonl;csv 显式选择', async () => {
|
||||
const exportJSONL = vi.fn(() => '{"a":1}\n{"b":2}\n');
|
||||
const exportCSV = vi.fn(() => 'col\nval\n');
|
||||
registerAppHandlers({
|
||||
auditService: { exportJSONL, exportCSV, log: vi.fn() },
|
||||
} as unknown as IPCContext);
|
||||
const handler = getHandler('audit:export');
|
||||
|
||||
const jsonl = (await handler(null)) as { format: string; recordCount: number };
|
||||
expect(jsonl.format).toBe('jsonl');
|
||||
expect(jsonl.recordCount).toBe(2);
|
||||
const csv = (await handler(null, 'csv')) as { format: string; recordCount: number };
|
||||
expect(csv.format).toBe('csv');
|
||||
expect(csv.recordCount).toBe(1); // 表头不计入
|
||||
});
|
||||
|
||||
it('audit:export 任意 format 回退 jsonl(非 csv 一律 jsonl)', async () => {
|
||||
const exportJSONL = vi.fn(() => 'x\n');
|
||||
registerAppHandlers({
|
||||
auditService: { exportJSONL, exportCSV: vi.fn(), log: vi.fn() },
|
||||
} as unknown as IPCContext);
|
||||
const r = (await getHandler('audit:export')(null, 'xml')) as { format: string };
|
||||
expect(r.format).toBe('jsonl');
|
||||
});
|
||||
|
||||
it('audit:export 空内容 → recordCount 0', async () => {
|
||||
registerAppHandlers({
|
||||
auditService: { exportJSONL: vi.fn(() => ''), exportCSV: vi.fn(() => ''), log: vi.fn() },
|
||||
} as unknown as IPCContext);
|
||||
const r = (await getHandler('audit:export')(null)) as { recordCount: number };
|
||||
expect(r.recordCount).toBe(0);
|
||||
});
|
||||
|
||||
it('audit:verifyChain 失败路径返回错误', async () => {
|
||||
registerAppHandlers({
|
||||
auditService: {
|
||||
verifyChain: vi.fn(() => {
|
||||
throw new Error('chain corrupted');
|
||||
}),
|
||||
log: vi.fn(),
|
||||
},
|
||||
} as unknown as IPCContext);
|
||||
const r = (await getHandler('audit:verifyChain')(null)) as { success: boolean; error?: string };
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toBe('chain corrupted');
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 追加:data:export 脱敏 =====
|
||||
|
||||
describe('data:export — 补充脱敏', () => {
|
||||
function makeCtx(messages: unknown[], sessions?: unknown[]) {
|
||||
const sessionService = {
|
||||
getMessages: vi.fn(() => messages),
|
||||
list: vi.fn(() => sessions ?? []),
|
||||
};
|
||||
const configService = {
|
||||
getAll: vi.fn(() => ({ 'llm.apiKey': 'sk-raw-1234', 'ui.theme': 'dark' })),
|
||||
};
|
||||
return { ctx: { sessionService, configService } as unknown as IPCContext, sessionService };
|
||||
}
|
||||
|
||||
it('全量导出:非工具消息原样保留(无 dataUrl 不动)', async () => {
|
||||
const msgs = [
|
||||
{ id: 'm1', role: 'user', content: 'hello', timestamp: Date.now() },
|
||||
{ id: 'm2', role: 'assistant', content: 'hi back', timestamp: Date.now() },
|
||||
];
|
||||
const { ctx } = makeCtx(msgs, [{ id: 's1', title: 't' }]);
|
||||
registerDataHandlers(ctx);
|
||||
|
||||
const result = (await getHandler('data:export')(null, undefined)) as {
|
||||
data: { sessions: Array<{ messages: unknown[]; truncated: boolean }> };
|
||||
};
|
||||
const session = result.data.sessions[0];
|
||||
expect(session.messages).toEqual(msgs);
|
||||
expect(session.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('全量导出:条数达到上限时标记 truncated=true(防 Blob 序列化 OOM)', async () => {
|
||||
const { ctx, sessionService } = makeCtx([], [{ id: 's1', title: 't' }]);
|
||||
// 构造恰好 2000 条消息(达到上限 → truncated=true)
|
||||
const many = Array.from({ length: 2000 }, (_, i) => ({
|
||||
id: `m${i}`,
|
||||
role: 'user',
|
||||
content: 'x',
|
||||
}));
|
||||
sessionService.getMessages.mockImplementation(() => many);
|
||||
registerDataHandlers(ctx);
|
||||
|
||||
const result = (await getHandler('data:export')(null, undefined)) as {
|
||||
data: { sessions: Array<{ truncated: boolean }> };
|
||||
};
|
||||
expect(result.data.sessions[0].truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('全量导出配置脱敏不包含明文 API Key 子串', async () => {
|
||||
const { ctx } = makeCtx([], [{ id: 's1', title: 't' }]);
|
||||
registerDataHandlers(ctx);
|
||||
const result = (await getHandler('data:export')(null, undefined)) as {
|
||||
data: { config: Record<string, unknown> };
|
||||
};
|
||||
expect(JSON.stringify(result.data.config)).not.toContain('sk-raw-1234');
|
||||
});
|
||||
|
||||
it('单会话导出不返回 truncated 标记(仅全量导出带)', async () => {
|
||||
const msgs = [{ id: 'm1', role: 'user', content: 'x' }];
|
||||
const { ctx } = makeCtx(msgs);
|
||||
registerDataHandlers(ctx);
|
||||
const result = (await getHandler('data:export')(null, 's1')) as {
|
||||
success: boolean;
|
||||
data: unknown[];
|
||||
};
|
||||
expect(result.success).toBe(true);
|
||||
expect(Array.isArray(result.data)).toBe(true);
|
||||
});
|
||||
|
||||
it('toolResult.result 为字符串时不剥离(dataUrl 剥离仅针对对象)', async () => {
|
||||
const msgs = [
|
||||
{
|
||||
id: 'm1',
|
||||
role: 'tool',
|
||||
toolResult: { toolCallId: 'tc_1', result: 'plain string result' },
|
||||
},
|
||||
];
|
||||
const { ctx } = makeCtx(msgs);
|
||||
registerDataHandlers(ctx);
|
||||
const result = (await getHandler('data:export')(null, 's1')) as {
|
||||
data: Array<{ toolResult: { result: string } }>;
|
||||
};
|
||||
expect(result.data[0].toolResult.result).toBe('plain string result');
|
||||
});
|
||||
|
||||
it('toolResult.result 为数组(非对象)不剥离', async () => {
|
||||
const msgs = [
|
||||
{
|
||||
id: 'm1',
|
||||
role: 'tool',
|
||||
toolResult: { toolCallId: 'tc_1', result: [{ path: 'a.png', dataUrl: 'x' }] },
|
||||
},
|
||||
];
|
||||
const { ctx } = makeCtx(msgs);
|
||||
registerDataHandlers(ctx);
|
||||
const result = (await getHandler('data:export')(null, 's1')) as {
|
||||
data: Array<{ toolResult: { result: Array<{ dataUrl: string }> } }>;
|
||||
};
|
||||
expect(result.data[0].toolResult.result).toHaveLength(1);
|
||||
expect(result.data[0].toolResult.result[0].dataUrl).toBe('x');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user