/** * Unit Tests — 控制台节点信号的读回层(27-P3B W12-UI) * * 三件被验的事,都是「产生 → 传递 → 落库 → 读回」里**读回那一环**: * * 1. `RUN_EVENT_KINDS` 必须覆盖引擎实际发出的每一种事件名。 * EventSource 对具名事件要逐个 addEventListener,名单漏一个 = 那一类事件 * **整类到不了浏览器**(这就是 node.error / node.triage / node.boundary 之前的下场: * 引擎在发、stream.log 里有、前端一条都收不到 → 失败节点在界面上「无原因」)。 * 判据从 graph-engine.js / graph-run-sse.js 的**源码文本**里扫,取不到就抛,不静默空跑 * (手法对标 template-verdicts.test.js)。 * ⚠️ 引擎将来新增一种事件名,这条会红并报出精确名字 —— 修法就是把它加进 * web/src/components/graph-console/node-signals.js 的 RUN_EVENT_KINDS。 * * 2. `describeEvent` 对每种事件都拼得出人话。SSE 帧里**没有** summary * (graph-run-sse.emitTransition 只把它写进 timeline.ndjson),前端只能自己拼; * 拼不出来就回落成原始 JSON,运行日志轨会退回「一堆花括号」。 * * 3. `nodeEvents` / `attemptOf` 的过滤语义:按 node_run_id 认领、按 attempt 分轮。 * 失败原因的三级回落与「还活着」条都建在这两个函数上。 */ import { describe, it, expect } from 'vitest'; import fs from 'fs'; import path from 'path'; import { RUN_EVENT_KINDS, nodeEvents, attemptOf, lastWhere, describeEvent, fmtDuration, since, } from '../../../web/src/components/graph-console/node-signals.js'; const REPO = path.resolve(__dirname, '../../..'); /** 从源码文本里扫出引擎实际会发的事件名(emitEvent / timelineNote 两个入口 + 两个状态机事件) */ function emittedEventNames() { const engine = fs.readFileSync(path.join(REPO, 'server/services/graph-engine.js'), 'utf8'); const sse = fs.readFileSync(path.join(REPO, 'server/services/graph-run-sse.js'), 'utf8'); const found = new Set(); // sse.emitEvent(runId, 'x.y', ...) / emitEvent(ctx.runId, 'x.y', ...)(跨行也匹配) for (const m of engine.matchAll(/emitEvent\(\s*[^,]+,\s*['"]([a-z][a-z._]*)['"]/g)) found.add(m[1]); // timelineNote(ctx, 'x.y', ...) —— 它内部同样走 sse.emitEvent,是审计轨与实时轨的合流点 for (const m of engine.matchAll(/timelineNote\(\s*[^,]+,\s*['"]([a-z][a-z._]*)['"]/g)) found.add(m[1]); // 状态机那两条是 graph-run-sse 自己写死的事件名 for (const m of sse.matchAll(/event:\s*'((?:node|run)\.status)'/g)) found.add(m[1]); if (found.size < 8) { throw new Error(`事件名扫描失效:只扫到 ${found.size} 个(${[...found]})。` + '引擎的 emitEvent / timelineNote 调用形态变了,先修这个扫描器,别让它静默空跑。'); } return found; } describe('控制台节点信号(W12-UI 读回层)', () => { it('RUN_EVENT_KINDS 覆盖引擎实际发出的每一种事件名(漏一个 = 整类事件收不到)', () => { const emitted = emittedEventNames(); const subscribed = new Set(RUN_EVENT_KINDS); const missing = [...emitted].filter((k) => !subscribed.has(k)).sort(); expect( missing, `这些事件引擎在发、GraphRunPane 没订阅(EventSource 具名事件必须逐个 addEventListener):` + `${missing.join(', ')}。加进 node-signals.js 的 RUN_EVENT_KINDS。`, ).toEqual([]); // 反向也别留死名字:订阅了一个引擎根本不发的事件名,说明名单在腐化 expect([...subscribed].filter((k) => !emitted.has(k))).toEqual([]); }); it('describeEvent 对每种事件都拼得出人话,不回落成原始 JSON', () => { const samples = { 'node.status': { from: 'ready', to: 'running' }, 'run.status': { from: 'ready', to: 'running' }, 'agent.call': { system_agent_id: 'coder', attempt: 1 }, 'node.output': { status: 'fail', fail_reason: 'contract_violation', output_excerpt: 'boom' }, 'node.error': { error_type: 'Error', error: 'Agent not found: coder' }, 'node.triage': { verdict: 'attributed', fail_reason: 'wrong_impl', remediation_note: '改回去' }, 'node.contract': { evidence: 'both', dirty: true, reset: ['n2'] }, 'node.boundary': { violation: 'boundary_escape', count: 3 }, 'node.subgraph': { child_run_id: 'r2', scope: 'node' }, 'loop.registered': { loop_count: 2 }, 'loop.terminated': { cap: 'edge' }, 'loop.cross_layer': {}, 'budget.spent': { delta: { agent_calls: 1, tokens: 42 } }, 'budget.extended': { applied: { wallclock_ms: 14_400_000 } }, 'plan.call': { agent: 'workgraph-planning', round: 1 }, 'plan.done': { work_graph_id: 'wg-1', round: 1, nodes: 3, edges: 2 }, 'plan.rejected': { round: 1, errors: [{ rule: 'reference', detail: '图不存在' }] }, }; // 样本表本身也要对齐名单,否则改了名单这条测试会悄悄少验几种 expect(Object.keys(samples).sort()).toEqual([...RUN_EVENT_KINDS].sort()); for (const [kind, data] of Object.entries(samples)) { const text = describeEvent({ kind, data }); expect(text, kind).toBeTruthy(); expect(text.startsWith('{'), `${kind} 回落成了原始 JSON`).toBe(false); } // 具体内容抽查:真跑时最关键的两条 expect(describeEvent({ kind: 'node.error', data: samples['node.error'] })) .toContain('Agent not found: coder'); expect(describeEvent({ kind: 'node.output', data: samples['node.output'] })) .toContain('contract_violation'); // 引擎哪天把 summary 并进 data,前端自动优先用它 expect(describeEvent({ kind: 'node.output', data: { summary: '引擎给的一句话' } })) .toBe('引擎给的一句话'); }); /** * 越界判罚是**按文件清单**下的,所以清单必须跟到界面上。 * 只报「留下 N 处改动」时,人没法判断这到底是 agent 写的、还是并行的开发会话写的 * (后者会被算到当时在跑的节点头上,是误判 —— 看一眼路径就知道), * 而清单原本只躺在 timeline.ndjson 里,控制台上翻不到。 */ it('node.boundary 要列出越界的文件,不能只报个数', () => { const text = describeEvent({ kind: 'node.boundary', data: { violation: 'boundary_escape', count: 2, entries: ['M server/services/graph-run-sse.js', '?? server/services/task-scheduler.js'], }, }); expect(text).toContain('graph-run-sse.js'); expect(text).toContain('task-scheduler.js'); // entries 缺席(旧事件 / 降级路径)时不许拼出半截标点 expect(describeEvent({ kind: 'node.boundary', data: { violation: 'boundary_escape', count: 2 } })) .toBe('写入越界:宿主仓库留下 2 处改动'); }); it('nodeEvents 按 node_run_id 认领、attemptOf 按 attempt 分轮', () => { const node = { id: 'nr-1', attempt: 1 }; const events = [ { kind: 'agent.call', data: { node_run_id: 'nr-1', attempt: 0 } }, { kind: 'node.output', data: { node_run_id: 'nr-2', attempt: 0 } }, // 别的节点 { kind: 'node.output', data: { node_run_id: 'nr-1', attempt: 1 } }, { kind: 'budget.spent', data: { run_id: 'r1' } }, // 没有节点归属 { kind: 'node.triage', data: { ref: 'nr-1' } }, // 只带 ref 的形态 ]; const mine = nodeEvents(events, node); expect(mine.map((e) => e.kind)).toEqual(['agent.call', 'node.output', 'node.triage']); // 事件没带 attempt 时算在节点当前 attempt 上(本轮),带了就按它自己的 expect(mine.map((e) => attemptOf(e, node))).toEqual([0, 1, 1]); expect(nodeEvents(events, null)).toEqual([]); // 「最近一条」是倒着找的第一个 expect(lastWhere(mine, (e) => e.kind !== 'node.triage').kind).toBe('node.output'); expect(lastWhere(mine, () => false)).toBeNull(); }); it('fmtDuration / since 的秒表口径', () => { expect(fmtDuration(0)).toBe('0s'); expect(fmtDuration(45_000)).toBe('45s'); expect(fmtDuration(187_000)).toBe('3m 07s'); expect(fmtDuration(3_840_000)).toBe('1h 04m'); expect(fmtDuration(-1)).toBeNull(); // 时钟偏斜时不显示,别报负数 expect(fmtDuration(NaN)).toBeNull(); const t0 = Date.parse('2026-08-17T00:00:00.000Z'); expect(since('2026-08-17T00:00:00.000Z', t0 + 12_000)).toBe('12s'); expect(since(null, t0)).toBeNull(); expect(since('不是时间', t0)).toBeNull(); }); });