Commit 483c132dcbe6a05f38853865fa65756d9f36f9af

Authored by yibo
1 parent 1037ab12

test: add comprehensive Harness Engineering test suite (41 assertions)

scripts/test-harness.js covers:
- Meta-Skill file existence & content
- Base Agent template completeness
- Hot Swap simulation
- Context Flush simulation
- _lessons.md injection
- billing.log format
- GoalsService new statuses
- CLI registration
- Transition API route verification
- Optional --online mode for live API tests
Showing 1 changed file with 376 additions and 0 deletions
scripts/test-harness.js 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * test-harness.js — Harness Engineering 四阶段流转手工测试脚本
  4 + *
  5 + * 测试覆盖:
  6 + * ✅ Test 1: Meta-Skill 文件存在性检查
  7 + * ✅ Test 2: Base Agent Template 完整性检查
  8 + * ✅ Test 3: Task Transition API — Hot Swap 换脑验证
  9 + * ✅ Test 4: Task Transition API — Context Flush 记忆断舍离
  10 + * ✅ Test 5: P0 事前阻断阀 (Human-in-the-loop)
  11 + * ✅ Test 6: _lessons.md 注入 System Prompt
  12 + * ✅ Test 7: billing.log 计费日志
  13 + * ✅ Test 8: OKR GoalsService 新增状态兼容
  14 + * ✅ Test 9: CLI `task transition` 命令注册
  15 + *
  16 + * 用法:
  17 + * node scripts/test-harness.js # 运行全部离线测试 (不需要服务器)
  18 + * node scripts/test-harness.js --online # 运行含 API 调用的在线测试 (需要服务器在 3000 端口)
  19 + */
  20 +
  21 +const fs = require('fs');
  22 +const path = require('path');
  23 +const http = require('http');
  24 +
  25 +const ROOT = path.resolve(__dirname, '..');
  26 +const DATA = path.join(ROOT, 'data');
  27 +const API_BASE = 'http://localhost:3000/api';
  28 +
  29 +let passed = 0;
  30 +let failed = 0;
  31 +const results = [];
  32 +
  33 +function assert(condition, testName, detail = '') {
  34 + if (condition) {
  35 + passed++;
  36 + results.push({ status: '✅', testName });
  37 + console.log(` ✅ ${testName}`);
  38 + } else {
  39 + failed++;
  40 + results.push({ status: '❌', testName, detail });
  41 + console.log(` ❌ ${testName}${detail ? ` — ${detail}` : ''}`);
  42 + }
  43 +}
  44 +
  45 +function fileExists(relPath) {
  46 + return fs.existsSync(path.join(ROOT, relPath));
  47 +}
  48 +
  49 +function fileContains(relPath, needle) {
  50 + if (!fileExists(relPath)) return false;
  51 + return fs.readFileSync(path.join(ROOT, relPath), 'utf-8').includes(needle);
  52 +}
  53 +
  54 +function httpPost(urlPath, body) {
  55 + return new Promise((resolve, reject) => {
  56 + const postData = JSON.stringify(body);
  57 + const url = new URL(API_BASE + urlPath);
  58 + const req = http.request({
  59 + hostname: url.hostname,
  60 + port: url.port,
  61 + path: url.pathname,
  62 + method: 'POST',
  63 + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) },
  64 + }, (res) => {
  65 + let data = '';
  66 + res.on('data', c => data += c);
  67 + res.on('end', () => {
  68 + try { resolve({ status: res.statusCode, body: JSON.parse(data) }); }
  69 + catch { resolve({ status: res.statusCode, body: data }); }
  70 + });
  71 + });
  72 + req.on('error', reject);
  73 + req.write(postData);
  74 + req.end();
  75 + });
  76 +}
  77 +
  78 +// ═══════════════════════════════════════
  79 +// Test 1: Meta-Skill 文件存在性
  80 +// ═══════════════════════════════════════
  81 +function test1_metaSkillFiles() {
  82 + console.log('\n📦 Test 1: Meta-Skill 文件存在性');
  83 + const phases = ['meta-research', 'meta-plan', 'meta-execute', 'meta-verify'];
  84 + for (const phase of phases) {
  85 + const skillPath = `data/system/skills/${phase}/SKILL.md`;
  86 + assert(fileExists(skillPath), `${phase}/SKILL.md 存在`, `缺失: ${skillPath}`);
  87 + }
  88 + // 内容校验
  89 + assert(
  90 + fileContains('data/system/skills/meta-research/SKILL.md', '调研'),
  91 + 'meta-research 包含"调研"关键词'
  92 + );
  93 + assert(
  94 + fileContains('data/system/skills/meta-execute/SKILL.md', '服从纪律'),
  95 + 'meta-execute 包含"服从纪律"约束'
  96 + );
  97 + assert(
  98 + fileContains('data/system/skills/meta-verify/SKILL.md', '_lessons.md'),
  99 + 'meta-verify 引用 _lessons.md 防坑机制'
  100 + );
  101 +}
  102 +
  103 +// ═══════════════════════════════════════
  104 +// Test 2: Base Agent Template 完整性
  105 +// ═══════════════════════════════════════
  106 +function test2_baseTemplate() {
  107 + console.log('\n📦 Test 2: Base Agent Template 完整性');
  108 + assert(fileExists('data/system/templates/base/agent.md'), 'base/agent.md 存在');
  109 + assert(fileExists('data/system/templates/base/config.json'), 'base/config.json 存在');
  110 +
  111 + // config.json 校验工具策略
  112 + const config = JSON.parse(fs.readFileSync(path.join(ROOT, 'data/system/templates/base/config.json'), 'utf-8'));
  113 + assert(config.cli?.toolPolicy?.mode === 'restricted', 'toolPolicy.mode = restricted');
  114 + assert(
  115 + Array.isArray(config.cli?.toolPolicy?.allowedTools) && config.cli.toolPolicy.allowedTools.includes('bash'),
  116 + 'allowedTools 包含 bash'
  117 + );
  118 +
  119 + // agent.md 校验四阶段规范
  120 + assert(
  121 + fileContains('data/system/templates/base/agent.md', 'Research') &&
  122 + fileContains('data/system/templates/base/agent.md', 'Plan') &&
  123 + fileContains('data/system/templates/base/agent.md', 'Execute') &&
  124 + fileContains('data/system/templates/base/agent.md', 'Verify'),
  125 + 'agent.md 包含 RPEV 四阶段关键词'
  126 + );
  127 + assert(
  128 + fileContains('data/system/templates/base/agent.md', 'task transition'),
  129 + 'agent.md 引用 task transition CLI 命令'
  130 + );
  131 +
  132 + // _registry.json 校验
  133 + assert(fileExists('data/system/templates/_registry.json'), '_registry.json 存在');
  134 + const registry = JSON.parse(fs.readFileSync(path.join(ROOT, 'data/system/templates/_registry.json'), 'utf-8'));
  135 + assert(registry.some(t => t.id === 'base'), '_registry.json 包含 base 模板');
  136 +}
  137 +
  138 +// ═══════════════════════════════════════
  139 +// Test 3: Hot Swap 换脑 — 模拟文件替换
  140 +// ═══════════════════════════════════════
  141 +function test3_hotSwap() {
  142 + console.log('\n🧠 Test 3: Hot Swap 模拟换脑');
  143 +
  144 + // 创建临时测试 Agent 目录
  145 + const testAgentDir = path.join(DATA, 'agents', '_test_harness_agent');
  146 + const skillDir = path.join(testAgentDir, '.claude', 'skills', 'main_skill');
  147 + fs.mkdirSync(skillDir, { recursive: true });
  148 + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Original Skill\nI am the original.', 'utf-8');
  149 + fs.writeFileSync(path.join(testAgentDir, 'config.json'), JSON.stringify({ id: '_test_harness_agent', type: 'skill', status: 'idle' }), 'utf-8');
  150 + fs.writeFileSync(path.join(testAgentDir, 'identity.md'), '**名称:** 测试小兵\n**Emoji:** 🧪', 'utf-8');
  151 +
  152 + // 模拟 Hot Swap: 复制 meta-plan 到 main_skill
  153 + const sourceSkill = path.join(ROOT, 'data/system/skills/meta-plan/SKILL.md');
  154 + if (fs.existsSync(sourceSkill)) {
  155 + fs.copyFileSync(sourceSkill, path.join(skillDir, 'SKILL.md'));
  156 + const content = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf-8');
  157 + assert(content.includes('meta-plan'), 'Hot Swap 后 SKILL.md 内容变为 meta-plan');
  158 + assert(!content.includes('Original Skill'), 'Hot Swap 后原始内容已被覆盖');
  159 + } else {
  160 + assert(false, 'Hot Swap 测试', '源技能文件 meta-plan/SKILL.md 不存在');
  161 + }
  162 +
  163 + // 清理
  164 + fs.rmSync(testAgentDir, { recursive: true, force: true });
  165 +}
  166 +
  167 +// ═══════════════════════════════════════
  168 +// Test 4: Context Flush 历史清空模拟
  169 +// ═══════════════════════════════════════
  170 +function test4_contextFlush() {
  171 + console.log('\n🛑 Test 4: Context Flush 记忆断舍离模拟');
  172 +
  173 + const testAgentDir = path.join(DATA, 'agents', '_test_flush_agent');
  174 + const convDir = path.join(testAgentDir, 'conversations', 'test-conv-001');
  175 + fs.mkdirSync(convDir, { recursive: true });
  176 +
  177 + // 模拟 history.json 有大量历史
  178 + const fakeHistory = Array.from({ length: 50 }, (_, i) => ({
  179 + role: i % 2 === 0 ? 'user' : 'assistant',
  180 + content: `Message ${i}: ${'x'.repeat(200)}`,
  181 + timestamp: new Date().toISOString()
  182 + }));
  183 + fs.writeFileSync(path.join(convDir, 'history.json'), JSON.stringify(fakeHistory), 'utf-8');
  184 +
  185 + assert(JSON.parse(fs.readFileSync(path.join(convDir, 'history.json'), 'utf-8')).length === 50,
  186 + '预置 50 条历史消息');
  187 +
  188 + // 模拟 Context Flush: 清空 history
  189 + fs.writeFileSync(path.join(convDir, 'history.json'), '[]', 'utf-8');
  190 + const afterFlush = JSON.parse(fs.readFileSync(path.join(convDir, 'history.json'), 'utf-8'));
  191 + assert(afterFlush.length === 0, 'Context Flush 后 history.json 被清空为空数组');
  192 +
  193 + // 模拟注入新起步信息
  194 + afterFlush.push({
  195 + role: 'user',
  196 + content: '【系统指令:强制状态切换】你已进入 PLAN 阶段。',
  197 + messageId: 'msg-flush-test',
  198 + timestamp: new Date().toISOString()
  199 + });
  200 + fs.writeFileSync(path.join(convDir, 'history.json'), JSON.stringify(afterFlush), 'utf-8');
  201 + const restored = JSON.parse(fs.readFileSync(path.join(convDir, 'history.json'), 'utf-8'));
  202 + assert(restored.length === 1 && restored[0].content.includes('PLAN'), '注入新阶段起步指令成功');
  203 +
  204 + // 清理
  205 + fs.rmSync(testAgentDir, { recursive: true, force: true });
  206 +}
  207 +
  208 +// ═══════════════════════════════════════
  209 +// Test 5: _lessons.md 注入 System Prompt
  210 +// ═══════════════════════════════════════
  211 +function test5_lessonsInjection() {
  212 + console.log('\n⚠️ Test 5: _lessons.md 防坑注入');
  213 +
  214 + // 模拟 buildSystemPrompt 中 _lessons.md 的读取逻辑
  215 + const testAgentDir = path.join(DATA, 'agents', '_test_lessons_agent');
  216 + const wsDir = path.join(testAgentDir, 'workspace');
  217 + fs.mkdirSync(wsDir, { recursive: true });
  218 + fs.writeFileSync(path.join(testAgentDir, 'config.json'), JSON.stringify({ id: '_test_lessons_agent', type: 'skill' }), 'utf-8');
  219 +
  220 + // 无 _lessons.md 时
  221 + const lessonsPath = path.join(wsDir, '_lessons.md');
  222 + assert(!fs.existsSync(lessonsPath), '初始状态无 _lessons.md');
  223 +
  224 + // 写入教训
  225 + fs.writeFileSync(lessonsPath, `# 试错记录
  226 +- 2026-03-26: Python2 语法在 node 22 环境下不兼容, 必须用 python3
  227 +- 2026-03-26: 不要用 fs.existsSync 检查远程 URL, 应该用 http.get
  228 +`, 'utf-8');
  229 +
  230 + const content = fs.readFileSync(lessonsPath, 'utf-8');
  231 + assert(content.includes('python3'), '_lessons.md 包含真实教训');
  232 + assert(content.includes('fs.existsSync'), '_lessons.md 包含第二条防坑记录');
  233 +
  234 + // 清理
  235 + fs.rmSync(testAgentDir, { recursive: true, force: true });
  236 +}
  237 +
  238 +// ═══════════════════════════════════════
  239 +// Test 6: billing.log 计费格式
  240 +// ═══════════════════════════════════════
  241 +function test6_billingLog() {
  242 + console.log('\n💰 Test 6: billing.log 计费日志');
  243 +
  244 + const logsDir = path.join(DATA, 'logs');
  245 + if (!fs.existsSync(logsDir)) fs.mkdirSync(logsDir, { recursive: true });
  246 + const billingPath = path.join(logsDir, 'billing.log');
  247 +
  248 + // 模拟写入一条计费记录
  249 + const testEntry = `${new Date().toISOString()} | Agent: _test_billing | Conv: test-conv | Cost: $0.003200 | Duration: 4521ms\n`;
  250 + fs.appendFileSync(billingPath, testEntry, 'utf-8');
  251 +
  252 + const content = fs.readFileSync(billingPath, 'utf-8');
  253 + assert(content.includes('_test_billing'), 'billing.log 包含测试 Agent ID');
  254 + assert(content.includes('Cost: $'), 'billing.log 包含美元成本字段');
  255 + assert(content.includes('Duration:'), 'billing.log 包含耗时字段');
  256 +
  257 + // 清理测试行(不删文件,只清测试数据)
  258 + const cleanedContent = content.split('\n').filter(l => !l.includes('_test_billing')).join('\n');
  259 + fs.writeFileSync(billingPath, cleanedContent, 'utf-8');
  260 +}
  261 +
  262 +// ═══════════════════════════════════════
  263 +// Test 7: GoalsService 新增状态兼容
  264 +// ═══════════════════════════════════════
  265 +function test7_goalsServiceStates() {
  266 + console.log('\n🎯 Test 7: GoalsService 新增状态兼容');
  267 +
  268 + const { GoalsService } = require(path.join(ROOT, 'server/services/goals.js'));
  269 + const svc = new GoalsService(); // 使用全局 goals dir
  270 +
  271 + // 验证源码中有新状态
  272 + const goalsSource = fs.readFileSync(path.join(ROOT, 'server/services/goals.js'), 'utf-8');
  273 + assert(goalsSource.includes('researching'), 'GoalsService 源码包含 researching 状态');
  274 + assert(goalsSource.includes('planning'), 'GoalsService 源码包含 planning 状态');
  275 + assert(goalsSource.includes('executing'), 'GoalsService 源码包含 executing 状态');
  276 + assert(goalsSource.includes('verifying'), 'GoalsService 源码包含 verifying 状态');
  277 + assert(goalsSource.includes('pending_approval'), 'GoalsService 源码包含 pending_approval 状态');
  278 +}
  279 +
  280 +// ═══════════════════════════════════════
  281 +// Test 8: CLI task transition 命令注册
  282 +// ═══════════════════════════════════════
  283 +function test8_cliRegistration() {
  284 + console.log('\n🔧 Test 8: CLI task transition 命令注册');
  285 +
  286 + const lemonSrc = fs.readFileSync(path.join(ROOT, 'bin/lemon.js'), 'utf-8');
  287 + assert(lemonSrc.includes("'task transition'"), "bin/lemon.js 注册了 'task transition' 命令");
  288 + assert(lemonSrc.includes("phase: { type: 'string'"), 'task transition 接受 phase 参数');
  289 + assert(lemonSrc.includes("artifact: { type: 'string'"), 'task transition 接受 artifact 参数');
  290 + assert(lemonSrc.includes("agentId: { type: 'string'"), 'task transition 接受 agentId 参数');
  291 +}
  292 +
  293 +// ═══════════════════════════════════════
  294 +// Test 9: agent-chat.js Transition 路由注册
  295 +// ═══════════════════════════════════════
  296 +function test9_transitionRoute() {
  297 + console.log('\n🌐 Test 9: Transition API 路由注册');
  298 +
  299 + const routeSrc = fs.readFileSync(path.join(ROOT, 'server/routes/agent-chat.js'), 'utf-8');
  300 + assert(routeSrc.includes("/:agentId/task/transition"), '存在 /:agentId/task/transition 路由');
  301 + assert(routeSrc.includes('Hot Swap'), '路由包含 Hot Swap 换脑逻辑');
  302 + assert(routeSrc.includes('Context Flush'), '路由包含 Context Flush 逻辑');
  303 + assert(routeSrc.includes('pending_approval'), '路由包含 P0 审批拦截逻辑');
  304 + assert(routeSrc.includes('billing.log'), '路由包含 billing.log 计费写入');
  305 + assert(routeSrc.includes('_lessons.md'), '路由包含 _lessons.md 注入');
  306 +}
  307 +
  308 +// ═══════════════════════════════════════
  309 +// Test 10 (Online): 真实 API 调用测试
  310 +// ═══════════════════════════════════════
  311 +async function test10_onlineAPI() {
  312 + console.log('\n🌍 Test 10: 在线 API 调用测试 (需要服务器)');
  313 +
  314 + try {
  315 + // 10a: 调用 transition API (对一个不存在的 Agent 应返回 404)
  316 + const res1 = await httpPost('/agent/_nonexistent_agent/task/transition', { phase: 'plan' });
  317 + assert(res1.status === 404, 'Transition API 对不存在的 Agent 返回 404');
  318 +
  319 + // 10b: 如果 master Agent 存在, 尝试对它做一次 research -> plan 的切换
  320 + const res2 = await httpPost('/agent/master/task/transition', { phase: 'plan', artifact: '' });
  321 + assert(res2.status === 200 && res2.body?.success === true, 'Master Agent transition(plan) 返回 success');
  322 +
  323 + // 10c: 检查 master 的 main_skill 是否被换成了 meta-plan
  324 + const masterSkillPath = path.join(ROOT, 'data/system/agents/master/.claude/skills/main_skill/SKILL.md');
  325 + if (fs.existsSync(masterSkillPath)) {
  326 + const content = fs.readFileSync(masterSkillPath, 'utf-8');
  327 + assert(content.includes('meta-plan'), 'Master 的 main_skill 已被换为 meta-plan');
  328 + } else {
  329 + assert(false, 'Master 的 main_skill/SKILL.md 不存在(可能目录结构不同)');
  330 + }
  331 + } catch (err) {
  332 + assert(false, '在线 API 测试', `服务器连接失败: ${err.message}. 请先启动服务器 (node server/index.js)`);
  333 + }
  334 +}
  335 +
  336 +// ═══════════════════════════════════════
  337 +// Runner
  338 +// ═══════════════════════════════════════
  339 +async function main() {
  340 + console.log('═══════════════════════════════════════');
  341 + console.log(' 🏗️ Harness Engineering 测试套件');
  342 + console.log('═══════════════════════════════════════');
  343 +
  344 + const isOnline = process.argv.includes('--online');
  345 +
  346 + test1_metaSkillFiles();
  347 + test2_baseTemplate();
  348 + test3_hotSwap();
  349 + test4_contextFlush();
  350 + test5_lessonsInjection();
  351 + test6_billingLog();
  352 + test7_goalsServiceStates();
  353 + test8_cliRegistration();
  354 + test9_transitionRoute();
  355 +
  356 + if (isOnline) {
  357 + await test10_onlineAPI();
  358 + } else {
  359 + console.log('\n⏭️ 跳过在线测试 (使用 --online 参数启用)');
  360 + }
  361 +
  362 + // 结果汇总
  363 + console.log('\n═══════════════════════════════════════');
  364 + console.log(` 📊 测试结果: ${passed} 通过, ${failed} 失败, 共 ${passed + failed} 项`);
  365 + if (failed > 0) {
  366 + console.log('\n 失败项:');
  367 + results.filter(r => r.status === '❌').forEach(r => {
  368 + console.log(` ❌ ${r.testName}: ${r.detail}`);
  369 + });
  370 + }
  371 + console.log('═══════════════════════════════════════\n');
  372 +
  373 + process.exit(failed > 0 ? 1 : 0);
  374 +}
  375 +
  376 +main();