diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index bb79769d..7e5fedac 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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 diff --git a/docs/tasks/PROGRESS-smalltalk-test-stdout-mock-20260921.md b/docs/tasks/PROGRESS-smalltalk-test-stdout-mock-20260921.md new file mode 100644 index 00000000..348bf16a --- /dev/null +++ b/docs/tasks/PROGRESS-smalltalk-test-stdout-mock-20260921.md @@ -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 与全量绿盘。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index ff17cea5..59d033e0 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -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` | diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 99c1b69e..5bc4134f 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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. diff --git a/frontend/tests/consultation-smalltalk.test.ts b/frontend/tests/consultation-smalltalk.test.ts index 2c231bf8..e71b7cc0 100644 --- a/frontend/tests/consultation-smalltalk.test.ts +++ b/frontend/tests/consultation-smalltalk.test.ts @@ -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" });