test-harness.js 17.4 KB
#!/usr/bin/env node
/**
 * test-harness.js — Harness Engineering 四阶段流转手工测试脚本
 *
 * 测试覆盖:
 *   ✅ Test 1: Meta-Skill 文件存在性检查
 *   ✅ Test 2: Base Agent Template 完整性检查
 *   ✅ Test 3: Task Transition API — Hot Swap 换脑验证
 *   ✅ Test 4: Task Transition API — Context Flush 记忆断舍离
 *   ✅ Test 5: P0 事前阻断阀 (Human-in-the-loop)
 *   ✅ Test 6: _lessons.md 注入 System Prompt
 *   ✅ Test 7: billing.log 计费日志
 *   ✅ Test 8: OKR GoalsService 新增状态兼容
 *   ✅ Test 9: CLI `task transition` 命令注册
 *
 * 用法:
 *   node scripts/test-harness.js          # 运行全部离线测试 (不需要服务器)
 *   node scripts/test-harness.js --online # 运行含 API 调用的在线测试 (需要服务器在 3000 端口)
 */

const fs = require('fs');
const path = require('path');
const http = require('http');

const ROOT = path.resolve(__dirname, '..');
const DATA = path.join(ROOT, 'data');
const API_BASE = 'http://localhost:3000/api';

let passed = 0;
let failed = 0;
const results = [];

function assert(condition, testName, detail = '') {
  if (condition) {
    passed++;
    results.push({ status: '✅', testName });
    console.log(`  ✅ ${testName}`);
  } else {
    failed++;
    results.push({ status: '❌', testName, detail });
    console.log(`  ❌ ${testName}${detail ? ` — ${detail}` : ''}`);
  }
}

function fileExists(relPath) {
  return fs.existsSync(path.join(ROOT, relPath));
}

function fileContains(relPath, needle) {
  if (!fileExists(relPath)) return false;
  return fs.readFileSync(path.join(ROOT, relPath), 'utf-8').includes(needle);
}

function httpPost(urlPath, body) {
  return new Promise((resolve, reject) => {
    const postData = JSON.stringify(body);
    const url = new URL(API_BASE + urlPath);
    const req = http.request({
      hostname: url.hostname,
      port: url.port,
      path: url.pathname,
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) },
    }, (res) => {
      let data = '';
      res.on('data', c => data += c);
      res.on('end', () => {
        try { resolve({ status: res.statusCode, body: JSON.parse(data) }); }
        catch { resolve({ status: res.statusCode, body: data }); }
      });
    });
    req.on('error', reject);
    req.write(postData);
    req.end();
  });
}

// ═══════════════════════════════════════
//  Test 1: Meta-Skill 文件存在性
// ═══════════════════════════════════════
function test1_metaSkillFiles() {
  console.log('\n📦 Test 1: Meta-Skill 文件存在性');
  const phases = ['meta-research', 'meta-plan', 'meta-execute', 'meta-verify'];
  for (const phase of phases) {
    const skillPath = `system/skills/${phase}/SKILL.md`;
    assert(fileExists(skillPath), `${phase}/SKILL.md 存在`, `缺失: ${skillPath}`);
  }
  // 内容校验
  assert(
    fileContains('system/skills/meta-research/SKILL.md', '调研'),
    'meta-research 包含"调研"关键词'
  );
  assert(
    fileContains('system/skills/meta-execute/SKILL.md', '服从纪律'),
    'meta-execute 包含"服从纪律"约束'
  );
  assert(
    fileContains('system/skills/meta-verify/SKILL.md', '_lessons.md'),
    'meta-verify 引用 _lessons.md 防坑机制'
  );
}

// ═══════════════════════════════════════
//  Test 2: Base Agent Template 完整性
// ═══════════════════════════════════════
function test2_baseTemplate() {
  console.log('\n📦 Test 2: Base Agent Template 完整性');
  assert(fileExists('system/templates/base/agent.md'), 'base/agent.md 存在');
  assert(fileExists('system/templates/base/config.json'), 'base/config.json 存在');
  
  // config.json 校验工具策略
  const config = JSON.parse(fs.readFileSync(path.join(ROOT, 'system/templates/base/config.json'), 'utf-8'));
  assert(config.cli?.toolPolicy?.mode === 'restricted', 'toolPolicy.mode = restricted');
  assert(
    Array.isArray(config.cli?.toolPolicy?.allowedTools) && config.cli.toolPolicy.allowedTools.includes('bash'),
    'allowedTools 包含 bash'
  );
  
  // agent.md 校验四阶段规范
  assert(
    fileContains('system/templates/base/agent.md', 'Research') &&
    fileContains('system/templates/base/agent.md', 'Plan') &&
    fileContains('system/templates/base/agent.md', 'Execute') &&
    fileContains('system/templates/base/agent.md', 'Verify'),
    'agent.md 包含 RPEV 四阶段关键词'
  );
  assert(
    fileContains('system/templates/base/agent.md', 'task transition'),
    'agent.md 引用 task transition CLI 命令'
  );

  // _registry.json 校验
  assert(fileExists('system/templates/_registry.json'), '_registry.json 存在');
  const registry = JSON.parse(fs.readFileSync(path.join(ROOT, 'system/templates/_registry.json'), 'utf-8'));
  assert(registry.some(t => t.id === 'base'), '_registry.json 包含 base 模板');
}

// ═══════════════════════════════════════
//  Test 3: Hot Swap 换脑 — 模拟文件替换
// ═══════════════════════════════════════
function test3_hotSwap() {
  console.log('\n🧠 Test 3: Hot Swap 模拟换脑');
  
  // 创建临时测试 Agent 目录
  const testAgentDir = path.join(DATA, 'agents', '_test_harness_agent');
  const skillDir = path.join(testAgentDir, '.claude', 'skills', 'main_skill');
  fs.mkdirSync(skillDir, { recursive: true });
  fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Original Skill\nI am the original.', 'utf-8');
  fs.writeFileSync(path.join(testAgentDir, 'config.json'), JSON.stringify({ id: '_test_harness_agent', type: 'skill', status: 'idle' }), 'utf-8');
  fs.writeFileSync(path.join(testAgentDir, 'identity.md'), '**名称:** 测试小兵\n**Emoji:** 🧪', 'utf-8');
  
  // 模拟 Hot Swap: 复制 meta-plan 到 main_skill
  const sourceSkill = path.join(ROOT, 'system/skills/meta-plan/SKILL.md');
  if (fs.existsSync(sourceSkill)) {
    fs.copyFileSync(sourceSkill, path.join(skillDir, 'SKILL.md'));
    const content = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf-8');
    assert(content.includes('meta-plan'), 'Hot Swap 后 SKILL.md 内容变为 meta-plan');
    assert(!content.includes('Original Skill'), 'Hot Swap 后原始内容已被覆盖');
  } else {
    assert(false, 'Hot Swap 测试', '源技能文件 meta-plan/SKILL.md 不存在');
  }
  
  // 清理
  fs.rmSync(testAgentDir, { recursive: true, force: true });
}

// ═══════════════════════════════════════
//  Test 4: Context Flush 历史清空模拟
// ═══════════════════════════════════════
function test4_contextFlush() {
  console.log('\n🛑 Test 4: Context Flush 记忆断舍离模拟');
  
  const testAgentDir = path.join(DATA, 'agents', '_test_flush_agent');
  const convDir = path.join(testAgentDir, 'conversations', 'test-conv-001');
  fs.mkdirSync(convDir, { recursive: true });
  
  // 模拟 history.json 有大量历史
  const fakeHistory = Array.from({ length: 50 }, (_, i) => ({
    role: i % 2 === 0 ? 'user' : 'assistant',
    content: `Message ${i}: ${'x'.repeat(200)}`,
    timestamp: new Date().toISOString()
  }));
  fs.writeFileSync(path.join(convDir, 'history.json'), JSON.stringify(fakeHistory), 'utf-8');
  
  assert(JSON.parse(fs.readFileSync(path.join(convDir, 'history.json'), 'utf-8')).length === 50,
    '预置 50 条历史消息');
  
  // 模拟 Context Flush: 清空 history
  fs.writeFileSync(path.join(convDir, 'history.json'), '[]', 'utf-8');
  const afterFlush = JSON.parse(fs.readFileSync(path.join(convDir, 'history.json'), 'utf-8'));
  assert(afterFlush.length === 0, 'Context Flush 后 history.json 被清空为空数组');
  
  // 模拟注入新起步信息
  afterFlush.push({
    role: 'user',
    content: '【系统指令:强制状态切换】你已进入 PLAN 阶段。',
    messageId: 'msg-flush-test',
    timestamp: new Date().toISOString()
  });
  fs.writeFileSync(path.join(convDir, 'history.json'), JSON.stringify(afterFlush), 'utf-8');
  const restored = JSON.parse(fs.readFileSync(path.join(convDir, 'history.json'), 'utf-8'));
  assert(restored.length === 1 && restored[0].content.includes('PLAN'), '注入新阶段起步指令成功');
  
  // 清理
  fs.rmSync(testAgentDir, { recursive: true, force: true });
}

// ═══════════════════════════════════════
//  Test 5: _lessons.md 注入 System Prompt
// ═══════════════════════════════════════
function test5_lessonsInjection() {
  console.log('\n⚠️  Test 5: _lessons.md 防坑注入');
  
  // 模拟 buildSystemPrompt 中 _lessons.md 的读取逻辑
  const testAgentDir = path.join(DATA, 'agents', '_test_lessons_agent');
  const wsDir = path.join(testAgentDir, 'workspace');
  fs.mkdirSync(wsDir, { recursive: true });
  fs.writeFileSync(path.join(testAgentDir, 'config.json'), JSON.stringify({ id: '_test_lessons_agent', type: 'skill' }), 'utf-8');
  
  // 无 _lessons.md 时
  const lessonsPath = path.join(wsDir, '_lessons.md');
  assert(!fs.existsSync(lessonsPath), '初始状态无 _lessons.md');
  
  // 写入教训
  fs.writeFileSync(lessonsPath, `# 试错记录
- 2026-03-26: Python2 语法在 node 22 环境下不兼容, 必须用 python3
- 2026-03-26: 不要用 fs.existsSync 检查远程 URL, 应该用 http.get
`, 'utf-8');
  
  const content = fs.readFileSync(lessonsPath, 'utf-8');
  assert(content.includes('python3'), '_lessons.md 包含真实教训');
  assert(content.includes('fs.existsSync'), '_lessons.md 包含第二条防坑记录');
  
  // 清理
  fs.rmSync(testAgentDir, { recursive: true, force: true });
}

// ═══════════════════════════════════════
//  Test 6: billing.log 计费格式
// ═══════════════════════════════════════
function test6_billingLog() {
  console.log('\n💰 Test 6: billing.log 计费日志');
  
  const logsDir = path.join(DATA, 'logs');
  if (!fs.existsSync(logsDir)) fs.mkdirSync(logsDir, { recursive: true });
  const billingPath = path.join(logsDir, 'billing.log');
  
  // 模拟写入一条计费记录
  const testEntry = `${new Date().toISOString()} | Agent: _test_billing | Conv: test-conv | Cost: $0.003200 | Duration: 4521ms\n`;
  fs.appendFileSync(billingPath, testEntry, 'utf-8');
  
  const content = fs.readFileSync(billingPath, 'utf-8');
  assert(content.includes('_test_billing'), 'billing.log 包含测试 Agent ID');
  assert(content.includes('Cost: $'), 'billing.log 包含美元成本字段');
  assert(content.includes('Duration:'), 'billing.log 包含耗时字段');
  
  // 清理测试行(不删文件,只清测试数据)
  const cleanedContent = content.split('\n').filter(l => !l.includes('_test_billing')).join('\n');
  fs.writeFileSync(billingPath, cleanedContent, 'utf-8');
}

// ═══════════════════════════════════════
//  Test 7: GoalsService 新增状态兼容
// ═══════════════════════════════════════
function test7_goalsServiceStates() {
  console.log('\n🎯 Test 7: GoalsService 新增状态兼容');
  
  const { GoalsService } = require(path.join(ROOT, 'server/services/goals.js'));
  const svc = new GoalsService(); // 使用全局 goals dir
  
  // 验证源码中有新状态
  const goalsSource = fs.readFileSync(path.join(ROOT, 'server/services/goals.js'), 'utf-8');
  assert(goalsSource.includes('researching'), 'GoalsService 源码包含 researching 状态');
  assert(goalsSource.includes('planning'), 'GoalsService 源码包含 planning 状态');
  assert(goalsSource.includes('executing'), 'GoalsService 源码包含 executing 状态');
  assert(goalsSource.includes('verifying'), 'GoalsService 源码包含 verifying 状态');
  assert(goalsSource.includes('pending_approval'), 'GoalsService 源码包含 pending_approval 状态');
}

// ═══════════════════════════════════════
//  Test 8: CLI task transition 命令注册
// ═══════════════════════════════════════
function test8_cliRegistration() {
  console.log('\n🔧 Test 8: CLI task transition 命令注册');
  
  const lemonSrc = fs.readFileSync(path.join(ROOT, 'bin/lemon.js'), 'utf-8');
  assert(lemonSrc.includes("'task transition'"), "bin/lemon.js 注册了 'task transition' 命令");
  assert(lemonSrc.includes("phase: { type: 'string'"), 'task transition 接受 phase 参数');
  assert(lemonSrc.includes("artifact: { type: 'string'"), 'task transition 接受 artifact 参数');
  assert(lemonSrc.includes("agentId: { type: 'string'"), 'task transition 接受 agentId 参数');
}

// ═══════════════════════════════════════
//  Test 9: agent-chat.js Transition 路由注册
// ═══════════════════════════════════════
function test9_transitionRoute() {
  console.log('\n🌐 Test 9: Transition API 路由注册');
  
  const routeSrc = fs.readFileSync(path.join(ROOT, 'server/routes/agent-chat.js'), 'utf-8');
  assert(routeSrc.includes("/:agentId/task/transition"), '存在 /:agentId/task/transition 路由');
  assert(routeSrc.includes('Hot Swap'), '路由包含 Hot Swap 换脑逻辑');
  assert(routeSrc.includes('Context Flush'), '路由包含 Context Flush 逻辑');
  assert(routeSrc.includes('pending_approval'), '路由包含 P0 审批拦截逻辑');
  assert(routeSrc.includes('billing.log'), '路由包含 billing.log 计费写入');
  assert(routeSrc.includes('_lessons.md'), '路由包含 _lessons.md 注入');
}

// ═══════════════════════════════════════
//  Test 10 (Online): 真实 API 调用测试
// ═══════════════════════════════════════
async function test10_onlineAPI() {
  console.log('\n🌍 Test 10: 在线 API 调用测试 (需要服务器)');
  
  try {
    // 10a: 调用 transition API (对一个不存在的 Agent 应返回 404)
    const res1 = await httpPost('/agent/_nonexistent_agent/task/transition', { phase: 'plan' });
    assert(res1.status === 404, 'Transition API 对不存在的 Agent 返回 404');
    
    // 10b: 如果 master Agent 存在, 尝试对它做一次 research -> plan 的切换
    const res2 = await httpPost('/agent/master/task/transition', { phase: 'plan', artifact: '' });
    assert(res2.status === 200 && res2.body?.success === true, 'Master Agent transition(plan) 返回 success');
    
    // 10c: 检查 master 的 main_skill 是否被换成了 meta-plan
    const masterSkillPath = path.join(ROOT, 'system/agents/master/.claude/skills/main_skill/SKILL.md');
    if (fs.existsSync(masterSkillPath)) {
      const content = fs.readFileSync(masterSkillPath, 'utf-8');
      assert(content.includes('meta-plan'), 'Master 的 main_skill 已被换为 meta-plan');
    } else {
      assert(false, 'Master 的 main_skill/SKILL.md 不存在(可能目录结构不同)');
    }
  } catch (err) {
    assert(false, '在线 API 测试', `服务器连接失败: ${err.message}. 请先启动服务器 (node server/index.js)`);
  }
}

// ═══════════════════════════════════════
//  Runner
// ═══════════════════════════════════════
async function main() {
  console.log('═══════════════════════════════════════');
  console.log(' 🏗️  Harness Engineering 测试套件');
  console.log('═══════════════════════════════════════');
  
  const isOnline = process.argv.includes('--online');
  
  test1_metaSkillFiles();
  test2_baseTemplate();
  test3_hotSwap();
  test4_contextFlush();
  test5_lessonsInjection();
  test6_billingLog();
  test7_goalsServiceStates();
  test8_cliRegistration();
  test9_transitionRoute();
  
  if (isOnline) {
    await test10_onlineAPI();
  } else {
    console.log('\n⏭️  跳过在线测试 (使用 --online 参数启用)');
  }
  
  // 结果汇总
  console.log('\n═══════════════════════════════════════');
  console.log(` 📊 测试结果: ${passed} 通过, ${failed} 失败, 共 ${passed + failed} 项`);
  if (failed > 0) {
    console.log('\n 失败项:');
    results.filter(r => r.status === '❌').forEach(r => {
      console.log(`   ❌ ${r.testName}: ${r.detail}`);
    });
  }
  console.log('═══════════════════════════════════════\n');
  
  process.exit(failed > 0 ? 1 : 0);
}

main();