graph-console-node-signals.test.js 7.4 KB
/**
 * 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 } },
      '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('引擎给的一句话');
  });

  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();
  });
});