consultation-smalltalk.test.ts mock process.stdout/stderr, node:test 的 TAP 也走 stdout,四条用例只上报 1~4 条, 失败时只剩一行 not ok <文件路径>。修法是 mock 透传。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
11 KiB
TASK 隐私测试接管 stdout,吞掉测试运行器自己的报告(BUG-995)
- 日期:2026-09-21
- 基线 commit:
a06829b3(origin/staginghead;开工时git fetch后以实际 head 为准) - 分支:
codex/smalltalk-test-stdout-mock-20260921 - BUG 编号起点:BUG-995(
docs/BUG_HISTORY.md当前最大号BUG-994,开工时复核) - 关联记录:AGENTS.md §7.3(测试总数不得低于基线)
- 涉及文件:只有
frontend/tests/consultation-smalltalk.test.ts
1. 事故实证
frontend/tests/consultation-smalltalk.test.ts 里那条三 scenario 的循环测试
(default Mastra adapter protects privacy and usage on ${scenario},scenario ∈ {invalid_schema, bad_json, provider_error})
为了验证「SDK 不得把私密文本写进日志」,把标准输出整个接管了:
for (const method of ["log", "info", "warn", "error", "debug"] as const) {
t.mock.method(console, method, (...values: unknown[]) => { logs.push(JSON.stringify(values)); });
}
t.mock.method(process.stdout, "write", (chunk: unknown) => { logs.push(String(chunk)); return true; });
t.mock.method(process.stderr, "write", (chunk: unknown) => { logs.push(String(chunk)); return true; });
node:test 的 TAP 报告也走 process.stdout。 mock 装着的时候,运行器自己写出去的
# Subtest: … 与 ok N - … 行被这两个 mock 吞进 logs 数组,再也没出去。
1.1 现象一:用例在报告里时有时无
本机(origin/staging,链到主检出 node_modules)连跑四次全量 npm test,
数「default Mastra adapter」开头的用例(静态展开应为 4 条:循环外 1 条 + 循环 3 条):
| 跑次 | # tests |
# Subtest: 声明数 |
ok 行数 |
|---|---|---|---|
| 1 | 3647 | 4 | 4 |
| 2 | 3645 | 2 | 2 |
| 3 | 3644 | 1 | 1 |
| 4 | 3644 | 1 | 1 |
Subtest 声明本身就少了——不是我 grep 的锚点问题,是那几行根本没出现在输出里。
单独跑这个文件同样只报 30 条(静态展开 33 条),只有 provider_error 那条稳定可见:
它是循环最后一个,跑完 t.mock.restoreAll() 之后没有下一个测试再装 mock,所以自己的报告行得以逃出。
1.2 现象二(更要紧):这三条失败时看不出是哪条、为什么
我往断言后面注入必失败的探针,逐个 scenario 试:
| 注入位置 | 退出码 | # fail |
报告出来的内容 | 断言文案可见 |
|---|---|---|---|---|
| 三个 scenario 全失败 | 1 | 1(不是 3) | not ok 20 - …on provider_error |
是 |
只 invalid_schema 失败 |
1 | 1 | not ok 1 - <文件绝对路径> |
否 |
只 bad_json 失败 |
1 | 1 | not ok 1 - <文件绝对路径> |
否 |
只 provider_error 失败 |
1 | 1 | not ok 20 - …on provider_error |
是 |
结论要写准,不要夸大:门禁不会漏掉失败——四种注入退出码都是 1,红还是会红。
坏的是红了看不出是什么:前两种情况只剩一行 not ok 1 - <文件路径>,
没有用例名、没有断言消息、没有 expected/actual。
这条很实际:2026-09-21 定位 BUG-987 时,我是从 3.5 MB 的门禁日志里 grep "not ok" 抓出用例名才找到根因的。
如果哪天红在这三条上,那条路直接断掉。
1.3 影响面只有这一个文件
grep -rln 'mock.method(process.stdout\|mock.method(process.stderr' frontend/tests/ 只命中
consultation-smalltalk.test.ts。--test 每个测试文件跑在各自的子进程里,所以吞的只是本文件的报告,
不会波及别的文件——现象一的表格里「同文件其它用例」始终稳定上报,也印证了这点。
2. 根因
测试要验证的东西(「私密文本不得出现在进程输出里」)与测试框架用来汇报自己的通道(process.stdout)是同一条。
用全局 mock 去拦截这条通道,就一定会连运行器的报告一起拦掉。
t.mock.restoreAll() 放在 finally 里没错,但报告行是在测试回调返回之后、由运行器异步写出的,
那时下一个 scenario 已经把 mock 重新装上了,于是上一条的报告落进下一条的 logs。
3. 决策记录
- D1:保留这三条测试要验证的东西。 「SDK 不得把私密文本写进 stdout/stderr」是 BUG-976/977 那条隐私线上的真实断言,
不得为了让报告干净就把
process.stdout/process.stderr的覆盖删掉,只留console.*。 - D2:修法是让 mock 透传,不是换报告通道。 在 mock 里记录 chunk 之后,调用原始
write把内容照原样写出去。 隐私断言照旧在logs上做,运行器的 TAP 不再被吞。 不采用「给npm test加--test-reporter-destination把报告重定向到文件」——那是为了一个文件改全套件的输出方式, 代价和风险都不对等。 - D3:不接受把这三条测试标
skip或删掉。
4. 硬红线
assert.equal(logs.join(" ").includes(sentinel), false, "SDK must not log private text")这条断言必须保留且仍然有效。- 透传之后
logs里会多出运行器自己的 TAP 文本,不得因此把断言改宽(例如改成只查console那部分)。 TAP 文本里不含 sentinel,断言原样成立;若发现某处成立不了,先查是不是真漏了,而不是改断言。 - 不得改
frontend/package.json的test/test:db脚本。 - 不得动
frontend/tests/下的其它文件。 - 改任何既有断言都要在进度记录里写「原值 / 新值 / 原因」三栏(AGENTS.md §7.3)。
5. 任务分解
F1|mock 透传,报告不再被吞(BUG-995)
- 在装 mock 之前保存原始函数引用(
process.stdout.write/process.stderr.write,注意bind到各自的流)。 - mock 实现改为:先
logs.push(String(chunk)),再把chunk(连同encoding/callback参数)交给原始write, 并返回原始write的返回值,而不是硬编码true——硬编码true会让背压信号失真。 console.*那五个 mock 维持原样(它们不走运行器的报告通道,没有问题)。- 验收标准(逐条都要有实测数字,不得只写"通过"):
- 单独跑
npx tsx --test tests/consultation-smalltalk.test.ts连续三次, 每次# tests都是同一个数,且default Mastra adapter开头的ok行每次都是 4 条。 - 全量
npm test连续三次,# tests三次相同,default Mastra adapter的ok行每次 4 条。 - 变异测试:临时给
invalid_schema注一条必失败断言(if (scenario === "invalid_schema") assert.equal(1, 2, "PROBE")), 确认输出里出现not ok N - default Mastra adapter protects privacy and usage on invalid_schema且PROBE这个字样可见;对bad_json再做一次。做完撤回注入,不要提交。 进度记录里贴这两次的输出片段。 - 隐私断言仍有效:临时让被测代码往
process.stdout.write里写一次 sentinel,确认测试转红;撤回,不要提交。
- 单独跑
F2|记录(BUG-995)
docs/BUG_HISTORY.md新增 BUG-995。- 用户现象:无用户可见现象,纯测试基础设施。
- 根因与修复按 §2 / §5 写。
- 防复发写两条:测试不得全局接管
process.stdout/process.stderr而不透传; 需要断言进程输出时,mock 必须把内容原样交回原始write。 - 相关记录关联 BUG-976/977(隐私断言的来源)。
docs/tasks/README.md状态板加行。- 不需要动
CHANGELOG.md(用户侧无变化)。
F3|把「总数不可信」这件事写进纪律(BUG-995)
AGENTS.md §7.3 写的是「测试总数不得低于开工时 origin/staging 的实测」。
在这个套件上,总数本身会因为本 Bug 抖动 ±3,这条门槛判不准;修好之后总数才恢复可信。
- 在
frontend/AGENTS.md(上一轮刚加了「改前端符号前git grep -- tests/ frontend/」那节)再加一条: 比较测试规模时比用例名列表的 diff,不比# tests汇总数;给出可照抄的命令:基线与交付各跑一次,npm test 2>&1 | grep -E "^(not )?ok [0-9]+ - " | sed -E 's/^(not )?ok [0-9]+ - //' | sort > /tmp/names.txtdiff两份名单;只允许新增,不允许消失。 - 验收标准:
frontend/AGENTS.md有这条且命令可直接复制运行。
6. 交付前必须全跑
cd frontend && ./node_modules/.bin/tsc --noEmit→ 0 错npm run lint→ 0 error,warning 不得超过 119npm test→ 全量,跑三次;用用例名列表 diff与基线比对(不是比总数),只允许新增- 不需要
npm run build(只动测试文件,不碰frontend/src;进度记录里写明这一点即可,不要编造构建数字) - 不需要
test:db、不需要 Python 门(本单不碰 Python,也不碰迁移)
7. 让步顺序
- 透传后隐私断言意外转红 → 先查是不是真的漏了 sentinel(那是真 Bug,另记一条); 确认是 TAP 文本误伤才允许把断言改成"排除运行器自身输出",并在进度记录里写清三栏。不得直接放宽成只查 console。
- F3 的
frontend/AGENTS.md那条不得让步。 - F1 不得让步。
8. 开工前置命令
cd /workspace/Jyotisha
git status -sb | head -1
git fetch origin --prune
git worktree add -b codex/smalltalk-test-stdout-mock-20260921 \
.worktrees/smalltalk-test-stdout-mock-20260921 origin/staging
cd .worktrees/smalltalk-test-stdout-mock-20260921/frontend
ln -s /workspace/Jyotisha/frontend/node_modules node_modules # 或 npm ci
# 复现(改之前先亲眼看一次,不要跳过)
for i in 1 2 3; do
npx tsx --test tests/consultation-smalltalk.test.ts 2>&1 \
| grep -cE "^(not )?ok [0-9]+ - default Mastra adapter"
done # 期望看到 1,而静态展开应为 4
# 记录基线用例名单
npm test 2>&1 | grep -E "^(not )?ok [0-9]+ - " | sed -E 's/^(not )?ok [0-9]+ - //' | sort > /tmp/names-base.txt
开工必读:AGENTS.md §7.3、§8;frontend/AGENTS.md;docs/BUG_HISTORY.md 的 BUG-976 / BUG-977(隐私断言的由来)。
注意:在 frontend/ 目录里跑 npx tsx --test 会生成一个未跟踪的 frontend/frontend/node_modules,
交付前 rm -rf frontend/frontend 清掉,别让它进提交。
9. 不在本单范围内
- 不改
npm test的并发度或 reporter。 - 不重构
consultation-smalltalk.test.ts的其它 12 条用例。 - 不处理别的测试文件——全仓只有这一个文件用了这个 mock 手法(已用 grep 确认)。