Files
Jyotisha/docs/tasks/TASK-smalltalk-test-stdout-mock-20260921.md
T
Jesse_ChenandClaude Opus 5 26c1fbc6eb docs(tasks): 隐私测试接管 stdout 吞掉运行器报告(BUG-995)
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
2026-09-21 23:10:53 +08:00

11 KiB
Raw Blame History

TASK 隐私测试接管 stdout,吞掉测试运行器自己的报告(BUG-995)

  • 日期:2026-09-21
  • 基线 commita06829b3origin/staging head;开工时 git fetch 后以实际 head 为准)
  • 分支:codex/smalltalk-test-stdout-mock-20260921
  • BUG 编号起点:BUG-995docs/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. 硬红线

  1. assert.equal(logs.join(" ").includes(sentinel), false, "SDK must not log private text") 这条断言必须保留且仍然有效。
  2. 透传之后 logs 里会多出运行器自己的 TAP 文本,不得因此把断言改宽(例如改成只查 console 那部分)。 TAP 文本里不含 sentinel,断言原样成立;若发现某处成立不了,先查是不是真漏了,而不是改断言。
  3. 不得改 frontend/package.jsontest / test:db 脚本。
  4. 不得动 frontend/tests/ 下的其它文件。
  5. 改任何既有断言都要在进度记录里写「原值 / 新值 / 原因」三栏(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 维持原样(它们不走运行器的报告通道,没有问题)。
  • 验收标准(逐条都要有实测数字,不得只写"通过"):
    1. 单独跑 npx tsx --test tests/consultation-smalltalk.test.ts 连续三次 每次 # tests 都是同一个数,且 default Mastra adapter 开头的 ok每次都是 4 条
    2. 全量 npm test 连续三次# tests 三次相同,default Mastra adapterok 行每次 4 条。
    3. 变异测试:临时给 invalid_schema 注一条必失败断言(if (scenario === "invalid_schema") assert.equal(1, 2, "PROBE")), 确认输出里出现 not ok N - default Mastra adapter protects privacy and usage on invalid_schemaPROBE 这个字样可见;对 bad_json 再做一次。做完撤回注入,不要提交。 进度记录里贴这两次的输出片段。
    4. 隐私断言仍有效:临时让被测代码往 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.txt
    
    基线与交付各跑一次,diff 两份名单;只允许新增,不允许消失。
  • 验收标准: frontend/AGENTS.md 有这条且命令可直接复制运行。

6. 交付前必须全跑

  • cd frontend && ./node_modules/.bin/tsc --noEmit → 0 错
  • npm run lint → 0 errorwarning 不得超过 119
  • npm test → 全量,跑三次;用用例名列表 diff与基线比对(不是比总数),只允许新增
  • 不需要 npm run build(只动测试文件,不碰 frontend/src;进度记录里写明这一点即可,不要编造构建数字)
  • 不需要 test:db、不需要 Python 门(本单不碰 Python,也不碰迁移)

7. 让步顺序

  1. 透传后隐私断言意外转红 → 先查是不是真的漏了 sentinel(那是真 Bug,另记一条); 确认是 TAP 文本误伤才允许把断言改成"排除运行器自身输出",并在进度记录里写清三栏。不得直接放宽成只查 console。
  2. F3 的 frontend/AGENTS.md 那条不得让步。
  3. 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、§8frontend/AGENTS.mddocs/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 确认)。