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

199 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TASK 隐私测试接管 stdout,吞掉测试运行器自己的报告(BUG-995)
- 日期:2026-09-21
- 基线 commit:`a06829b3`(`origin/staging` head;开工时 `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 不得把私密文本写进日志」,把标准输出整个接管了:
```ts
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.json` 的 `test` / `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 adapter` 的 `ok` 行每次 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_schema`**
且 **`PROBE` 这个字样可见**;对 `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` 汇总数**;给出可照抄的命令:
```bash
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 error,warning 不得超过 **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. 开工前置命令
```bash
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 确认)。