fix(test): passthrough stdout mocks so smalltalk TAP is visible

BUG-995: privacy tests were swallowing node:test TAP by replacing process.stdout.write without calling through.
This commit is contained in:
jesse-ux
2026-09-22 00:59:36 +08:00
parent 24795cbff8
commit 2503c0190b
5 changed files with 103 additions and 3 deletions
+16
View File
@@ -13183,3 +13183,19 @@
- 相关记录:BUG-150、BUG-993
- 复发自:无
- 修复版本:`a06829b3`staging 已部署;migrate 2843 / deploy 2844
## BUG-995 | 寒暄隐私测试接管 stdout,吞掉 node:test 自己的 TAP 报告
- 状态:resolved
- 首次发现:2026-09-21
- 最近更新:2026-09-22
- 影响面:`frontend/tests/consultation-smalltalk.test.ts` 三条 `default Mastra adapter protects privacy and usage on *` 用例的报告通道
- 用户现象:**无用户可见现象,纯测试基础设施。** 这三条失败时,报告可能只剩 `not ok 1 - <文件绝对路径>`,没有用例名和断言消息。退出码仍是 1,门禁不会漏掉失败。
- 触发条件:`t.mock.method(process.stdout/stderr, "write", …)` 全局接管标准输出,且不把内容交回原始 `write`
- 根因:要验证的「私密文本不得出现在进程输出」与 `node:test` 的 TAP 报告共用 `process.stdout`。mock 只 `logs.push` 并硬编码返回 `true``t.mock.restoreAll()``finally` 里,但报告行在测试回调返回之后异步写出,下一条 scenario 已经重新装上 mock,于是上一条 TAP 落进 `logs`
- 修复:装 mock 前 `bind` 保存原始 `write`mock 先把 chunk 记入 `logs`,再把 `chunk` / `encoding` / `callback` 交给原始 `write` 并返回其返回值。`console.*` 五个 mock 维持原样。隐私断言 `logs.join(" ").includes(sentinel)` 不改宽。
- 验证:单文件 `tsx --test tests/consultation-smalltalk.test.ts` 连续三次均为 `# tests 33` / `# fail 0` / `default Mastra adapter``ok` 行每次 4 条。变异:`invalid_schema` 注入 `PROBE` 后出现 `not ok 21 - default Mastra adapter protects privacy and usage on invalid_schema``PROBE` 可见;`bad_json``not ok 22` 同样可见。隐私探针:mock 装上后 `process.stdout.write(sentinel)`,三条均 `not ok` 且断言文案 `SDK must not log private text` 可见;探针已撤回未提交。本机全量 `npm test` 因 Windows Docker/symlink 既有缺口 `# fail 89`,但 `default Mastra adapter` 仍报 4 条;Linux 门禁以推送后 run 为准。
- 防复发:测试不得全局接管 `process.stdout` / `process.stderr` 而不透传。需要断言进程输出时,mock 必须把内容原样交回原始 `write`。比较测试规模时比用例名列表 diff,不比 `# tests` 汇总数。
- 相关记录:BUG-976、BUG-977
- 复发自:无
- 修复版本:待本轮合入 staging
@@ -0,0 +1,56 @@
# PROGRESS · 寒暄隐私测试 stdout mock 透传(2026-09-21 / 执行 2026-09-22
工作树:`.worktrees/smalltalk-test-stdout-mock-20260921`
分支:`codex/smalltalk-test-stdout-mock-20260921`
基线:`origin/staging` @ `24795cbf`(任务书写 `a06829b3`;开工 fetch 后实际 head
## 任务状态
| 任务 | 状态 | 说明 |
| --- | --- | --- |
| F1 mock 透传 | 完成 | 保存并 bind 原始 `write`,记录后原样转发,返回原始返回值 |
| F1 单文件三次 | 完成 | 每次 `# tests 33` / `# fail 0` / `default Mastra adapter``ok` 行 = 4 |
| F1 变异 | 完成 | `invalid_schema` / `bad_json``not ok` 含用例名和 `PROBE`;已撤回 |
| F1 隐私探针 | 完成 | 写 sentinel 后三条均 `SDK must not log private text`;已撤回 |
| F2 记录 | 完成 | BUG-995;状态板改待验收;无 CHANGELOG |
| F3 `frontend/AGENTS.md` | 完成 | 比用例名列表 diff,不比 `# tests` |
未改既有断言,无三栏。未跑 `next build`(只动测试与文档,不碰 `frontend/src`)。
## 开工复现
本机 Windows 单跑该文件三次(改前)已经是 4 条 `default Mastra adapter` / `# tests 33`,没有复现任务书在 Linux 主检出上看到的 1/4。仍按 D2 做透传:全仓只有这一处接管 `process.stdout.write`,且变异证明失败报告现在带用例名。
## 变异输出片段(已撤回,未提交)
`invalid_schema`
```
# Subtest: default Mastra adapter protects privacy and usage on invalid_schema
not ok 21 - default Mastra adapter protects privacy and usage on invalid_schema
PROBE
```
`bad_json`
```
# Subtest: default Mastra adapter protects privacy and usage on bad_json
not ok 22 - default Mastra adapter protects privacy and usage on bad_json
PROBE
```
隐私探针(`process.stdout.write(sentinel)`):exit 1`# fail 3`,三条均含 `SDK must not log private text`
## 测试
| 项 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错 |
| `npm run lint` | 0 error / 119 warning |
| 单文件 ×3(改后) | 每次 33 / 0 fail / Mastra `ok` 4 |
| 全量 `npm test` 本机 | `# tests 3603` / `# pass 3514` / `# fail 89`Mastra `ok` 4。89 条为 Windows Docker/symlink/shell 既有缺口,与本单无关;Linux 门禁以推送后 run 为准 |
| `npm run build` | 未跑(本单不碰 `frontend/src` |
## 环境缺口
本机全量三次无法当作门禁证据:与基线相同量级的 89 条 Windows 失败(Docker 网段、symlink、chmod、shell)。本单验收看 Gitea `backend-quality-gate` 的该文件 TAP 与全量绿盘。
+1 -1
View File
@@ -182,7 +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-smalltalk-test-stdout-mock-20260921.md` | `PROGRESS-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 确认)| 待验收 | `codex/smalltalk-test-stdout-mock-20260921` |
| `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` |
+10
View File
@@ -14,3 +14,13 @@ Before deleting or renaming a frontend symbol, class name, or visible copy, run
`test_daily_and_rectification_entrypoints.py`,
`test_birth_time_journey_contract.py`). Grepping only `frontend/` missed them
four times (BUG-933, BUG-934, BUG-939, BUG-992).
When comparing test-suite size, diff the **test name list**, not the `# tests`
summary. `# tests` can drop TAP rows when a test swallows `process.stdout`
(BUG-995). Baseline vs delivery:
```bash
npm test 2>&1 | grep -E "^(not )?ok [0-9]+ - " | sed -E 's/^(not )?ok [0-9]+ - //' | sort > /tmp/names.txt
```
Run that twice and `diff` the two files. New names are allowed; disappearing names are not.
+20 -2
View File
@@ -100,8 +100,26 @@ for (const scenario of ["invalid_schema", "bad_json", "provider_error"] as const
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; });
const stdoutWrite = process.stdout.write.bind(process.stdout);
const stderrWrite = process.stderr.write.bind(process.stderr);
t.mock.method(process.stdout, "write", ((
chunk: string | Uint8Array,
encoding?: BufferEncoding | ((error?: Error | null) => void),
callback?: (error?: Error | null) => void,
) => {
logs.push(String(chunk));
if (typeof encoding === "function") return stdoutWrite(chunk, encoding);
return stdoutWrite(chunk, encoding, callback);
}) as typeof process.stdout.write);
t.mock.method(process.stderr, "write", ((
chunk: string | Uint8Array,
encoding?: BufferEncoding | ((error?: Error | null) => void),
callback?: (error?: Error | null) => void,
) => {
logs.push(String(chunk));
if (typeof encoding === "function") return stderrWrite(chunk, encoding);
return stderrWrite(chunk, encoding, callback);
}) as typeof process.stderr.write);
result = await classifyConsultationTurn({ model: { ...model, model: fake }, question: sentinel, history: [], onObservation: (o) => observations.push(o) });
} finally { t.mock.restoreAll(); }
assert.deepEqual(result, { kind: "consult" });