新增: df-mcp E2E 测试脚本(spawn 真实二进制按行喂 JSON-RPC 断言)

This commit is contained in:
lxy
2026-08-08 14:20:32 +08:00
parent 9f75db5b15
commit 60cc163d62
+184
View File
@@ -0,0 +1,184 @@
// df-mcp E2E 实测脚本:spawn 真实二进制,按行喂 JSON-RPC,断言修复项 + 全链路。
// 用法: node df-mcp-e2e.mjs <exe路径> [--db <临时库>]
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const exe = process.argv[2];
if (!exe) { console.error('用法: node df-mcp-e2e.mjs <exe路径>'); process.exit(1); }
const dbArg = process.argv[4] ?? join(mkdtempSync(join(tmpdir(), 'dfmcp-')), 'test.db');
let pass = 0, fail = 0;
const failures = [];
function assert(cond, name, extra) {
if (cond) { pass++; console.log(`${name}`); }
else { fail++; failures.push(name); console.log(`${name}${extra ? `${extra}` : ''}`); }
}
function session(dbPath, readOnly = false) {
const args = ['mcp-server', '--db', dbPath];
if (readOnly) args.push('--read-only');
const child = spawn(exe, args, { stdio: ['pipe', 'pipe', 'pipe'] });
const rl = createInterface({ input: child.stdout });
const pending = [];
const waiters = [];
let buf = '';
rl.on('line', (line) => {
const resolved = waiters.shift();
if (resolved) resolved(line);
});
child.stderr.on('data', (d) => process.stderr.write(` [server] ${d}`));
let idSeq = 0;
return {
async call(req) {
const payload = { jsonrpc: '2.0', id: ++idSeq, ...req };
const p = new Promise((res) => waiters.push(res));
child.stdin.write(JSON.stringify(payload) + '\n');
const line = await p;
return JSON.parse(line);
},
async raw(line) {
const p = new Promise((res) => waiters.push(res));
child.stdin.write(line + '\n');
const out = await p;
return JSON.parse(out);
},
close() { child.stdin.end(); },
};
}
// 从 CallToolResult 取文本
function txt(res) {
try { return res.result.content[0].text; } catch { return JSON.stringify(res); }
}
function json(res) { return JSON.parse(txt(res)); }
async function main() {
console.log(`\n═══ df-mcp E2E 实测 exe=${exe} db=${dbArg} ═══`);
// ── 实例 A:读写模式 ──────────────────────────────
const s = session(dbArg, false);
console.log('\n【1】握手与工具清单');
const init = await s.call({ method: 'initialize', params: {} });
assert(init.result.serverInfo.name === 'devflow-mcp', 'initialize 返回 serverInfo.name', init.result?.serverInfo?.name);
assert(init.result.capabilities.tools.listChanged === false, 'capabilities.tools 声明');
const tl = await s.call({ method: 'tools/list', params: {} });
const names = tl.result.tools.map(t => t.name);
const low = ['list_projects','get_project','list_tasks','list_ideas','evaluate_idea','list_trash'];
const med = ['create_project','update_project','bind_directory','create_task','update_task','advance_task','create_idea','update_idea','score_idea','restore_project'];
const high = ['delete_project','delete_task','delete_idea','run_workflow'];
assert(low.every(n => names.includes(n)), 'Low 工具全部可见');
assert(med.every(n => names.includes(n)), 'Medium 工具全部可见');
assert(!high.some(n => names.includes(n)), 'High 工具默认不可见');
assert(names.length === low.length + med.length, `工具数 = ${low.length + med.length}`, `实际 ${names.length}`);
console.log('\n【2】修复项:parse error 带 id:null(修复#1)');
const perr = await s.raw('{"jsonrpc":"2.0","id":999,"method":"bogus", BAD JSON');
assert(perr.error && perr.error.code === -32700, '非法 JSON → PARSE_ERROR(-32700)');
assert(perr.id === null, 'parse error 响应 id 必须为 null', `实际 ${JSON.stringify(perr.id)}`);
console.log('\n【3】修复项:tools/call 缺 name 明确报错(修复#7)');
const noname = await s.call({ method: 'tools/call', params: { arguments: {} } });
assert(noname.error && noname.error.code === -32601, '缺 name → METHOD_NOT_FOUND');
assert(noname.error.message.includes('name'), '错误消息含 name 提示', noname.error.message);
assert(!noname.error.message.includes('未知工具: '), '不是空名「未知工具: 」', noname.error.message);
console.log('\n【4】未知工具');
const unk = await s.call({ method: 'tools/call', params: { name: 'nope', arguments: {} } });
assert(txt(unk).includes('未知工具'), '未知工具报错', txt(unk));
console.log('\n【5】修复项:update_project 非法状态不再静默降级(修复#3)');
const cp = await s.call({ method: 'tools/call', params: { name: 'create_project', arguments: { name: 'E2E项目', description: '实测' } } });
assert(!cp.result.isError, 'create_project 成功', txt(cp));
const proj = json(cp);
const pid = proj.id;
const badSt = await s.call({ method: 'tools/call', params: { name: 'update_project', arguments: { id: pid, status: 'bogus_state' } } });
assert(badSt.result.isError === true, 'update_project 非法 status 报错');
assert(txt(badSt).includes('非法状态值'), '错误信息含"非法状态值"', txt(badSt));
console.log('\n【6】update_project 部分更新保留原值');
const upd = await s.call({ method: 'tools/call', params: { name: 'update_project', arguments: { id: pid, name: 'E2E项目改' } } });
assert(!upd.result.isError, '部分更新成功');
const updJ = json(upd);
assert(updJ.project.name === 'E2E项目改' && updJ.project.status === 'planning', 'name 更新 + status 保留 planning', `status=${updJ.project.status}`);
console.log('\n【7】create_task 带 parent_id(外部新增能力)');
const cproj = await s.call({ method: 'tools/call', params: { name: 'list_projects', arguments: {} } });
const listJ = json(cproj);
const parentT = await s.call({ method: 'tools/call', params: { name: 'create_task', arguments: { project_id: pid, title: '父任务', description: '' } } });
const parentId = json(parentT).id;
const childT = await s.call({ method: 'tools/call', params: { name: 'create_task', arguments: { project_id: pid, title: '子任务', description: '', parent_id: parentId } } });
assert(!childT.result.isError, '创建子任务成功', txt(childT));
const grandchild = await s.call({ method: 'tools/call', params: { name: 'create_task', arguments: { project_id: pid, title: '孙任务', description: '', parent_id: json(childT).id } } });
assert(grandchild.result.isError === true && txt(grandchild).includes('父任务不能是子任务'), '1 级嵌套限制:孙任务被拒', txt(grandchild));
const badP = await s.call({ method: 'tools/call', params: { name: 'create_task', arguments: { project_id: pid, title: 'x', description: '', parent_id: 'ghost' } } });
assert(badP.result.isError === true && txt(badP).includes('父任务不存在'), '父任务不存在被拒', txt(badP));
console.log('\n【8】advance_task 状态机');
const adv = await s.call({ method: 'tools/call', params: { name: 'advance_task', arguments: { id: parentId, to: 'in_progress' } } });
assert(!adv.result.isError && json(adv).task.status === 'in_progress', 'todo→in_progress 成功');
const badAdv = await s.call({ method: 'tools/call', params: { name: 'advance_task', arguments: { id: parentId, to: 'done' } } });
assert(badAdv.result.isError === true, 'todo→done 非法跳态被拒', txt(badAdv));
console.log('\n【9】idea 闭环:evaluate(Low只读) → score(Medium写库)');
const ci = await s.call({ method: 'tools/call', params: { name: 'create_idea', arguments: { title: '核心功能', description: '需要立即重构关键模块以解除阻塞' } } });
const iid = json(ci).id;
const ev = await s.call({ method: 'tools/call', params: { name: 'evaluate_idea', arguments: { id: iid } } });
assert(!ev.result.isError && json(ev).scores.overall >= 0, 'evaluate_idea 返回分数');
const evJ = json(ev);
assert(evJ.idea.scores == null, 'evaluate_idea 不写库(scores 仍 null)');
const sc = await s.call({ method: 'tools/call', params: { name: 'score_idea', arguments: { id: iid } } });
const scJ = json(sc);
assert(!sc.result.isError && scJ.idea.scores != null, 'score_idea 写库成功');
console.log('\n【10】bind_directory:冲突/穿越/不存在路径');
const bd1 = await s.call({ method: 'tools/call', params: { name: 'bind_directory', arguments: { id: pid, path: 'C:/Temp/nonexist_dir' } } });
assert(!bd1.result.isError, '绑定不存在目录成功(规范化为绝对路径)');
const bdConflict = await s.call({ method: 'tools/call', params: { name: 'create_project', arguments: { name: 'E2E项目2', description: '' } } });
const pid2 = json(bdConflict).id;
const bd2 = await s.call({ method: 'tools/call', params: { name: 'bind_directory', arguments: { id: pid2, path: 'c:/temp/nonexist_dir' } } });
assert(bd2.result.isError === true && txt(bd2).includes('路径已被项目'), '路径冲突检测(大小写不敏感)', txt(bd2));
const bdTraverse = await s.call({ method: 'tools/call', params: { name: 'bind_directory', arguments: { id: pid2, path: 'C:/temp/../Windows' } } });
assert(bdTraverse.result.isError === true && txt(bdTraverse).includes('..'), '.. 段拒绝', txt(bdTraverse));
console.log('\n【11】回收站 list_trash / restore_project');
const lt = await s.call({ method: 'tools/call', params: { name: 'list_trash', arguments: {} } });
assert(!lt.result.isError, 'list_trash 成功');
const rest = await s.call({ method: 'tools/call', params: { name: 'restore_project', arguments: { id: 'ghost' } } });
assert(rest.result.isError === true, '恢复不存在的项目报错', txt(rest));
console.log('\n【12】跨实体校验');
const cross = await s.call({ method: 'tools/call', params: { name: 'update_task', arguments: { id: iid, title: 'x' } } });
assert(cross.result.isError === true && txt(cross).includes('属于 idea'), 'update_task 传 idea id → 跨实体报错', txt(cross));
console.log('\n【13】High 工具即便绕过 tools/list 也被拒');
const del = await s.call({ method: 'tools/call', params: { name: 'delete_project', arguments: { id: pid } } });
assert(del.result.isError === true && txt(del).includes('DevFlow'), 'delete_project 默认拒绝', txt(del));
s.close();
// ── 实例 B:read-only 模式 ─────────────────────────
console.log('\n════ read-only 模式 ════');
const ro = session(dbArg, true);
const roList = await ro.call({ method: 'tools/list', params: {} });
const roNames = roList.result.tools.map(t => t.name);
assert(roNames.length === low.length, `read-only 仅 Low(${low.length} 个)`, `实际 ${roNames.length}`);
assert(roNames.every(n => low.includes(n)), 'read-only 只含 Low 工具');
const roCall = await ro.call({ method: 'tools/call', params: { name: 'create_project', arguments: { name: 'x', description: '' } } });
assert(roCall.result.isError === true && txt(roCall).includes('只读模式'), 'read-only 拒绝 Medium', txt(roCall));
const roEval = await ro.call({ method: 'tools/call', params: { name: 'evaluate_idea', arguments: { id: iid } } });
assert(!roEval.result.isError, 'read-only 放行 evaluate_idea(Low)');
ro.close();
try { await new Promise(r => setTimeout(r, 300)); rmSync(join(dbArg, '..'), { recursive: true, force: true }); } catch {}
console.log(`\n═══ 结果: ${pass} 通过 / ${fail} 失败 ═══`);
if (failures.length) { console.log('失败项:'); failures.forEach(f => console.log(` - ${f}`)); process.exit(1); }
console.log('✅ 全部通过');
}
main().catch(e => { console.error('脚本异常:', e); process.exit(1); });