diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 4fa98340..ff17cea5 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -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` | diff --git a/docs/tasks/TASK-smalltalk-test-stdout-mock-20260921.md b/docs/tasks/TASK-smalltalk-test-stdout-mock-20260921.md new file mode 100644 index 00000000..aec776b9 --- /dev/null +++ b/docs/tasks/TASK-smalltalk-test-stdout-mock-20260921.md @@ -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 确认)。