graph-run-route-io.test.js
6.21 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
/**
* 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 () => {
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, source_kind, legacy_path, status, created_at, updated_at)
VALUES (?, ?, 'public', NULL, 'global_agent', ?, '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));
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);
});
});