tester-tools.js
8.28 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
/**
* DevLoop · tester-tools (5 个)
* parse_vitest vitest JSON reporter 解析 → result.json + hint 生成
* hint_builder 从 failure stack + expected/got 启发式生成 hint
* bug_open 调 bugTracker.open + 自动 dedup
* bug_transition bug 状态转换
* bug_list 按 filter 查 bug
*
* 规格: docs/prd/newfunction/_spec/agents-tester.md · §7
*/
const fs = require('fs');
const path = require('path');
const pathResolver = require('../../../services/devloop/path-resolver');
const bugTracker = require('../../../services/devloop/bug-tracker');
const { ToolValidationError, ToolMissingDependencyError } = require('./_errors');
// ──────────────────── hint_builder ────────────────────
const HINT_PATTERNS = [
{ match: s => /cannot read.*?of undefined/i.test(s),
hint: () => '某个对象/字段是 undefined,先做存在性判断再访问' },
{ match: s => /TypeError.*compare/i.test(s),
hint: () => '调用 bcrypt.compare 前先确认 user 存在;否则 null 参数会抛 TypeError' },
{ match: s => /SQLITE_CONSTRAINT/i.test(s),
hint: () => '数据库约束冲突(unique/foreign key),业务层应该先查再写' },
{ match: s => /timeout/i.test(s),
hint: () => '某个异步操作超时;检查是否漏 await 或外部依赖未 mock' },
];
function buildHint(ctx) {
const stack = ctx.stack || '';
for (const p of HINT_PATTERNS) {
if (p.match(stack)) return p.hint(ctx);
}
// status code 启发式
const m = String(stack).match(/[Ee]xpected[:\s]+(\d{3})[\s\S]{0,200}?[Rr]eceived[:\s]+(\d{3})/);
const e = m ? parseInt(m[1], 10) : ctx.expected;
const g = m ? parseInt(m[2], 10) : ctx.got;
if (typeof e === 'number' && typeof g === 'number') {
if (e === 401 && g === 500) return '凭据错误应返 401 不是 500;检查认证路径是否 try/catch 兜底';
if (e === 400 && g === 500) return '参数校验错应返 400 不是 500;在 route 层检查后再传 service';
if (e === 404 && g === 200) return '不存在的资源不应返 200;返回前判断查询结果';
return `期望 ${e} 收 ${g},检查对应分支处理`;
}
return `${ctx.testName || 'unknown'}: 检查 stack 推断原因 (Tester 应补充)`;
}
const hint_builder = {
name: 'hint_builder',
description: '从 failure stack/expected/got 生成给 Coder 反思用的 hint(1-2 句人话)',
inputSchema: {
type: 'object',
properties: {
testName: { type: 'string' },
expected: {},
got: {},
stack: { type: 'string' },
},
},
async execute(failure, _ctx) {
return { ok: true, hint: buildHint(failure) };
},
};
// ──────────────────── parse_vitest ────────────────────
const parse_vitest = {
name: 'parse_vitest',
description: 'vitest --reporter=json 输出 → 结构化 result.json + auto-fill hint for failures',
inputSchema: {
type: 'object',
required: ['raw_json_path'],
properties: {
raw_json_path: { type: 'string' },
result_out_path: { type: 'string', description: '可选,result.json 写入位置' },
},
},
async execute({ raw_json_path, result_out_path }, _ctx) {
if (!fs.existsSync(raw_json_path)) {
return {
ok: false, crashed: true,
crash_reason: 'vitest raw json missing — process likely crashed early',
summary: { total: 0, passed: 0, failed: 0, skipped: 0 },
failures: [],
};
}
let raw;
try {
raw = JSON.parse(fs.readFileSync(raw_json_path, 'utf8'));
} catch (e) {
return { ok: false, crashed: true, crash_reason: 'invalid vitest json: ' + e.message };
}
const summary = { total: 0, passed: 0, failed: 0, skipped: 0 };
const failures = [];
const testResults = raw.testResults || raw.tests || [];
for (const file of testResults) {
const asserts = file.assertionResults || file.assertions || [];
for (const a of asserts) {
summary.total++;
if (a.status === 'passed') summary.passed++;
else if (a.status === 'failed') {
summary.failed++;
const stack = (a.failureMessages || a.errors || []).join('\n');
const { expected, got } = _extractAssertion(stack);
failures.push({
test: a.fullName || a.title,
expected, got, stack,
hint: buildHint({ testName: a.fullName, expected, got, stack }),
});
} else summary.skipped++;
}
}
const result = {
ok: summary.failed === 0 && summary.total > 0,
crashed: false,
summary, failures,
duration_ms: raw.testResults?.[0]?.perfStats?.runtime || 0,
};
if (result_out_path) {
fs.mkdirSync(path.dirname(result_out_path), { recursive: true });
fs.writeFileSync(result_out_path, JSON.stringify(result, null, 2));
}
return result;
},
};
function _extractAssertion(stack) {
const exp = stack.match(/Expected[:\s]+([^\n]+)/);
const got = stack.match(/(?:Received|Actual)[:\s]+([^\n]+)/);
return {
expected: exp ? exp[1].trim() : null,
got: got ? got[1].trim() : null,
};
}
// ──────────────────── bug_open / transition / list ────────────────────
const bug_open = {
name: 'bug_open',
description: 'append bugs.ndjson 一条 status=open;dedupKey 命中 open/fixing 时复用旧 id',
inputSchema: {
type: 'object',
required: ['title'],
properties: {
source: { type: 'string', enum: ['unit_test','integration_test','triage','human'] },
unit_id: { type: 'string' },
run_id: { type: 'string' },
test_name: { type: 'string' },
category: { type: 'string' },
severity: { type: 'string', enum: ['critical','high','medium','low'] },
title: { type: 'string', maxLength: 120 },
detail: { type: 'string' },
affected_units: { type: 'array', items: { type: 'string' } },
},
},
async execute(bug, ctx) {
_req(ctx, ['pid']);
// 注入 sseBroker + timelineLogger (如果 ctx 有)
if (ctx.sseBroker) bugTracker.attachSseBroker(ctx.sseBroker);
if (ctx.timelineLogger) bugTracker.attachTimelineLogger(ctx.timelineLogger);
const b = bugTracker.open(ctx.pid, { ...bug, by: ctx.agent || 'tester' });
return { ok: true, bug_id: b.id, dedup: b.discovered_at !== new Date().toISOString().slice(0, b.discovered_at.length) };
},
};
const bug_transition = {
name: 'bug_transition',
description: 'bug 状态转换 (open → fixing → fixed → verified 等),走 P-9 状态机',
inputSchema: {
type: 'object',
required: ['bug_id', 'to'],
properties: {
bug_id: { type: 'string' },
to: { type: 'string', enum: ['open','fixing','fixed','verified','wontfix','duplicate'] },
note: { type: 'string' },
fix_refs: { type: 'array', items: { type: 'string' } },
duplicate_of: { type: 'string' },
},
},
async execute({ bug_id, to, note, fix_refs, duplicate_of }, ctx) {
_req(ctx, ['pid']);
if (ctx.sseBroker) bugTracker.attachSseBroker(ctx.sseBroker);
if (ctx.timelineLogger) bugTracker.attachTimelineLogger(ctx.timelineLogger);
try {
bugTracker.transition(ctx.pid, bug_id, {
to, by: ctx.agent || 'system', note, fix_refs, duplicate_of,
});
return { ok: true };
} catch (e) {
if (e.name === 'InvalidBugTransitionError') {
throw new ToolValidationError('to', e.message);
}
throw e;
}
},
};
const bug_list = {
name: 'bug_list',
description: '列 bug,可按 status/severity/unit_id 过滤;返回每个 bug.id 的最新态',
inputSchema: {
type: 'object',
properties: {
status: { type: 'array', items: { type: 'string' } },
severity: { type: 'array', items: { type: 'string' } },
unit_id: { type: 'string' },
since: { type: 'string' },
},
},
async execute(filter, ctx) {
_req(ctx, ['pid']);
const bugs = bugTracker.list(ctx.pid, filter || {});
return { ok: true, bugs, total: bugs.length };
},
};
function _req(ctx, keys) {
for (const k of keys) {
if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`);
}
}
module.exports = { hint_builder, parse_vitest, bug_open, bug_transition, bug_list };