/** * Unit Tests — GET /api/graph-runs/:id 的 R-19 输入输出三字段(27-P4 §4.6) * * 控制台的「输入输出」tab 读三个字段:`edges[].contracts` / `companyRoot` / `taskFiles`。 * 缺任何一个它整块降级 —— 契约声明和产物索引都在库里,界面上却看不见, * 就是这一路踩了十几次的「实现在、零调用方」换个方向再来一次(这次是前端在、后端没喂)。 * * 走真入口:把 router 挂到真 express 上 listen(0) 再 fetch(与 devloop/routes.test.js 同法), * 不导出内部函数、不打桩 res.json。 * * 顺带钉住两条形状约定: * - `contracts` 只在非空时出现(与 failReason 一致,存量边不该凭空多字段) * - `companyRoot` 是绝对路径,且与 recordFile 存的 rel_path 拼起来就是真实文件 * —— 「拼得回去」才是这个字段唯一的用处,光断言它非空等于没测 */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; import express from 'express'; const TEST_DB_PATH = '/tmp/test-graph-run-route-io.sqlite'; const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; const MODULES = [ '../../services/graph-db', '../../services/graph-service', '../../services/org-graph-service', '../../services/work-graph-db', '../../services/company-paths', '../../services/graph-run-sse', '../../services/graph-engine', '../../routes/graph-run', ]; describe('GET /api/graph-runs/:id — R-19 输入输出三字段', () => { let wdb; let srv; let base; let runsDir; let runId; let companyId = 'co-route-io'; const taskId = 'T-route-io'; beforeAll(async () => { // 38 号文档 Phase 2:graph-run.js 的 :id 路由现在会反查 run 的 company_id 是否是一家 // 真实存在的公司,companyId 只是这份测试里的一个字符串标签,从没真的建过公司。 require('../helpers/ensure-test-company').ensureTestCompany(companyId); for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } process.env.GRAPH_DB_PATH = TEST_DB_PATH; runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'route-io-runs-')); process.env.GRAPH_RUNS_DIR = runsDir; for (const m of MODULES) delete require.cache[require.resolve(m)]; wdb = require('../../services/work-graph-db'); const db = wdb.getDb(); const ts = new Date().toISOString(); const ins = db.prepare(` INSERT OR IGNORE INTO system_agents (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?) `); for (const id of ['brd-planning', 'prd-planning']) ins.run(id, id, `system/agents/${id}`, ts, ts); const g = wdb.createWorkGraph({ companyId, taskId, nodes: [ { nodeName: 'p-brd', systemAgentId: 'brd-planning' }, { nodeName: 'p-prd', systemAgentId: 'prd-planning' }, // 第三个节点只为了造一条**没有任何声明**的边:全是有声明的边时, // 「无声明的边上不出现 contracts 字段」那条断言在这份 fixture 上永远自洽 { nodeName: 'p-tail', systemAgentId: 'prd-planning' }, ], edges: [ { from: 'p-brd', to: 'p-prd', condition: 'success', produces: ['brd'] }, { from: 'p-prd', to: 'p-tail', condition: 'success' }, ], }); expect(g.ok).toBe(true); const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, taskId }); runId = run.id; // 真的把文件写到公司根下再登记 —— 「拼得回去」这条断言要求文件真实存在 const { companyRootDir, taskDirRel } = require('../../services/company-paths'); const relPath = path.join(taskDirRel(taskId), 'brd.md'); const abs = path.join(companyRootDir(companyId), relPath); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, '# brd\n'); wdb.recordFile({ companyId, taskId, relPath, kind: 'brd' }); const app = express(); app.use(express.json()); app.use('/api/graph-runs', require('../../routes/graph-run')); await new Promise((resolve) => { srv = app.listen(0, '127.0.0.1', () => { base = `http://127.0.0.1:${srv.address().port}`; resolve(); }); }); }); afterAll(async () => { if (srv) await new Promise((r) => srv.close(r)); require('../helpers/ensure-test-company').removeTestCompany(companyId); for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } delete process.env.GRAPH_DB_PATH; try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} delete process.env.GRAPH_RUNS_DIR; for (const m of MODULES) delete require.cache[require.resolve(m)]; }); it('三个字段都在,且 companyRoot + rel_path 拼回真实文件', async () => { const r = await fetch(`${base}/api/graph-runs/${runId}`); expect(r.status).toBe(200); const d = await r.json(); // ① 边上的契约(哪一层的边查哪一层的契约;work run 查 work 层) const declared = d.edges.filter((e) => e.contracts); expect(declared).toHaveLength(1); expect(declared[0].contracts.map((c) => c.kind)).toEqual(['brd']); expect(declared[0].contracts[0].role).toBe('produces'); // ② 产物索引:本 task 已登记的实例 expect(d.taskFiles.map((f) => f.kind)).toEqual(['brd']); // ③ companyRoot 的唯一用处是让前端把相对路径拼成可点的绝对路径 expect(path.isAbsolute(d.companyRoot)).toBe(true); const abs = path.join(d.companyRoot, d.taskFiles[0].rel_path); expect(fs.existsSync(abs)).toBe(true); expect(fs.readFileSync(abs, 'utf8')).toContain('# brd'); }); it('无声明的边上不出现 contracts 字段(与 failReason 同一个约定)', async () => { const r = await fetch(`${base}/api/graph-runs/${runId}`); const d = await r.json(); // 三个节点两条边:一条有声明、一条没有。没有那条必须整个字段不出现, // 而不是回一个空数组(存量边绝大多数都是这个形态,恒空字段会让既有消费方凭空多东西)。 expect(d.edges).toHaveLength(2); expect(d.edges.filter((e) => 'contracts' in e)).toHaveLength(1); for (const e of d.edges) { if ('contracts' in e) expect(e.contracts.length).toBeGreaterThan(0); } }); it('run 不存在仍是 404(新字段不该把错误路径也一起改掉)', async () => { const r = await fetch(`${base}/api/graph-runs/run_nope`); expect(r.status).toBe(404); }); // 34 号文档:graph_files 只索引节点私有目录,真实交付物写在这个任务共享的 // 项目根(W2 ensureProjectRoot)——控制台「产物」面板要同时看到两处,缺一个字段 // 就是同一路「实现在、零调用方」再来一次。 it('projectRoot:没分配过就是 null(不假装有)', async () => { const r = await fetch(`${base}/api/graph-runs/${runId}`); const d = await r.json(); expect(d.projectRoot).toBeNull(); }); it('projectRoot:分配过之后原样透传(root_path/pid 与落库的记录一致)', async () => { const engine = require('../../services/graph-engine'); const run = wdb.getRun(runId); const rec = engine.ensureProjectRoot({ run, companyId, nodeRunId: 'rn-fake' }); expect(rec).toBeTruthy(); const r = await fetch(`${base}/api/graph-runs/${runId}`); const d = await r.json(); expect(d.projectRoot).toBeTruthy(); expect(d.projectRoot.pid).toBe(rec.pid); expect(d.projectRoot.root_path).toBe(rec.root_path); // 幂等:再查一次不会重新分配一个不同的项目根 expect(engine.ensureProjectRoot({ run, companyId, nodeRunId: 'rn-fake-2' }).root_path).toBe(rec.root_path); }); // 35 号文档提案 B:控制台「这份验收有没有证据支撑」要读的数据——只读 // goal-contract.js 已经落盘的判决,不重新计算。 it('benchmarkVerdicts:没有任何判决落盘时是空数组(不是整块降级)', async () => { const r = await fetch(`${base}/api/graph-runs/${runId}`); const d = await r.json(); expect(d.benchmarkVerdicts).toEqual([]); }); it('benchmarkVerdicts:落过判决之后,逐维度的 evidence_required/ok 原样透传给前端', async () => { const gc = require('../../services/goal-contract'); const run = wdb.getRun(runId); const runDir = gc.resolveRunDir(companyId, run.root_run_id || run.id); const verdict = { verdict_id: 'vd-test-1', ok: false, fail_reason: 'acceptance_not_met', goal_id: 'G-1', run_id: run.id, node_run_id: 'rn-benchmark-1', attempt: 0, contract_hash: 'x', target: 'x', created_at: new Date().toISOString(), sections: { benchmark: { ok: false, dimensions: [ { name: 'hero_first_impression', score: 100, ok: false, evidence_required: true }, { name: 'code_quality', score: 90, ok: true }, ], }, }, }; gc.persistVerdict(runDir, verdict); const r = await fetch(`${base}/api/graph-runs/${runId}`); const d = await r.json(); expect(d.benchmarkVerdicts).toHaveLength(1); const v = d.benchmarkVerdicts[0]; expect(v.nodeRunId).toBe('rn-benchmark-1'); expect(v.ok).toBe(false); expect(v.dimensions).toEqual([ { name: 'hero_first_impression', ok: false, score: 100, evidenceRequired: true }, { name: 'code_quality', ok: true, score: 90, evidenceRequired: false }, ]); }); });