test-company-e2e.js 18.5 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418
#!/usr/bin/env node
/**
 * Company Layer — E2E 测试脚本
 *
 * 测试内容:
 *   1. Company CRUD (创建、查询、列表、更新、关停)
 *   2. 模板库查询
 *   3. Agent 招聘 (模板命中 / 模板未命中)
 *   4. 公司内 Agent 列表
 *   5. Agent 解雇
 *   6. resolveAgentDir 统一寻址
 *
 * 运行: node server/scripts/test-company-e2e.js
 */

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

// 直接引入 service 层 (不需要 HTTP)
const { CompanyService } = require('../services/company-service');
const { GoalsService } = require('../services/goals');
const { PipelineService } = require('../services/pipeline-service');

const DATA_DIR = path.resolve(__dirname, '../../data');
const TEST_COMPANY_ID = 'test-media-co';
const TEST_COMPANY_DIR = path.join(DATA_DIR, TEST_COMPANY_ID);

let passed = 0;
let failed = 0;

function assert(condition, label) {
  if (condition) {
    console.log(`  ✅ ${label}`);
    passed++;
  } else {
    console.error(`  ❌ ${label}`);
    failed++;
  }
}

function cleanup() {
  // 清理测试数据
  if (fs.existsSync(TEST_COMPANY_DIR)) {
    fs.rmSync(TEST_COMPANY_DIR, { recursive: true, force: true });
  }
}

async function main() {
  console.log('\n🏢 Company Layer E2E 测试\n');
  cleanup();

  const svc = new CompanyService();

  // ─── 1. 模板库 ───
  console.log('📦 Phase 1: 模板库');
  const templates = svc.listTemplates();
  assert(Array.isArray(templates), '模板列表返回数组');
  assert(templates.length >= 2, `模板数量 ≥ 2 (实际: ${templates.length})`);

  const ceoTpl = templates.find(t => t.id === 'ceo');
  assert(ceoTpl !== undefined, '存在 CEO 模板');
  assert(ceoTpl && ceoTpl.type === 'cxo', 'CEO 模板类型为 cxo');

  const qaTpl = templates.find(t => t.id === 'qa-judge');
  assert(qaTpl !== undefined, '存在 qa-judge 模板');

  // ─── 2. 创建公司 ───
  console.log('\n🏗️  Phase 2: 创建公司');
  const company = svc.create({
    id: TEST_COMPANY_ID,
    name: '测试自媒体公司',
    description: '一家用于 E2E 测试的自媒体公司',
  });

  assert(company.id === TEST_COMPANY_ID, `公司 ID = ${TEST_COMPANY_ID}`);
  assert(company.status === 'creating', '初始状态为 creating');
  assert(fs.existsSync(path.join(TEST_COMPANY_DIR, 'company.json')), 'company.json 已创建');
  assert(fs.existsSync(path.join(TEST_COMPANY_DIR, 'agents')), 'agents/ 目录已创建');
  assert(fs.existsSync(path.join(TEST_COMPANY_DIR, 'goals')), 'goals/ 目录已创建');
  assert(fs.existsSync(path.join(TEST_COMPANY_DIR, 'workspace')), 'workspace/ 目录已创建');
  assert(fs.existsSync(path.join(TEST_COMPANY_DIR, 'pipelines')), 'pipelines/ 目录已创建');

  // ─── 3. 查询公司 ───
  console.log('\n🔍 Phase 3: 查询公司');
  const fetched = svc.get(TEST_COMPANY_ID);
  assert(fetched !== null, 'get() 返回非空');
  assert(fetched.name === '测试自媒体公司', '公司名称正确');

  const list = svc.list();
  assert(list.some(c => c.id === TEST_COMPANY_ID), 'list() 中包含新公司');

  // ─── 4. 招聘 CEO (从模板) ───
  console.log('\n👔 Phase 4: 招聘 CEO (模板命中)');
  const hireCeo = svc.hire(TEST_COMPANY_ID, 'ceo');
  assert(hireCeo.status === 'hired_from_template', '从模板招聘成功');

  const ceoDir = path.join(TEST_COMPANY_DIR, 'agents', 'ceo');
  assert(fs.existsSync(ceoDir), 'CEO Agent 目录已创建');
  assert(fs.existsSync(path.join(ceoDir, 'agent.md')), 'agent.md 已复制');
  assert(fs.existsSync(path.join(ceoDir, 'soul.md')), 'soul.md 已复制');
  assert(fs.existsSync(path.join(ceoDir, 'config.json')), 'config.json 已复制');
  assert(fs.existsSync(path.join(ceoDir, 'memory', 'memory.md')), 'memory/memory.md 已初始化');

  // 检查上下文注入
  const ceoAgentMd = fs.readFileSync(path.join(ceoDir, 'agent.md'), 'utf-8');
  assert(ceoAgentMd.includes(TEST_COMPANY_ID), 'agent.md 注入了公司 ID');
  assert(ceoAgentMd.includes('测试自媒体公司'), 'agent.md 注入了公司名称');

  const ceoConfig = JSON.parse(fs.readFileSync(path.join(ceoDir, 'config.json'), 'utf-8'));
  assert(ceoConfig.companyId === TEST_COMPANY_ID, 'config.json 注入了 companyId');
  assert(ceoConfig.status === 'active', 'config.json 状态为 active');

  // 检查公司元数据更新
  const companyAfterCeo = svc.get(TEST_COMPANY_ID);
  assert(companyAfterCeo.ceo === 'ceo', 'company.json 中 CEO 已标记');
  assert(companyAfterCeo.agents.includes('ceo'), 'company.json agents 列表包含 ceo');

  // ─── 5. 招聘 qa-judge (从模板) ───
  console.log('\n🔍 Phase 5: 招聘 qa-judge (模板命中)');
  const hireQa = svc.hire(TEST_COMPANY_ID, 'qa-judge');
  assert(hireQa.status === 'hired_from_template', 'qa-judge 从模板招聘成功');

  // ─── 6. Phase 2: hireWithTraining — 模板不存在, 自动创建骨架 ───
  console.log('\n🎓 Phase 6: hireWithTraining (模板不存在 → 自动创建骨架)');
  const hireNew = await svc.hireWithTraining(TEST_COMPANY_ID, 'hot-topic-hunter', {
    type: 'skill',
    name: '热点搜索员',
    description: '全网热点监控、选题建议',
  });
  assert(hireNew.status === 'training', '返回 training 状态');

  const hthDir = path.join(TEST_COMPANY_DIR, 'agents', 'hot-topic-hunter');
  assert(fs.existsSync(hthDir), '热点搜索员骨架目录已创建');
  assert(fs.existsSync(path.join(hthDir, 'agent.md')), 'agent.md 已生成');
  assert(fs.existsSync(path.join(hthDir, 'soul.md')), 'soul.md 已生成');
  assert(fs.existsSync(path.join(hthDir, 'config.json')), 'config.json 已生成');
  assert(fs.existsSync(path.join(hthDir, 'todo.md')), 'todo.md 已生成');
  assert(fs.existsSync(path.join(hthDir, 'memory', 'memory.md')), 'memory/ 已初始化');

  const hthConfig = JSON.parse(fs.readFileSync(path.join(hthDir, 'config.json'), 'utf-8'));
  assert(hthConfig.status === 'training', 'config 状态为 training');
  assert(hthConfig.companyId === TEST_COMPANY_ID, 'config 含 companyId');

  // 检查公司 agents 列表已包含
  const companyWithHth = svc.get(TEST_COMPANY_ID);
  assert(companyWithHth.agents.includes('hot-topic-hunter'), 'company.agents 包含 hot-topic-hunter');

  // ─── 7. Phase 2: depositTemplate — Benchmark 门禁 ───
  console.log('\n🚧 Phase 7: depositTemplate — Benchmark 质量门禁');

  // 7a: 评分不够 → 拒绝
  const rejectResult = svc.depositTemplate(TEST_COMPANY_ID, 'hot-topic-hunter', 80);
  assert(rejectResult.status === 'rejected', '评分 80 被拒绝');
  assert(rejectResult.message.includes('80'), '拒绝消息包含分数');

  // 7b: 评分 ≥ 95 → 通过
  const depositResult = svc.depositTemplate(TEST_COMPANY_ID, 'hot-topic-hunter', 97);
  assert(depositResult.status === 'deposited', '评分 97 成功沉淀');
  assert(depositResult.templateId === 'hot-topic-hunter', '模板 ID 正确');

  // 检查模板已创建
  const tplDir = path.join(DATA_DIR, 'templates', 'hot-topic-hunter');
  assert(fs.existsSync(tplDir), '模板目录已创建');
  assert(fs.existsSync(path.join(tplDir, 'config.json')), '模板 config.json 存在');

  const tplConfig = JSON.parse(fs.readFileSync(path.join(tplDir, 'config.json'), 'utf-8'));
  assert(tplConfig.benchmarkScore === 97, '模板 config 含 benchmarkScore');
  assert(tplConfig.companyId === undefined, '模板 config 不含 companyId (已清除)');
  assert(tplConfig.status === 'ready', '模板 config 状态为 ready');

  // 检查 Agent 状态从 training → active
  const hthConfigAfter = JSON.parse(fs.readFileSync(path.join(hthDir, 'config.json'), 'utf-8'));
  assert(hthConfigAfter.status === 'active', 'Agent 状态从 training → active');

  // 检查模板注册表更新
  const updatedRegistry = svc.listTemplates();
  const hthTemplate = updatedRegistry.find(t => t.id === 'hot-topic-hunter');
  assert(hthTemplate !== undefined, '注册表包含新模板');
  assert(hthTemplate && hthTemplate.benchmarkScore === 97, '注册表分数正确');

  // ─── 8. Phase 2: 新模板可被第二家公司复用 ───
  console.log('\n🔄 Phase 8: 验证新模板可被第二家公司复用');
  const SECOND_COMPANY = 'test-manga-co';
  const secondDir = path.join(DATA_DIR, SECOND_COMPANY);
  try {
    svc.create({ id: SECOND_COMPANY, name: '测试漫剧公司', description: '漫剧测试' });
    const hireFromNewTpl = svc.hire(SECOND_COMPANY, 'hot-topic-hunter');
    assert(hireFromNewTpl.status === 'hired_from_template', '第二家公司从新模板招聘成功');

    const secondHthConfig = JSON.parse(fs.readFileSync(
      path.join(secondDir, 'agents', 'hot-topic-hunter', 'config.json'), 'utf-8'
    ));
    assert(secondHthConfig.companyId === SECOND_COMPANY, '第二家公司 companyId 正确注入');
  } finally {
    if (fs.existsSync(secondDir)) fs.rmSync(secondDir, { recursive: true, force: true });
  }

  // ─── 9. 重复招聘检测 ───
  console.log('\n🔁 Phase 9: 重复招聘检测');
  const hireDup = svc.hire(TEST_COMPANY_ID, 'ceo');
  assert(hireDup.status === 'already_exists', '重复招聘返回 already_exists');

  // ─── 10. 公司 Agent 列表 ───
  console.log('\n📋 Phase 10: 公司 Agent 列表');
  const agents = svc.listAgents(TEST_COMPANY_ID);
  assert(agents.length === 3, `Agent 数量 = 3 (实际: ${agents.length})`);
  assert(agents.some(a => a.id === 'ceo'), '列表包含 CEO');
  assert(agents.some(a => a.id === 'qa-judge'), '列表包含 qa-judge');
  assert(agents.some(a => a.id === 'hot-topic-hunter'), '列表包含 hot-topic-hunter');

  // ─── 11. resolveAgentDir ───
  console.log('\n📍 Phase 11: resolveAgentDir 统一寻址');
  const companyAgentDir = CompanyService.resolveAgentDir(TEST_COMPANY_ID, 'ceo');
  assert(companyAgentDir !== null, '公司内 CEO 可定位');
  assert(companyAgentDir.includes(TEST_COMPANY_ID), '路径包含公司 ID');

  const globalMasterDir = CompanyService.resolveAgentDir(null, 'master');
  assert(globalMasterDir !== null, '全局 Master 可定位');
  assert(globalMasterDir.includes('data/agents/master'), '全局路径正确');

  const notExist = CompanyService.resolveAgentDir(TEST_COMPANY_ID, 'nonexistent-agent');
  assert(notExist === null, '不存在的 Agent 返回 null');

  // ─── 12. 解雇 Agent ───
  console.log('\n🚪 Phase 12: 解雇 Agent');
  const fireResult = svc.fire(TEST_COMPANY_ID, 'qa-judge');
  assert(fireResult.status === 'suspended', '解雇返回 suspended');

  const companyAfterFire = svc.get(TEST_COMPANY_ID);
  assert(!companyAfterFire.agents.includes('qa-judge'), 'qa-judge 已从 agents 列表移除');

  const firedConfig = JSON.parse(fs.readFileSync(path.join(TEST_COMPANY_DIR, 'agents', 'qa-judge', 'config.json'), 'utf-8'));
  assert(firedConfig.status === 'suspended', 'qa-judge config 状态为 suspended');

  // ─── 13. Phase 3: Company Goals (公司级 OKR) ───
  console.log('\n🎯 Phase 13: Company Goals (公司级 OKR)');

  const companyGoals = new GoalsService(TEST_COMPANY_ID);

  // 13a: 创建 Goal
  const goal = companyGoals.create({
    title: '30天粉丝破万',
    description: '通过日更优质内容实现公众号粉丝增长',
    owner: 'ceo',
  });
  assert(goal.id === 'G-001', `Goal ID = G-001 (实际: ${goal.id})`);
  assert(goal.title === '30天粉丝破万', 'Goal 标题正确');

  // 13b: 检查数据存储在公司目录下
  const goalFilePath = path.join(TEST_COMPANY_DIR, 'goals', 'G-001', 'goal.json');
  assert(fs.existsSync(goalFilePath), 'Goal 存储在公司目录下');

  // 13c: 添加节点 (Objective + Key Results)
  const obj = companyGoals.addNode('G-001', null, {
    title: '建立内容产出流水线',
    owner: 'ceo',
    priority: 'P0',
  });
  assert(obj.nodeId === 'G-001.1', `Objective ID = G-001.1 (实际: ${obj.nodeId})`);

  const kr1 = companyGoals.addNode('G-001', 'G-001.1', {
    title: '日更1篇高质量文章',
    owner: 'content-writer',
    priority: 'P0',
  });
  assert(kr1.nodeId === 'G-001.1.1', `KR ID = G-001.1.1 (实际: ${kr1.nodeId})`);

  const kr2 = companyGoals.addNode('G-001', 'G-001.1', {
    title: '建立选题数据库 (≥100 条)',
    owner: 'hot-topic-hunter',
    priority: 'P1',
  });
  assert(kr2.nodeId === 'G-001.1.2', `KR2 ID = G-001.1.2 (实际: ${kr2.nodeId})`);

  // 13d: 更新节点状态
  companyGoals.updateNode('G-001', 'G-001.1.1', { status: 'in_progress' });
  const updatedNode = companyGoals.getNode('G-001', 'G-001.1.1');
  assert(updatedNode.status === 'in_progress', '节点状态更新为 in_progress');

  // 13e: 进度计算
  const progress = companyGoals.getProgress('G-001');
  assert(progress.total === 2, `叶子节点总数 = 2 (实际: ${progress.total})`);
  assert(progress.inProgress === 1, `进行中 = 1 (实际: ${progress.inProgress})`);

  // 13f: Goal list
  const goalList = companyGoals.list();
  assert(goalList.length === 1, `Goal 列表长度 = 1 (实际: ${goalList.length})`);
  assert(goalList[0].id === 'G-001', '列表中 Goal ID 正确');

  // 13g: 验证 OKR 是公司隔离的 (全局 GoalsService 看不到公司的目标)
  const globalGoals = new GoalsService();
  const globalList = globalGoals.list();
  const hasCompanyGoal = globalList.some(g => g.id === 'G-001' && g.title === '30天粉丝破万');
  assert(!hasCompanyGoal, '全局 GoalsService 看不到公司级 OKR (隔离)');

  // ─── 14. Phase 4: Pipeline SOP 流水线 ───
  console.log('\n🛠️  Phase 14: Pipeline SOP 流水线');

  const pSvc = new PipelineService(TEST_COMPANY_ID);

  // 14a: 创建流水线
  const pipeline = pSvc.createPipeline({
    id: 'daily-content',
    name: '每日内容产出',
    description: '选题 → 写稿 → 审核 → 发布',
    steps: [
      { name: '热点搜索与选题', agent: 'hot-topic-hunter', prompt: '搜索今日热点并提供3个选题建议' },
      { name: '内容写作', agent: 'content-writer', prompt: '根据选题写一篇3000字文章' },
      { name: '质量审核', agent: 'qa-judge', prompt: '审核文章质量,给出修改意见' },
      { name: '发布推送', agent: 'publisher', prompt: '排版并发布到公众号' },
    ],
  });
  assert(pipeline.id === 'daily-content', '流水线 ID 正确');
  assert(pipeline.steps.length === 4, `步骤数 = 4 (实际: ${pipeline.steps.length})`);
  assert(pipeline.steps[0].agent === 'hot-topic-hunter', '第1步 agent 正确');
  assert(pipeline.steps[1].dependsOn === 0, '第2步依赖第1步');

  // 14b: 列出流水线
  const pipelines = pSvc.listPipelines();
  assert(pipelines.length === 1, `流水线数量 = 1 (实际: ${pipelines.length})`);

  // 14c: 获取流水线定义
  const fetPipeline = pSvc.getPipeline('daily-content');
  assert(fetPipeline !== null, '获取流水线定义成功');
  assert(fetPipeline.name === '每日内容产出', '流水线名称正确');

  // 14d: 启动运行
  const run = pSvc.startRun('daily-content', {
    trigger: 'heartbeat',
    input: { date: '2026-03-25' },
  });
  assert(run.status === 'running', 'Run 状态为 running');
  assert(run.steps.length === 4, `Run 步骤数 = 4`);
  assert(run.steps[0].status === 'ready', '第1步状态为 ready');
  assert(run.steps[1].status === 'waiting', '第2步状态为 waiting');
  assert(run.currentStep === 0, '当前步骤 = 0');

  // 14e: 获取当前任务
  const curTask = pSvc.getCurrentTask(run.runId);
  assert(curTask !== null, '当前任务不为空');
  assert(curTask.agent === 'hot-topic-hunter', '当前任务分配给 hot-topic-hunter');
  assert(curTask.stepIndex === 0, '当前任务是第0步');

  // 14f: 标记步骤开始
  pSvc.markStepStarted(run.runId, 0);
  const runAfterStart = pSvc.getRun(run.runId);
  assert(runAfterStart.steps[0].status === 'in_progress', '第1步状态变为 in_progress');

  // 14g: 推进步骤 0 → 1
  const run1 = pSvc.advanceStep(run.runId, 0, {
    output: { topics: ['AI提效工具', '副业经济', '新能源资讯'] },
  });
  assert(run1.steps[0].status === 'done', '第1步完成');
  assert(run1.steps[1].status === 'ready', '第2步变为 ready');
  assert(run1.currentStep === 1, '当前步骤推进到 1');
  assert(run1.steps[1].input !== null, '第2步的 input 为第1步的 output');

  // 14h: 推进到最后一步
  pSvc.advanceStep(run.runId, 1, { output: { draft: '文章初稿...' } });
  pSvc.advanceStep(run.runId, 2, { output: { approved: true, score: 92 } });
  const runFinal = pSvc.advanceStep(run.runId, 3, { output: { publishUrl: 'https://mp.weixin.qq.com/s/xxx' } });
  assert(runFinal.status === 'completed', 'Run 全部完成');
  assert(runFinal.output !== null, 'Run 携带最终 output');

  // 14i: 列出运行记录
  const runs = pSvc.listRuns();
  assert(runs.length === 1, `运行记录数 = 1 (实际: ${runs.length})`);
  assert(runs[0].status === 'completed', '运行状态为 completed');

  // 14j: 更新流水线
  const updPipeline = pSvc.updatePipeline('daily-content', { name: '每日内容产出 v2' });
  assert(updPipeline.name === '每日内容产出 v2', '流水线名称更新成功');

  // ─── 15. 更新公司 ───
  console.log('\n✏️  Phase 15: 更新公司');
  const updated = svc.update(TEST_COMPANY_ID, {
    status: 'running',
    heartbeat: { enabled: true },
  });
  assert(updated.status === 'running', '状态更新为 running');
  assert(updated.heartbeat.enabled === true, '心跳已开启');

  // ─── 16. 关停公司 ───
  console.log('\n🛑 Phase 16: 关停公司');
  const shutdown = svc.shutdown(TEST_COMPANY_ID);
  assert(shutdown.status === 'shutdown', '状态变为 shutdown');
  assert(shutdown.heartbeat.enabled === false, '心跳已关闭');

  // ─── 清理 ───
  cleanup();
  // 清理测试生成的模板
  const testTplDir = path.join(DATA_DIR, 'templates', 'hot-topic-hunter');
  if (fs.existsSync(testTplDir)) fs.rmSync(testTplDir, { recursive: true, force: true });
  // 恢复 registry (移除测试模板)
  const regPath = path.join(DATA_DIR, 'templates', '_registry.json');
  if (fs.existsSync(regPath)) {
    const reg = JSON.parse(fs.readFileSync(regPath, 'utf-8'));
    const cleaned = reg.filter(t => t.id !== 'hot-topic-hunter');
    fs.writeFileSync(regPath, JSON.stringify(cleaned, null, 2), 'utf-8');
  }

  // ─── 结果 ───
  console.log(`\n${'═'.repeat(50)}`);
  console.log(`  📊 测试结果: ${passed} 通过, ${failed} 失败 (共 ${passed + failed})`);
  if (failed === 0) {
    console.log('  🎉 ALL PASSED — Phase 1 + 2 + 3 + 4 生产就绪!\n');
  } else {
    console.log(`  ⚠️  ${failed} 个测试未通过\n`);
    process.exit(1);
  }
}

main().catch(err => {
  console.error('测试异常:', err);
  cleanup();
  process.exit(1);
});