test-company-e2e.js
23.4 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
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
#!/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 { OkrService } = require('../services/okr-service');
const { PipelineService } = require('../services/pipeline-service');
const { CompanyHeartbeatService } = require('../services/company-heartbeat-service');
const DATA_DIR = path.resolve(__dirname, '../../data');
// 公司数据在 data/companies/{id}/ 下(company-service.js:18),不是 data/{id}/。
// 本脚本长期按后者找,导致所有断言全失败——这是 data/companies/ 那次目录调整时漏改的。
const COMPANIES_DIR = path.join(DATA_DIR, 'companies');
// 模板库 = 本体库。28 批次 1 把 `system/templates/` 整体并进 `system/agents/`,
// 这里必须跟着 company-service 的 TEMPLATES_DIR(= agent-locator.AGENTS_ROOT)走:
// 指着已删除的旧目录不只是断言失败——Phase 7b 的 depositTemplate 会往
// `system/agents/hot-topic-hunter` 写,而收尾的 rmSync 按旧路径删,删不掉,
// 于是每跑一次就在本体库里留一份测试残渣。
const TEMPLATES_DIR = require('../services/agent-locator').AGENTS_ROOT;
const TEST_COMPANY_ID = 'test-media-co';
const TEST_COMPANY_DIR = path.join(COMPANIES_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, 'okr')), 'okr/ 目录已创建');
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. 招聘 CXO (从模板) ───
// ⚠️ 这里刻意用 coo 而不是 ceo:system/templates/ceo/ 当时只有 plan.md + todo.md(28 批次 1 已补齐 system/agents/ceo/),
// 没有 agent.md / soul.md / config.json,拿它测"模板骨架是否被完整复制"测不出东西
// (而且缺 config.json 就没有 type='cxo',company.ceo 也标不上)。
// coo 是同为 type='cxo' 且文件完整的模板。ceo 模板本身的残缺是另一个待补的问题。
const CXO_ROLE = 'coo';
console.log(`\n👔 Phase 4: 招聘 ${CXO_ROLE} (模板命中)`);
const hireCeo = svc.hire(TEST_COMPANY_ID, CXO_ROLE);
assert(hireCeo.status === 'hired_from_template', '从模板招聘成功');
const ceoDir = path.join(TEST_COMPANY_DIR, 'agents', CXO_ROLE);
assert(fs.existsSync(ceoDir), 'CXO Agent 目录已创建');
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 已初始化');
// ⚠️ P1 虚拟引用寻址(commit 226489d)之后,agent.md 不再物理拷贝到私有目录,
// 改为 config.json 里的 systemAgentRef 指向共享模板,公司上下文单独落 company-context.md。
// 本段断言在 P1 之前是"agent.md 已复制 + 注入了公司 ID/名称",已按新模型改写。
assert(!fs.existsSync(path.join(ceoDir, 'agent.md')), 'agent.md 不再物理拷贝(P1 引用模式)');
const ceoCfgRef = JSON.parse(fs.readFileSync(path.join(ceoDir, 'config.json'), 'utf-8'));
assert(ceoCfgRef.systemAgentRef === CXO_ROLE, `config.json 记录了 systemAgentRef=${CXO_ROLE}`);
const ceoCtxPath = path.join(ceoDir, 'company-context.md');
assert(fs.existsSync(ceoCtxPath), 'company-context.md 已生成');
const ceoCtx = fs.readFileSync(ceoCtxPath, 'utf-8');
assert(ceoCtx.includes(TEST_COMPANY_ID), 'company-context.md 注入了公司 ID');
assert(ceoCtx.includes('测试自媒体公司'), 'company-context.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 === CXO_ROLE, 'company.json 中 CEO 已标记(现有实现:任何 type=cxo 的角色都会覆盖此字段)');
assert(companyAfterCeo.agents.includes(CXO_ROLE), `company.json agents 列表包含 ${CXO_ROLE}`);
// ─── 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: '全网热点监控、选题建议',
});
// T5b(27-p2-org-graph.md §4.1 第 7 步)起:模板不存在时不再走 needs_training 全量培养,
// 改为先从 system/agents/_base/ 元 agent 继承骨架,再交 agent-creator 填充。
assert(hireNew.status === 'hired_from_base', '返回 hired_from_base 状态(从 base 元 agent 继承)');
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 已生成');
// ⚠️ base 元 agent(system/agents/_base/)当时只有 agent.md + config.json(28 号已补 soul.md + SKILL.md),
// 所以继承出来的骨架不含 soul.md / identity.md / tools.md / todo.md —— 这些由
// agent-creator 后续填充。这里只断言 base 继承本身保证的东西,不断言 agent-creator 的产物。
const hthCfg = JSON.parse(fs.readFileSync(path.join(hthDir, 'config.json'), 'utf-8'));
assert(hthCfg.inheritedFrom === 'base', 'config.json 标记 inheritedFrom=base');
assert(fs.existsSync(path.join(hthDir, 'config.json')), 'config.json 已生成');
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(TEMPLATES_DIR, '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(COMPANIES_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, CXO_ROLE);
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 === CXO_ROLE), '列表包含 CXO');
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, CXO_ROLE);
assert(companyAgentDir !== null, '公司内 CEO 可定位');
assert(companyAgentDir.includes(TEST_COMPANY_ID), '路径包含公司 ID');
const globalMasterDir = CompanyService.resolveAgentDir(null, 'master');
assert(globalMasterDir !== null, '全局 Master 可定位');
// 全局 agent 在 system/agents/ 下,不是 data/agents/(同属那次目录调整时漏改的断言)
assert(globalMasterDir.includes('system/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 OkrService(TEST_COMPANY_ID);
// 13a: 创建 Goal
const goal = companyGoals.create({
title: '30天粉丝破万',
description: '通过日更优质内容实现公众号粉丝增长',
owner: 'ceo',
});
assert(goal.id === 'O-001', `Objective ID = O-001 (实际: ${goal.id})`);
assert(goal.title === '30天粉丝破万', 'Goal 标题正确');
// 13b: 检查数据存储在公司目录下
const goalFilePath = path.join(TEST_COMPANY_DIR, 'okr', 'O-001', 'objective.json');
assert(fs.existsSync(goalFilePath), 'Goal 存储在公司目录下');
// 13c: 添加节点 (Objective + Key Results)
const obj = companyGoals.addNode('O-001', null, {
title: '建立内容产出流水线',
owner: 'ceo',
priority: 'P0',
});
assert(obj.nodeId === 'O-001.1', `Objective ID = O-001.1 (实际: ${obj.nodeId})`);
const kr1 = companyGoals.addNode('O-001', 'O-001.1', {
title: '日更1篇高质量文章',
owner: 'content-writer',
priority: 'P0',
});
assert(kr1.nodeId === 'O-001.1.1', `KR ID = O-001.1.1 (实际: ${kr1.nodeId})`);
const kr2 = companyGoals.addNode('O-001', 'O-001.1', {
title: '建立选题数据库 (≥100 条)',
owner: 'hot-topic-hunter',
priority: 'P1',
});
assert(kr2.nodeId === 'O-001.1.2', `KR2 ID = O-001.1.2 (实际: ${kr2.nodeId})`);
// 13d: 更新节点状态
companyGoals.updateNode('O-001', 'O-001.1.1', { status: 'in_progress' });
const updatedNode = companyGoals.getNode('O-001', 'O-001.1.1');
assert(updatedNode.status === 'in_progress', '节点状态更新为 in_progress');
// 13e: 进度计算
const progress = companyGoals.getProgress('O-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 === 'O-001', '列表中 Objective ID 正确');
// 13g: 验证 OKR 是公司隔离的 (全局 OkrService 看不到公司的目标)
const globalGoals = new OkrService();
const globalList = globalGoals.list();
const hasCompanyGoal = globalList.some(g => g.id === 'O-001' && g.title === '30天粉丝破万');
assert(!hasCompanyGoal, '全局 OkrService 看不到公司级 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. Phase 5: 心跳服务 + Pipeline 自动触发 ───
console.log('\n💓 Phase 15: 心跳服务 + Pipeline 自动触发');
// 15a: 创建带 cron 的流水线
const pSvc2 = new PipelineService(TEST_COMPANY_ID);
const cronPipeline = pSvc2.createPipeline({
id: 'weekly-report',
name: '周报汇总',
description: '每周自动汇总运营数据',
cron: { intervalHours: 168 }, // 每168小时 = 每周
steps: [
{ name: '数据汇总', agent: 'ceo', prompt: '汇总本周运营数据' },
{ name: '报告生成', agent: 'content-writer', prompt: '生成周报' },
],
});
assert(cronPipeline.cron !== null, '流水线包含 cron 配置');
assert(cronPipeline.cron.intervalHours === 168, 'cron 间隔 = 168小时');
// 15b: 心跳服务实例化
const hbSvc = new CompanyHeartbeatService();
assert(hbSvc.timers.size === 0, '初始无计时器');
// 15c: _shouldRunPipeline 检测 (cron 无运行记录 → 应该运行)
const shouldRun1 = hbSvc._shouldRunPipeline(pSvc2, cronPipeline);
assert(shouldRun1 === true, '无历史运行 → 应该触发');
// 15d: 手动启动一次 Run 后再检测
pSvc2.startRun('weekly-report', { trigger: 'test' });
const shouldRun2 = hbSvc._shouldRunPipeline(pSvc2, cronPipeline);
assert(shouldRun2 === false, '刚运行过 → 不应触发 (间隔未到)');
// 15e: 注册/取消公司心跳
svc.update(TEST_COMPANY_ID, { status: 'running', heartbeat: { enabled: true, intervalMinutes: 5 } });
hbSvc.registerCompany(TEST_COMPANY_ID);
assert(hbSvc.timers.has(TEST_COMPANY_ID), '公司心跳已注册');
hbSvc.unregisterCompany(TEST_COMPANY_ID);
assert(!hbSvc.timers.has(TEST_COMPANY_ID), '公司心跳已取消');
// 15f: 驱动运行中的 Run (检查 getCurrentTask)
const testRun = pSvc2.startRun('daily-content', { trigger: 'heartbeat-test' });
const readyTask = pSvc2.getCurrentTask(testRun.runId);
assert(readyTask !== null, '新 Run 有 ready 任务');
assert(readyTask.agent === 'hot-topic-hunter', 'Ready 任务分配给正确 Agent');
// 15g: 超时检测 (模拟超时场景)
pSvc2.markStepStarted(testRun.runId, 0);
// 手动篡改 startedAt 为很久以前
const runData = pSvc2.getRun(testRun.runId);
runData.steps[0].startedAt = new Date(Date.now() - 60 * 60 * 1000).toISOString(); // 1小时前
fs.writeFileSync(path.join(TEST_COMPANY_DIR, 'pipelines', 'runs', `${testRun.runId}.json`), JSON.stringify(runData, null, 2), 'utf-8');
// 清理
hbSvc.stop();
// ─── 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(TEMPLATES_DIR, 'hot-topic-hunter');
if (fs.existsSync(testTplDir)) fs.rmSync(testTplDir, { recursive: true, force: true });
// 恢复 registry (移除测试模板)
const regPath = path.join(TEMPLATES_DIR, '_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);
});