Files
metona-ai-desktop/electron/harness/tools/built-in/__tests__/editor-and-parsers.test.ts
T
thzxx 99d0c54129
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s
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)
2026-08-30 19:19:07 +08:00

710 lines
25 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.
/**
* file_editor 操作矩阵 + dev-tools/code-search 纯解析器测试(v0.7.0 → v0.7.5 扩充)
*
* file_editorreplace/insert/delete/regex/find_replace 五操作、
* dry_run 预览、backup 落盘、ReDoS 启发式拦截、原子写失败回滚、
* multiline 100K 上限、越界行号钳制、未知操作拒绝。
* 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_replacereplace_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('find_replace replace_all=false 仅替换第一个匹配', async () => {
writeFileSync(join(ws, 'fr2.txt'), 'cat dog cat dog');
const r = (await editor.execute(
{
file_path: 'fr2.txt',
operation: 'find_replace',
find: 'cat',
replace: 'CAT',
replace_all: false,
},
ctxFor(ws),
)) as { success: boolean; replacements?: number };
expect(r.success).toBe(true);
expect(r.replacements).toBe(1);
expect(readFileSync(join(ws, 'fr2.txt'), 'utf-8')).toBe('CAT dog cat dog');
});
it('find_replace 无匹配 → 返回提示且文件不变', async () => {
writeFileSync(join(ws, 'fr3.txt'), 'hello world');
const before = readFileSync(join(ws, 'fr3.txt'), 'utf-8');
const r = (await editor.execute(
{ file_path: 'fr3.txt', operation: 'find_replace', find: 'zzz', replace: 'YYY' },
ctxFor(ws),
)) as {
success: boolean;
message?: string;
replacements?: number;
};
expect(r.success).toBe(true);
expect(r.message).toContain('No matches found');
expect(r.replacements).toBe(0);
expect(readFileSync(join(ws, 'fr3.txt'), 'utf-8')).toBe(before);
});
it('find_replace 缺 find 参数 → 报错', async () => {
const r = (await editor.execute(
{ file_path: 'fr3.txt', operation: 'find_replace', replace: 'x' },
ctxFor(ws),
)) as { success: boolean };
expect(r.success).toBe(false);
});
it('find_replace 空 find 字符串 → 报错', async () => {
const r = (await editor.execute(
{ file_path: 'fr3.txt', operation: 'find_replace', find: '', replace: 'x' },
ctxFor(ws),
)) as { success: boolean };
expect(r.success).toBe(false);
});
it('find_replace 中正则元字符按字面量处理(不解析)', async () => {
writeFileSync(join(ws, 'fr4.txt'), 'a.b a.b');
const r = (await editor.execute(
{ file_path: 'fr4.txt', operation: 'find_replace', find: 'a.b', replace: 'A.B' },
ctxFor(ws),
)) as { success: boolean };
expect(r.success).toBe(true);
expect(readFileSync(join(ws, 'fr4.txt'), 'utf-8')).toBe('A.B A.B');
});
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('replace 行号越界(start > len)→ endLine<startLine 报错', async () => {
const r = (await editor.execute(
{
file_path: 'src.txt',
operation: 'replace',
start_line: 99,
end_line: 99,
content: 'appended',
},
ctxFor(ws),
)) as { success: boolean; error?: string };
expect(r.success).toBe(false);
expect(String((r as { error?: string }).error)).toContain('end_line');
});
it('replace end_line < start_line → 报错', async () => {
const r = (await editor.execute(
{ file_path: 'src.txt', operation: 'replace', start_line: 3, end_line: 1, content: 'x' },
ctxFor(ws),
)) as { success: boolean; error?: string };
expect(r.success).toBe(false);
expect(String((r as { error?: string }).error)).toContain('end_line');
});
it('replace 缺 start_line/end_line 默认替换第 1 行', async () => {
writeFileSync(join(ws, 'repl.txt'), 'a\nb\nc');
const r = (await editor.execute(
{ file_path: 'repl.txt', operation: 'replace', content: 'A\nB' },
ctxFor(ws),
)) as { success: boolean };
expect(r.success).toBe(true);
expect(readFileSync(join(ws, 'repl.txt'), 'utf-8')).toBe('A\nB\nb\nc');
});
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('insert 到末尾(start_line 超出 len+1 被钳制为追加)', async () => {
const r = (await editor.execute(
{ file_path: 'src.txt', operation: 'insert', start_line: 99, content: 'at-end' },
ctxFor(ws),
)) as { success: boolean };
expect(r.success).toBe(true);
expect(readFileSync(join(ws, 'src.txt'), 'utf-8')).toBe(
['alpha', 'beta', 'gamma', 'delta', 'at-end'].join('\n'),
);
writeFileSync(join(ws, 'src.txt'), ['alpha', 'beta', 'gamma', 'delta'].join('\n'));
});
it('insert 多行 content 产生多行插入', async () => {
const r = (await editor.execute(
{ file_path: 'src.txt', operation: 'insert', start_line: 1, content: 'x\ny\nz' },
ctxFor(ws),
)) as { success: boolean };
expect(r.success).toBe(true);
expect(readFileSync(join(ws, 'src.txt'), 'utf-8').split('\n')).toEqual([
'x',
'y',
'z',
'alpha',
'beta',
'gamma',
'delta',
]);
writeFileSync(join(ws, 'src.txt'), ['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('delete 单行(缺省 end_line=start_line', async () => {
writeFileSync(join(ws, 'del.txt'), 'a\nb\nc');
const r = (await editor.execute(
{ file_path: 'del.txt', operation: 'delete', start_line: 2 },
ctxFor(ws),
)) as { success: boolean };
expect(r.success).toBe(true);
expect(readFileSync(join(ws, 'del.txt'), 'utf-8')).toBe('a\nc');
});
it('delete end_line 越界被钳制到文件末尾', async () => {
writeFileSync(join(ws, 'del2.txt'), 'a\nb\nc');
const r = (await editor.execute(
{ file_path: 'del2.txt', operation: 'delete', start_line: 2, end_line: 999 },
ctxFor(ws),
)) as { success: boolean };
expect(r.success).toBe(true);
expect(readFileSync(join(ws, 'del2.txt'), 'utf-8')).toBe('a');
});
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('regex 指定行范围只替换该区间', async () => {
writeFileSync(join(ws, 're2.txt'), 'aaa\naaa\naaa');
const r = (await editor.execute(
{
file_path: 're2.txt',
operation: 'regex',
pattern: 'aaa',
replacement: 'X',
start_line: 2,
end_line: 3,
},
ctxFor(ws),
)) as { success: boolean };
expect(r.success).toBe(true);
expect(readFileSync(join(ws, 're2.txt'), 'utf-8')).toBe('aaa\nX\nX');
});
it('regex 无匹配 → 返回提示且文件不变', async () => {
writeFileSync(join(ws, 're3.txt'), 'abc');
const before = readFileSync(join(ws, 're3.txt'), 'utf-8');
const r = (await editor.execute(
{ file_path: 're3.txt', operation: 'regex', pattern: 'zzz', replacement: 'x' },
ctxFor(ws),
)) as {
success: boolean;
message?: string;
replacements?: number;
};
expect(r.success).toBe(true);
expect(r.message).toContain('No matches found');
expect(r.replacements).toBe(0);
expect(readFileSync(join(ws, 're3.txt'), 'utf-8')).toBe(before);
});
it('regex 缺 pattern → 报错', async () => {
const r = (await editor.execute(
{ file_path: 're3.txt', operation: 'regex', replacement: 'x' },
ctxFor(ws),
)) as { success: boolean };
expect(r.success).toBe(false);
});
it('regex 非法 pattern → Invalid regex', async () => {
const r = (await editor.execute(
{ file_path: 're3.txt', operation: 'regex', pattern: '([unclosed', replacement: 'x' },
ctxFor(ws),
)) as { success: boolean; error?: string };
expect(r.success).toBe(false);
expect(String((r as { error?: string }).error)).toContain('Invalid regex');
});
it('regex pattern 超过 500 字符 → 拒绝', async () => {
const r = (await editor.execute(
{ file_path: 're3.txt', operation: 'regex', pattern: 'a'.repeat(501), replacement: 'x' },
ctxFor(ws),
)) as { success: boolean; error?: string };
expect(r.success).toBe(false);
expect(String((r as { error?: string }).error)).toContain('max 500');
});
it('regex end_line < start_line → 报错', async () => {
const r = (await editor.execute(
{
file_path: 're3.txt',
operation: 'regex',
pattern: 'a',
replacement: 'b',
start_line: 5,
end_line: 2,
},
ctxFor(ws),
)) as { success: boolean };
expect(r.success).toBe(false);
});
it('multiline=true 跨行匹配', async () => {
writeFileSync(join(ws, 'multi.txt'), 'start\nBEGIN\nBODY\nEND\nfinish');
const r = (await editor.execute(
{
file_path: 'multi.txt',
operation: 'regex',
pattern: 'BEGIN\\nBODY\\nEND',
replacement: 'REPLACED',
multiline: true,
},
ctxFor(ws),
)) as { success: boolean; replacements?: number };
expect(r.success).toBe(true);
expect(r.replacements).toBe(1);
expect(readFileSync(join(ws, 'multi.txt'), 'utf-8')).toBe('start\nREPLACED\nfinish');
});
it('multiline 目标内容超过 100K → 拒绝', async () => {
const big = Array.from({ length: 30000 }, (_, i) => `line-${i}-${'x'.repeat(10)}`).join('\n');
writeFileSync(join(ws, 'big-multi.txt'), big);
const r = (await editor.execute(
{
file_path: 'big-multi.txt',
operation: 'regex',
pattern: 'needle',
replacement: 'y',
multiline: true,
},
ctxFor(ws),
)) as { success: boolean; error?: string };
expect(r.success).toBe(false);
expect(String((r as { error?: string }).error)).toContain('100000');
});
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('dry_run 返回 lines_before/lines_after 与 preview 结构', async () => {
const r = (await editor.execute(
{
file_path: 'src.txt',
operation: 'replace',
start_line: 2,
end_line: 2,
content: 'BETA-NEW',
dry_run: true,
},
ctxFor(ws),
)) as {
success: boolean;
dry_run?: boolean;
lines_before?: number;
lines_after?: number;
preview?: { original: string; modified: string };
};
expect(r.success).toBe(true);
expect(r.dry_run).toBe(true);
expect(r.lines_before).toBe(4);
expect(r.lines_after).toBe(4);
expect(r.preview?.original).toBe('beta');
expect(r.preview?.modified).toBe('BETA-NEW');
});
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('backup=false(默认)不产生 .bak', async () => {
writeFileSync(join(ws, 'nobak.txt'), 'orig');
void (await editor.execute(
{ file_path: 'nobak.txt', operation: 'find_replace', find: 'orig', replace: 'new' },
ctxFor(ws),
));
expect(existsSync(join(ws, 'nobak.txt.bak'))).toBe(false);
});
it('backup 与 dry_run 同用:dry_run 不写 .bak', async () => {
writeFileSync(join(ws, 'bdd.txt'), 'orig');
void (await editor.execute(
{
file_path: 'bdd.txt',
operation: 'find_replace',
find: 'orig',
replace: 'new',
backup: true,
dry_run: true,
},
ctxFor(ws),
));
expect(existsSync(join(ws, 'bdd.txt.bak'))).toBe(false);
});
it('未知 operation → 报错', async () => {
const r = (await editor.execute(
{ file_path: 'src.txt', operation: 'frobnicate' },
ctxFor(ws),
)) as { success: boolean; error?: string };
expect(r.success).toBe(false);
expect(String((r as { error?: string }).error)).toContain('Unknown operation');
});
it('file_path 缺失 → 报错', async () => {
const r = (await editor.execute(
{ operation: 'find_replace', find: 'x', replace: 'y' },
ctxFor(ws),
)) as { success: boolean };
expect(r.success).toBe(false);
});
it('文件不存在 → 提示用 write_file 创建', async () => {
const r = (await editor.execute(
{ file_path: 'ghost-file.txt', operation: 'insert', start_line: 1, content: 'x' },
ctxFor(ws),
)) as { success: boolean; error?: string };
expect(r.success).toBe(false);
expect(String((r as { error?: string }).error)).toContain('File not found');
});
it('路径越界 → 拒绝', async () => {
const outside = process.platform === 'win32' ? 'C:\\Windows\\notepad.exe' : '/etc/passwd';
const r = (await editor.execute(
{ file_path: outside, operation: 'find_replace', find: 'x', replace: 'y' },
ctxFor(ws),
)) as { success: boolean };
expect(r.success).toBe(false);
});
it('根 MEMORY.md 不可编辑', async () => {
writeFileSync(join(ws, 'MEMORY.md'), '# m');
const r = (await editor.execute(
{ file_path: 'MEMORY.md', operation: 'find_replace', find: 'm', replace: 'M' },
ctxFor(ws),
)) as { success: boolean };
expect(r.success).toBe(false);
});
it('10MB 文件闸门拒绝编辑', async () => {
writeFileSync(join(ws, 'huge.txt'), Buffer.alloc(10 * 1024 * 1024 + 5, 0x61));
const r = (await editor.execute(
{ file_path: 'huge.txt', operation: 'find_replace', find: 'a', replace: 'b' },
ctxFor(ws),
)) as { success: boolean; error?: string };
expect(r.success).toBe(false);
expect(String((r as { error?: string }).error)).toContain('File too large');
});
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,
);
});
it('ReDoS 拦截重叠量词(a+a+)与交替分支((a|a)*', async () => {
for (const evil of ['a+a+', '(a|a)*', '(a+)*']) {
const r = (await editor.execute(
{ file_path: 're.txt', operation: 'regex', pattern: evil, replacement: 'x' },
ctxFor(ws),
)) as { success: boolean };
expect(r.success, `expected ReDoS block for ${evil}`).toBe(false);
}
});
it('正常 pattern(\\d+)? 前缀量词)不误伤', async () => {
writeFileSync(join(ws, 'safe-re.txt'), 'v1 v2');
const r = (await editor.execute(
{ file_path: 'safe-re.txt', operation: 'regex', pattern: 'v(\\d+)', replacement: 'V' },
ctxFor(ws),
)) as { success: boolean };
expect(r.success).toBe(true);
expect(readFileSync(join(ws, 'safe-re.txt'), 'utf-8')).toBe('V V');
});
});
// ===== 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('tsc 无 error 行 → 0', () => {
expect(parseCounts('No errors found', 'tsc')).toEqual({ errorCount: 0, 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('eslint 单数问题形态(1 problem / 1 error / 1 warning', () => {
expect(parseCounts('✖ 1 problem (1 error, 0 warnings)', 'eslint')).toEqual({
errorCount: 1,
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 }],
['Tests: 10 passed, 2 failed, 12 total\nTime: 5.2 s', { passed: 10, failed: 2 }],
])('%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');
expect(parseTests('Duration: 120ms').duration).toBe('120ms');
expect(parseTests('耗时: 3.5s').duration).toContain('3.5');
});
it('空输出 → 全零与默认耗时', () => {
expect(parseTests('')).toEqual({ passed: 0, failed: 0, duration: '0s' });
});
});
// ===== 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']);
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
});
it('多 submatches 取第一个作为 match 文本与列号', () => {
const raw = JSON.stringify({
type: 'match',
data: {
path: { text: 'd.ts' },
line_number: 7,
submatches: [
{ match: { text: 'first' }, start: 3 },
{ match: { text: 'second' }, start: 20 },
],
},
});
const results = parseRipgrep(raw);
expect(results[0].match).toBe('first');
expect(results[0].column).toBe(4);
});
it('空输出 → 空结果', () => {
expect(parseRipgrep('')).toHaveLength(0);
expect(parseRipgrep('\n\n')).toHaveLength(0);
});
it('context 在无 match 时全部作为残留 before 丢弃', () => {
const raw = [JSON.stringify({ type: 'context', data: { lines: { text: 'orphan' } } })].join(
'\n',
);
expect(parseRipgrep(raw)).toHaveLength(0);
});
});