补 322 个测试,挖出 19 个 bug
开发记录 · 2026-08-08 · RepoPilot
项目原本有 67 个测试,集中在 mutation 引擎和端到端链路 ------ 因为那是我觉得最容易错的地方。 command.ts、verify.ts、patch.ts、tools.ts、registry.ts 一个单测都没有, 理由是"这些逻辑很直"。
补完之后:389 个测试,19 个真实缺陷。
那些"很直"的模块,缺陷密度最高。
先说最扎心的一条
现有的端到端测试里有这么一条断言,从写下那天起一直是绿的:
ts
// 规划阶段没有产生任何 R1 副作用
const planPhaseWrites = recorder.toolCalls.filter(
(t) => t.risk !== 'R0' && t.at < recorder.planApprovedAt!,
);
expect(planPhaseWrites).toHaveLength(0);
看起来它在守护一条重要的安全性质:规划阶段只读。
它绿,只是因为脚本化的模型恰好没点名 R1 工具。
真实情况是:dispatchTool 查的是全局 TOOLS_BY_NAME,函数签名里根本没有 phase 参数。PLANNING_TOOLS 只决定「给模型看哪些 schema」。模型只要凭记忆 写出 workspace_mutate 这个名字(它在训练数据里见过无数次类似的工具名), 规划阶段就能真的改文件。
一个永远不会红的断言,等于没有断言。
新的测试专门让模型去点名:
ts
/** 规划期就点名写工具的模型。第二轮才老老实实提交计划。 */
class RogueModel implements ModelInvoker {
async invoke(input) {
if (input.purpose === 'PLANNING' && this.turn === 1) {
// 规划期直接点名 R1 工具 ------ 模型完全可以凭记忆写出这个名字
this.attempted.push('workspace_mutate');
return toolUse('workspace_mutate', {
operations: [{ kind: 'CREATE_FILE', path: 'src/planted.ts', newText: '...' }],
});
}
...
}
}
断言里第一句是前提检查:
ts
// 前提:模型确实尝试了 ------ 否则这条测试什么都没验证
expect(model.attempted).toEqual(['workspace_mutate', 'run_command']);
const denied = host.calls.filter((c) => c.resolution === 'DENIED');
expect(denied.map((c) => c.toolName).sort()).toEqual(['run_command', 'workspace_mutate']);
for (const c of denied) expect(c.reason).toBe('PHASE_READONLY');
// 最硬的断言:那个文件根本不存在,generation 也没推进
expect(existsSync(join(workspace.activePath, 'src/planted.ts'))).toBe(false);
expect(workspace.activeGeneration).toBe(0);
修法是给 dispatchTool 加 phase 参数,在风险门之前加一道真闸门。
最严重的运行时缺陷:SIGKILL 是死代码
command.ts 的注释写着「用 detached 进程组 + kill(-pid) 终止整棵进程树, 避免留下孤儿构建进程」,代码里也确实有 SIGTERM → SIGKILL 的升级逻辑:
ts
const killTree = (): void => {
try { process.kill(-child.pid, 'SIGTERM'); } catch { ... }
setTimeout(() => {
if (settled || child.pid === undefined) return; // ← 这里
process.kill(-child.pid, 'SIGKILL');
}, 3000).unref();
};
问题在 settled。两个调用点都是:
ts
killTree();
finish('TIMEOUT', null, null); // finish() 同步把 settled 置为 true
3 秒后定时器触发,settled 早就是 true 了,回调必然直接 return。 升级逻辑一次都不会执行。
实测:一个 process.on('SIGTERM', () => {}) 的 node 子进程, 在 runCommand 返回 TIMEOUT 之后 6 秒仍然活着。
后果是:任何屏蔽 SIGTERM 的构建 / watch 工具超时后都会变成永久孤儿进程 , 而返回值报告的是 TIMEOUT ------ 从调用方的角度完全看不出来。
根因是语义混用 :settled 表示"Promise 已定型",不是"进程已死"。
ts
let settled = false;
/** 进程是否真的退出了。**不能**复用 settled ------ 那表示 Promise 已定型,不是进程已死。 */
let childClosed = false;
顺带那个 .unref() 也得去掉 ------ 不然进程可能在升级发生前就随主进程退出了。
第二严重的:输出截断方向反了
这条特别隐蔽,因为它平时完全正常,只在输出很大的时候出问题。
两层截断,方向相反:
ts
// 捕获层:累计到 512KB 就停止累积 ------ 保留的是【开头】
const capture = (chunk, into) => {
const total = stdout.length + stderr.length;
if (total >= MAX_CAPTURE_BYTES) { truncated = true; return; }
...
};
// 预览层:从中取尾巴 ------ 因为构建错误在【结尾】
function tailPreview(text: string): string {
const tail = trimmed.slice(-PREVIEW_MAX_BYTES);
...
}
于是拿到的是「前 512KB 的末尾」,而不是进程真正的末尾。
实测:产生 720KB 输出、最后一行是 FINAL-ERROR-MARKER,预览里完全没有这一行, 全是中段噪音。
tsc、webpack、大仓库的测试输出动辄上 MB,而错误摘要恰恰在最后几行。 也就是说:输出越大,越拿不到结论 ------ 正好和你需要它的时候相反。
修法是把捕获层改成保留尾部的环形缓冲,从头部丢弃而不是停止累积:
ts
class TailBuffer {
push(chunk: Buffer): void {
...
while (this.bytes > MAX_CAPTURE_BYTES && this.chunks.length > 1) {
this.bytes -= this.chunks.shift()!.byteLength; // 从头部丢
this.droppedHead = true;
}
}
}
同一个文件里还有三条
outputTruncated 在被截断时报 false。 判定用的是两条流之和 > 8000, 而实际截断发生在 tailPreview 里、按每条流 4000 字节。单条 stdout 输出 6000 字节时, 预览已经被腰斩,标志却是 false ------ 下游据此把残缺日志当成完整证据。 修法:让截断层自己回报,不在外面用另一套阈值猜。
跨 chunk 的多字节字符被解成 U+FFFD。 每个 chunk 独立 toString('utf8'), 64KB 的 pipe 边界几乎不可能整除 3。实测中文输出会随机糊掉几个字符, 而且是静默发生的。修法:StringDecoder 持有跨块状态。
按字节判阈值、按字符切片。 Buffer.byteLength(x) > 4000 判定, x.slice(-4000) 截取。纯中文时返回 12000 字节 ------ 调用方按 4000 字节规划 IPC 载荷和提示词预算,实际拿到三倍。这个模式在三个地方各写了一遍 (command.tailPreview、tools.project、patch.sealPatch)。
一个我很喜欢的缺陷:原型链
profile.commands 是普通对象字面量,三处都这么判存在性:
ts
const def = profile.commands[id];
if (!def) { /* 记为 SPAWN_ERROR */ }
commandId 是模型可控的字符串。当它是 'constructor' / 'toString' / 'valueOf' 时,取到的是 Object.prototype 上的成员 ------ truthy 。 于是 !def 为 false,代码往下走:
ts
const [bin, ...args] = def.argv; // def.argv 是 undefined
// TypeError: undefined is not iterable
整个 runVerification 的 Promise reject。而这违反了同一个文件里写着的不变式: 「命令不存在时不是跳过成功,而是记为 SPAWN_ERROR」。
更妙的是入口校验处有同样的洞:
ts
const unknownCommands = input.verificationCommandIds.filter(
(id) => !effectiveProfile.commands[id],
);
所以 'constructor' 能顺利通过「未登记的验证命令」这道 BAD_REQUEST 拦截, 一路走到运行时崩溃。
根因修在构造处,call site 再加一道:
ts
// 用无原型对象:commandId 是模型可控的字符串,普通对象字面量会让
// 'constructor' / 'toString' 这类 key 命中 Object.prototype 并被当成"已登记命令"
const commands: Record<string, CommandDefinition> = Object.create(null);
({...obj} 会重新带上原型,所以三个 call site 还是得用 Object.prototype.hasOwnProperty.call。)
静默省略又出现了两次
这个项目反复强调「任何省略都要报数量和原因」,但代码里还是漏了两处:
被删除的文件在补丁里彻底消失。 changedVsBaseline() 只遍历"当前存在"的文件, 所以基线里有、现在没了的文件既不在 authored 也不在 generated。 PatchFileEntry.changeKind 甚至只有 MODIFIED | ADDED。 这直接违反 patch.ts 自己 docblock 里的「不允许静默把某个文件从列表里去掉」。
fs_grep 跳过大文件和被取消时都返回「(无匹配)」。 超过 500KB 的文件 静默跳过,命中被丢掉;搜索被 abort 时也一路走到「(无匹配)」。 于是「真的搜完了没找到」和「压根没搜完」长得一模一样。
有意思的是,同一个函数里为 MAX_HITS 早停加了「结果不完整」声明 ------ 说明作者(我)知道这个原则,只是没贯彻到每一条分支。
测试自己的两个问题
复核 agent 挑出来的,都很中肯。
24 处 it.fails。 写测试的 agent 用它来标记"这条测的是已知缺陷"。 但 vitest 的 it.fails 对失败原因不做任何区分 ------ 夹具报错、ENOENT、 workspace 创建失败,全都会让它变绿。
最典型的是那条 SIGKILL 用例:它 readFileSync(pidFile) 拿 pid, 如果子进程因为任何原因没起来、pid 文件不存在,readFileSync 抛 ENOENT, it.fails 照样绿 ------ 而我们真正想验证的"进程还活着"一次都没被检查。
这正是本项目反对的那种断言。缺陷修完后我把 24 处全转成了正常 it。
两处 afterAll 无护栏地 rmSync(PATHS.root)。 PATHS 来自 vi.mock('../paths'),指向一个 mkdtemp 出来的临时目录。但一旦 mock 因为重构失效 (import 路径变了、mock 提升行为变化、有人改成动态 import),PATHS.root 就是用户真实的 ~/Library/Application Support/RepoPilotPrototype ------ 这一行会把它整棵删掉:projects.json、credentials.bin、全部快照。
ts
// 护栏:PATHS 来自 vi.mock,一旦 mock 失效这行会删掉用户真实的数据根。
// 只允许删临时目录,绝不碰 ~/Library/Application Support。
if (PATHS.root.startsWith(tmpdir()) || PATHS.root.startsWith('/private')) {
rmSync(PATHS.root, { recursive: true, force: true });
}
编排
10 个 agent 各写一个测试文件(文件互不重叠,可以安全并行),另有 1 个 agent 统一复核测试质量并核实报上来的缺陷。
给写测试的 agent 的核心约束有三条:
markdown
- **负向测试是主体。** 正向路径不容易错,错都错在边界上。
一个只有 happy path 的测试文件是不合格的。
- **断言要卡在"会说谎"的地方。** 例如不能只断言 passed === false ------
要断言失败的**原因**是对的(spawn 失败和真实构建失败都会让 passed=false)。
- **失败必须证明无副作用。** 凡是有写操作的模块,失败用例要断言状态逐字节不变。
以及一条很重要的分工纪律:
markdown
有失败就判断是测试写错了还是**产品真有 bug**:
- 测试写错 → 改测试
- 产品有 bug → **不要去改产品代码**(并行作业),把缺陷报上来,
让主进程统一处理
并行作业里最怕的就是九个 agent 同时去改同一个 command.ts。
11 个 agent,29 分钟,112 万 token。10 个写手里 9 个成功、1 个(gateway)连接中断, 最后落地 10 个新测试文件 ------ 所以 model gateway 的单测覆盖到现在仍然是空白,这条得说出来。
(后记:这个空白在 08-09 被补上了 ------ 先是 6 条门禁用例,随后 A2 又给两个适配器补了 30 条,见 同一道检查,只装在了一半的入口上。)
修完之后的一个信号
修完 command.ts 那六条,跑测试:
bash
× 输出超过 MAX_CAPTURE_BYTES 时,真正的末尾错误行仍应可见
→ Expect test to fail
× 预览被截断时 outputTruncated 必须为 true
→ Expect test to fail
× 跨 chunk 的多字节字符不能被解码成乱码
→ Expect test to fail
六条 it.fails 全部变成了「Expect test to fail」 ------ 它们本该失败, 现在却通过了。
这是个很干净的信号:缺陷真的修好了,而且是被这些测试精确捕捉到的那一条, 不是碰巧变绿。
可以带走的
- 永远不会红的断言等于没有断言。 检查你的安全性断言: 有没有一个测试是主动去触发它守护的那件事的?
- "这个逻辑很直"是缺陷密度最高的地方。 我跳过单测的五个模块,占了 19 个缺陷里的 16 个。
- 别复用语义相近的变量。
settled(Promise 定型)和childClosed(进程已死) 只差一点点,但那一点点让整段升级逻辑成了死代码。 - 截断要注意方向。 日志的结论在结尾,捕获层却在保留开头。
it.fails不是好工具。 它对失败原因不做区分。要标记已知缺陷, 就写成断言当前(错误)行为的普通it,修好时它会带着有意义的 diff 变红。- 测试里的
rmSync要有护栏。 尤其是路径来自 mock 的时候。 - 并行写测试时禁止改产品代码。 报上来统一处理,否则九个 agent 会打架。
上一篇:写了不能读的日志,不叫证据 下一篇:删除也要诚实