feat: v0.7.0 四阶段全量迭代 — 修复面收口 · 安全纵深 · 架构还债 · 能力演进
P1 修复面收口: v0.6.3 截断自愈推全量(Anthropic/Ollama/非流式/引擎兜底); SSE 上游错误帧检测进重试通道; clearMessages 摘要游标根治; truncateResult 内联图片白名单统一; 前端四 bug(确认弹窗锁死/MemoryViewer/ Virtuoso Footer/abort 尾部过滤) + reasoning 缓冲跨迭代污染; 托盘通知过滤与新建会话死链接线 P2 安全纵深: MCP 审批闭环(ConfirmationHook×PolicyEngine 联动+重名拒注册); SSRF 收敛 ssrf-guard 共享模块 (web_fetch 双通道校验+重定向终态复检); Electron 加固(preload CJS 化→sandbox:true/CSP/权限白名单/will-navigate); run_command cmd.exe 白名单通道元字符守门; diff_viewer 10MB 预检; Anthropic thinking 预算下限; Agnes 思考显式关闭 P3 架构还债: OpenAICompatibleAdapter 中间基类收敛四家样板; 错误分类单轨化(删 mapError/getFetchSignal, 超时显式 ETIMEDOUT); PRAGMA user_version 迁移版本化; 死代码清理专项(cn.ts/SHORTCUTS/ContextMenu 分支/ getWindowState/modifiedArgs/sandbox 空壳); i18next 引入; a11y 第一轮; SearXNG 页批量草稿模型统一 P4 能力演进: Ollama pull 可取消/capabilities 探测/num_ctx 实测缓存; UpdateService feed 比对式自动更新 (app:updateCheck IPC + StatusBar 入口); MiMo providerOptions(web_search 服务端工具/strict JSON); web_fetch extract_mode=markdown(turndown); network.proxyUrl 全局代理(Chromium sessions+undici dispatcher) 测试: 264 → 507 用例(Electron ABI 全绿零跳过), 覆盖引擎压缩管线/重试竞速/MEMORY.md 闸门/file_editor 五操作/ filesystem 七工具实体夹具/git 真实仓库/SSE 错误帧/全线截断自愈/Provider 请求形态矩阵/SSRF 表测/钩子分级矩阵/ OutputValidator 全量/SLO 指标/MCP 安全纯函数/task_manager 链路/渲染层纯域/i18n 桥契约
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* file_editor 操作矩阵 + dev-tools/code-search 纯解析器测试(v0.7.0 覆盖补齐)
|
||||
*
|
||||
* file_editor(此前零测试):replace/insert/delete/regex/find_replace 五操作、
|
||||
* dry_run 预览、backup 落盘、ReDoS 启发式拦截、原子写失败回滚。
|
||||
* dev-tools.parseCounts/parseTestResults、code-search.parseRipgrepJsonOutput:
|
||||
* 已 @visibleForTesting 导出,直接锁定输出格式契约。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
vi.mock('electron-log', () => ({
|
||||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
import { FileEditorTool } from '../file-editor';
|
||||
import { LintCodeTool, RunTestsTool } from '../dev-tools';
|
||||
import { CodeSearchTool } from '../code-search';
|
||||
import type { ToolExecutionContext } from '../../../types/metona-tool';
|
||||
|
||||
function ctxFor(ws: string): ToolExecutionContext {
|
||||
return { sessionId: 't', workspacePath: ws, iteration: 1, requestId: 'r' };
|
||||
}
|
||||
|
||||
let ws: string;
|
||||
beforeAll(() => {
|
||||
ws = mkdtempSync(join(tmpdir(), 'metona-edit-'));
|
||||
writeFileSync(join(ws, 'src.txt'), ['alpha', 'beta', 'gamma', 'delta'].join('\n'));
|
||||
});
|
||||
afterAll(() => rmSync(ws, { recursive: true, force: true }));
|
||||
|
||||
const editor = new FileEditorTool();
|
||||
|
||||
describe('file_editor — 五种 operation', () => {
|
||||
it('find_replace:replace_all=true 全量;缺省亦全量(split/join 契约)', async () => {
|
||||
writeFileSync(join(ws, 'fr.txt'), 'cat dog cat dog');
|
||||
const all = (await editor.execute({ file_path: 'fr.txt', operation: 'find_replace', find: 'cat', replace: 'CAT', replace_all: true }, ctxFor(ws))) as { success: boolean };
|
||||
expect(all.success).toBe(true);
|
||||
expect(readFileSync(join(ws, 'fr.txt'), 'utf-8')).toBe('CAT dog CAT dog');
|
||||
|
||||
// 实况契约:split/join 实现 → 缺省 replace_all 即为全量替换
|
||||
writeFileSync(join(ws, 'fr.txt'), 'cat dog cat dog');
|
||||
const first = (await editor.execute({ file_path: 'fr.txt', operation: 'find_replace', find: 'dog', replace: 'BIRD' }, ctxFor(ws))) as { success: boolean };
|
||||
expect(first.success).toBe(true);
|
||||
expect(readFileSync(join(ws, 'fr.txt'), 'utf-8')).toBe('cat BIRD cat BIRD');
|
||||
void all;
|
||||
});
|
||||
|
||||
it('replace 区间替换:start/end_line 契约', async () => {
|
||||
const r = (await editor.execute({ file_path: 'src.txt', operation: 'replace', start_line: 2, end_line: 3, content: 'BETA2\nGAMMA2' }, ctxFor(ws))) as { success: boolean };
|
||||
expect(r.success).toBe(true);
|
||||
expect(readFileSync(join(ws, 'src.txt'), 'utf-8').split('\n')).toEqual(['alpha', 'BETA2', 'GAMMA2', 'delta']);
|
||||
// 还原
|
||||
writeFileSync(join(ws, 'src.txt'), ['alpha', 'beta', 'gamma', 'delta'].join('\n'));
|
||||
});
|
||||
|
||||
it('insert 支持追加到文件末尾(end_line=len+1 形态)与中间插入', async () => {
|
||||
const mid = (await editor.execute({ file_path: 'src.txt', operation: 'insert', start_line: 2, content: 'inserted' }, ctxFor(ws))) as { success: boolean };
|
||||
expect(mid.success).toBe(true);
|
||||
expect(readFileSync(join(ws, 'src.txt'), 'utf-8')).toBe(['alpha', 'inserted', 'beta', 'gamma', 'delta'].join('\n'));
|
||||
|
||||
const tail = (await editor.execute({ file_path: 'src.txt', operation: 'delete', start_line: 2, end_line: 2 }, ctxFor(ws))) as { success: boolean };
|
||||
expect(tail.success).toBe(true);
|
||||
expect(readFileSync(join(ws, 'src.txt'), 'utf-8')).toBe(['alpha', 'beta', 'gamma', 'delta'].join('\n'));
|
||||
});
|
||||
|
||||
it('delete 区间删除行', async () => {
|
||||
const r = (await editor.execute({ file_path: 'src.txt', operation: 'delete', start_line: 1, end_line: 1 }, ctxFor(ws))) as { success: boolean };
|
||||
expect(r.success).toBe(true);
|
||||
expect(readFileSync(join(ws, 'src.txt'), 'utf-8').startsWith('beta')).toBe(true);
|
||||
writeFileSync(join(ws, 'src.txt'), ['alpha', 'beta', 'gamma', 'delta'].join('\n'));
|
||||
});
|
||||
|
||||
it('regex 替换强制 g 标志保证计数一致', async () => {
|
||||
writeFileSync(join(ws, 're.txt'), 'aaa bbb aaa ccc');
|
||||
const r = (await editor.execute({ file_path: 're.txt', operation: 'regex', pattern: 'a{3}', replacement: 'XXX' }, ctxFor(ws))) as Record<string, unknown>;
|
||||
expect(r.success).toBe(true);
|
||||
expect(readFileSync(join(ws, 're.txt'), 'utf-8')).toContain('XXX bbb XXX');
|
||||
});
|
||||
|
||||
it('dry_run=true 不落盘并给出预览', async () => {
|
||||
const before = readFileSync(join(ws, 'src.txt'), 'utf-8');
|
||||
const r = (await editor.execute({ file_path: 'src.txt', operation: 'find_replace', find: 'alpha', replace: 'ALPHA', dry_run: true }, ctxFor(ws))) as { success: boolean };
|
||||
expect(r.success).toBe(true);
|
||||
expect(readFileSync(join(ws, 'src.txt'), 'utf-8')).toBe(before);
|
||||
});
|
||||
|
||||
it('backup=true 产出 .bak 且内容为改动前快照', async () => {
|
||||
writeFileSync(join(ws, 'bak.txt'), 'orig-line');
|
||||
void (await editor.execute({ file_path: 'bak.txt', operation: 'find_replace', find: 'orig', replace: 'new', backup: true }, ctxFor(ws)));
|
||||
expect(existsSync(join(ws, 'bak.txt.bak'))).toBe(true);
|
||||
expect(readFileSync(join(ws, 'bak.txt.bak'), 'utf-8')).toBe('orig-line');
|
||||
});
|
||||
|
||||
it('ReDoS 启发式拦截嵌套量词 pattern', async () => {
|
||||
const r = (await editor.execute({ file_path: 're.txt', operation: 'regex', pattern: '(a+)+$', replacement: 'x' }, ctxFor(ws))) as { success: boolean; error?: string };
|
||||
expect(r.success).toBe(false);
|
||||
expect(String((r as { error?: string }).error).toLowerCase()).toMatch(/catastrophic|unsafe|complex|pattern/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== dev-tools 解析器 =====
|
||||
|
||||
const lintDevTools = new LintCodeTool();
|
||||
const parseCounts = lintDevTools.parseCounts.bind(lintDevTools);
|
||||
const parseTests = new RunTestsTool().parseTestResults.bind(new RunTestsTool());
|
||||
|
||||
describe('dev-tools.parseCounts / parseTestResults 输出契约', () => {
|
||||
it('tsc 格式:error TS#### 行计数,warning 恒 0', () => {
|
||||
const out = [
|
||||
'src/a.ts(1,7): error TS2304: Cannot find name',
|
||||
'src/b.ts(5,1): warning TS6133: unused var',
|
||||
'src/c.ts(9,9): error TS2551: typo',
|
||||
].join('\n');
|
||||
expect(parseCounts(out, 'tsc')).toEqual({ errorCount: 2, warningCount: 0 });
|
||||
});
|
||||
|
||||
it('eslint 汇总行 "✖ N problems (X errors, Y warnings)" 解析', () => {
|
||||
expect(parseCounts('✖ 7 problems (5 errors, 2 warnings)', 'eslint')).toEqual({
|
||||
errorCount: 5,
|
||||
warningCount: 2,
|
||||
});
|
||||
expect(parseCounts('All clean', 'eslint')).toEqual({ errorCount: 0, warningCount: 0 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Tests: 5 passed, 2 failed, 7 total', { passed: 5, failed: 2 }],
|
||||
['Tests: 9 passed, 9 total', { passed: 9, failed: 0 }],
|
||||
['42 passing (3.5s)', { passed: 42, failed: 0 }],
|
||||
['3 failing (1.2s)', { passed: 0, failed: 3 }],
|
||||
])('%s → %j', (output, expected) => {
|
||||
const parsed = parseTests(output);
|
||||
expect(parsed.passed).toBe(expected.passed);
|
||||
expect(parsed.failed).toBe(expected.failed);
|
||||
expect(typeof parsed.duration).toBe('string');
|
||||
});
|
||||
|
||||
it('耗时优先 Time:/Duration:/耗时: 标签,回退括号形态', () => {
|
||||
expect(parseTests('Time: 12.3 s').duration).toMatch(/^12\.3\s*s$/);
|
||||
expect(parseTests('(3.5s)').duration).toContain('3.5');
|
||||
});
|
||||
});
|
||||
|
||||
// ===== code-search ripgrep JSON 状态机 =====
|
||||
|
||||
const cs = new CodeSearchTool();
|
||||
const parseRipgrep = cs.parseRipgrepJsonOutput.bind(cs);
|
||||
|
||||
describe('parseRipgrepJsonOutput — rg --json 上下文状态机', () => {
|
||||
it('match/context 状态机(首个 match 的 before / 最后残留 after)', () => {
|
||||
const raw = [
|
||||
JSON.stringify({ type: 'context', data: { lines: { text: 'before line 1' } } }),
|
||||
JSON.stringify({ type: 'context', data: { lines: { text: 'before line 2' } } }),
|
||||
JSON.stringify({ type: 'match', data: { path: { text: 'a.ts' }, line_number: 10, submatches: [{ match: { text: 'needle' }, start: 4 }] } }),
|
||||
JSON.stringify({ type: 'context', data: { lines: { text: 'after line 1' } } }),
|
||||
JSON.stringify({ type: 'context', data: { lines: { text: 'after line 2' } } }),
|
||||
].join('\n');
|
||||
|
||||
const results = parseRipgrep(raw);
|
||||
expect(results).toHaveLength(1);
|
||||
const hit = results[0];
|
||||
expect(hit.path).toBe('a.ts');
|
||||
expect(hit.line).toBe(10);
|
||||
expect(hit.column).toBe(5); // start=4 → column 从 1 计数
|
||||
expect(hit.match).toBe('needle');
|
||||
expect(hit.before?.map((l: string) => l.trim())).toEqual(['before line 1', 'before line 2']);
|
||||
// 结尾残留的 context 属于最后一个 match 的 after
|
||||
expect(hit.after?.map((l: string) => l.trim())).toEqual(['after line 1', 'after line 2']);
|
||||
});
|
||||
|
||||
it('多 match 相邻排布:每个 match 的 before/after 各自正确收敛', () => {
|
||||
const raw = [
|
||||
JSON.stringify({ type: 'match', data: { path: { text: 'b.ts' }, line_number: 1, submatches: [{ match: { text: 'one' }, start: 0 }] } }),
|
||||
JSON.stringify({ type: 'context', data: { lines: { text: 'gap line' } } }),
|
||||
JSON.stringify({ type: 'match', data: { path: { text: 'b.ts' }, line_number: 3, submatches: [{ match: { text: 'two' }, start: 2 }] } }),
|
||||
].join('\n');
|
||||
|
||||
const results = parseRipgrep(raw);
|
||||
expect(results.map((r: { match: string }) => r.match)).toEqual(['one', 'two']);
|
||||
// 实况契约:夹在两个 match 之间的 context 归属【前一个 match 的 after】,
|
||||
// 且不会同时作为后一个 match 的 before(单向流转,无复制)
|
||||
expect(results[0].after?.map((l: string) => l.trim())).toEqual(['gap line']);
|
||||
expect(results[1].before).toBeUndefined();
|
||||
});
|
||||
|
||||
it('坏行静默跳过不中断状态机', () => {
|
||||
const raw = ['not-json-at-all', JSON.stringify({ type: 'match', data: { path: { text: 'c.ts' }, line_number: 2 } })].join('\n');
|
||||
const results = parseRipgrep(raw);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].path).toBe('c.ts');
|
||||
expect(results[0].column).toBe(1); // 无 submatches 时列号兜底 1
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user