/** * 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; 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 }); });