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
This commit is contained in:
co-authored by
Claude Opus 5
parent
a06829b33e
commit
26c1fbc6eb
@@ -182,6 +182,7 @@
|
||||
|
||||
| 任务书 | 进度 | 主题 | 状态 | 落点 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `TASK-smalltalk-test-stdout-mock-20260921.md` | — | **测试基础设施(BUG-995)**:`consultation-smalltalk.test.ts` 为验证「SDK 不得把私密文本写进日志」,用 `t.mock.method(process.stdout/stderr, "write", …)` 全局接管标准输出——而 `node:test` 的 TAP 报告也走 `process.stdout`,于是运行器自己的 `# Subtest:` / `ok` 行被吞进测试的 `logs` 数组。实测四次全量:`default Mastra adapter` 四条用例只上报 4 / 2 / 1 / 1 条(`Subtest` 声明本身就少,不是 grep 锚点问题),`# tests` 因此 ±3 抖;单跑该文件 33 条静态展开只报 30 条,仅循环最后一个 `provider_error` 稳定可见。**更要紧**:注入必失败探针后,`invalid_schema` / `bad_json` 只剩一行 `not ok 1 - <文件绝对路径>`,**没有用例名、没有断言消息**(退出码仍是 1,门禁不会漏掉失败,不夸大)。定位 BUG-987 时靠的就是从门禁日志 grep `not ok` 抓用例名,红在这三条上那条路会断。**决策**:mock 改为记录后**透传给原始 `write`**(保留隐私断言,不换报告通道,不改 `npm test` 脚本);F3 把「比用例名列表 diff、不比 `# tests` 总数」写进 `frontend/AGENTS.md`。全仓只此一个文件用了这个手法(已 grep 确认)| 待领取 | — |
|
||||
| `TASK-frontend-optimization-20260828.md` | `PROGRESS-frontend-optimization-20260828.md` | 前端优化九条 | 待核对 | 分支 `codex/frontend-optimization-20260828` |
|
||||
| `TASK-frontend-followup-20260829.md` | `PROGRESS-frontend-followup-20260829.md` | 九条收尾 | 待核对 | — |
|
||||
| `TASK-frontend-interaction-20260830.md` | `PROGRESS-frontend-interaction-20260830.md` | 交互与 UI 收尾 | 待核对 | 分支 `codex/interaction-20260830` |
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# 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 确认)。
|
||||
Reference in New Issue
Block a user