# dsh-interaction-notify — 宿主半功能测试证据(T3) 状态:**PASS** — 90 / 90 用例通过,连续 3 轮全绿,无 flaky。 被测实现:`src/detection.js`、`src/config.js`、`src/notifier.js`、`src/index.js`(T1,按 CONTRACT.md)。 测试代码:`test/detection.test.mjs`、`test/config.test.mjs`、`test/notifier.test.mjs`、`test/routes.test.mjs`、`test/e2e.test.mjs`、`test/lib/harness.mjs`。 测试只写 `test/**`;**不改** `src/`、`assets/`、`scripts/`、`CONTRACT.md`、`README.md`。 --- ## 1. 运行命令与结果 本机 Node 版本:**v25.9.0**(`node` 在 PATH)。 > ⚠ Node 25 的 `node --test test/` / `node --test test\` **不再接受目录参数**,会把 `test` 当模块加载并报 > `Cannot find module '…\plugins\dsh-interaction-notify\test'`。必须显式给文件或 glob。 可用的运行方式(已实测): ```powershell # 工作目录:插件根目录(仓库根) & node --test "test/*.test.mjs" # 推荐(Node 自行展开 glob,实测可用) # 或显式列文件: & node --test test/config.test.mjs test/detection.test.mjs test/notifier.test.mjs test/routes.test.mjs test/e2e.test.mjs # 或(无参数,Node 自动发现;会额外把 test/lib/harness.mjs 也当测试文件统计,结果 91 条,同样全绿) & node --test ``` 尾部输出(`--test "test/*.test.mjs"`,连续 3 轮一致): ``` ✔ invalid JSON bodies answer 400 (77.6ms) ✔ non-object JSON bodies are refused with 400 where an object is required (76.3ms) ✔ a body above 256 KiB is refused and the host stays alive (77.6ms) ✔ an empty POST body is refused with 400 (no crash) (76.5ms) ℹ tests 90 ℹ suites 0 ℹ pass 90 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 4765.6 ``` 分文件:`detection 13/13`、`config 16/16`、`notifier 26/26`、`routes 20/20`、`e2e 15/15`。 ### 测试安全铁律(已验证) - **绝无真实通知/响铃**:每个用例都从 `createPlugin({ deliver: fakeDeliver })` 或 `createNotifier({ deliver: fakeDeliver })` 注入假投递;`src/deliver.js` 从不被当作投递实现调用 (测试只 import 模块,模块级 `apply()` 生产入口**从不调用**,否则会接上真实 deliver)。 - **绝不写真实 `$DSH_HOME`**:每个用例 `mkdtemp` 出独立临时目录并 `process.env.DSH_HOME = `, 同时给 `createPlugin`/`createNotifier` 传 `paths: /dsh-interaction-notify` 固定落盘位置; `harness.assertTempPath()` 拒绝任何非 `os.tmpdir()` 下的路径操作。 - 无第三方依赖,只用 `node:test` / `node:assert` / Node 内置模块。 --- ## 2. 用例清单 ### `test/detection.test.mjs`(13,契约「纯函数契约」) | # | 用例 | 断言要点 | |---|---|---| | 1 | approval/asked → approval | `kind='approval'`、`id='approval:ap-42'`、标题 `需要你的批准`、正文 `工具「Bash」需要你授权` | | 2 | approval + reason | 正文后缀 `:`(全角冒号) | | 3 | approval 超长 reason | reason 部分 ≤ 120 字,前缀保留 | | 4 | approval 无 reason | 正文不含 `undefined`,等于固定文案 | | 5 | ask_user_question | `kind='question'`、`id='question:'`、标题 `需要你的回答`、正文=第一个问题 | | 6 | question 含换行 | 正文无 `\n`,两行内容都保留 | | 7 | question 超长 | 正文 ≤ 140 字 | | 8 | question 多问题 | 追加 `(共 3 个问题)`,正文含第一问 | | 9 | questions 为空数组 | 正文回退 `请打开对话回答问题` | | 10 | exit_plan_mode | `kind='plan'`、`id='plan:'`、固定标题/正文 | | 11 | 无关事件 | `session/start`、`message/added`、其它 `tool/call`、缺 data 的 approval/tool-call → `null` | | 12 | 脏数据 | 17 组(undefined/null/number/string/array/`{}`/data 为字符串/data 缺 id 或 toolName/questions 非数组/questions 含 null/arguments 缺失或 null…)**绝不抛错**,非 null 时必须是完整四字段描述 | | 13 | 纯函数 | 同输入两次 deepEqual,且不修改入参 | ### `test/config.test.mjs`(16,契约「设置契约」) | # | 用例 | 断言要点 | |---|---|---| | 1 | 导出面 | `DEFAULT_CONFIG`、`normalize`、`load`、`save` 存在 | | 2 | 默认值 | 与契约默认值逐字段 deepEqual(含 `volume:55`、`events{question,approval,plan}`、`sound:'chime'`、`quietWhenFocused:true`、`clickToReturn:true`、`minIntervalMs:1200`) | | 3 | 无 state.json 时 load() | 返回默认值、不抛错 | | 4 | normalize(undefined/null/{}) | 均等于默认值 | | 5 | normalize 合法值 | `enabled:false`、`sound:silent/system`、`minIntervalMs:5000`、`minIntervalMs:0`、`volume:35`、两个开关 false 均保留 | | 6 | **volume 契约** | 默认 55;`-20/-1/0/1/99/100/150/55.6/'loud'/null/NaN/Infinity` 归一化后必为 **0–100 整数**;`'loud'`/`null` 回退 55;负数不会变响、超 100 不会变静;`volume:0` 保留 | | 7 | normalize 非法值 | `sound:'evil'|42 → 'chime'`;`minIntervalMs:'abc' → 1200`;`enabled:'yes please' → true`;`events.question:'yes' → true`;`events:'nope' → 默认` | | 8 | events 深合并 | `{events:{question:false}}` → approval/plan 仍 true;入参不被修改 | | 9 | save 深合并 | 两次 partial(`quietWhenFocused`、`events.question`)都保留,未触及字段仍为默认 | | 10 | 落盘读回 | `$DSH_HOME/dsh-interaction-notify/state.json` 存在,内容与 `load()` 一致 | | 11 | save 归一化 | 非法 sound/interval/event 写入后读回为默认值,磁盘无 `evil` | | 12 | **volume 持久化** | `save({volume:35})` → `load()`=35、磁盘=35;`save({volume:999})` 读回 0–100 整数;新模块实例读回同值 | | 13 | 原子写 | 连续 8 次 save 后目录无 `.tmp-*` 残留,`state.json` 为最后一次 | | 14 | 损坏 JSON | `state.json` 写入坏 JSON → 回退默认、不抛错 | | 15 | 错误形状 | `[]` / `null` → 回退默认 | | 16 | 真落盘 | 新 import 的模块实例仍读到已存 `enabled:false`/`events.question:false` | ### `test/notifier.test.mjs`(26,契约「`src/notifier.js`」) | 组 | 用例 | 断言要点 | |---|---|---| | 统计 | stats 形状 | `attempted/delivered/skipped/failed` 为 number,`lastAt`/`lastError` 存在 | | 投递 | approval 消息 | `deliver(message, options)` 收到 `{kind:'approval',title,body}`;options.sound=`chime`、**volume=55**、quietWhenFocused=true、clickToReturn=true | | | volume 透传 | config.volume=42 → options.volume=42 且为 0–100 数字 | | | question/plan | 两种消息体形状正确 | | | sound=silent | options.sound 原样透传 | | 去重 | 同 id | 只投递 1 次 | | | 不同 id | 各投递 1 次 | | | 上限 200 | 250 个 id 全投递后,`cap-0` 被淘汰可再投(251 次),`cap-249` 仍被去重(仍 251 次) | | 节流 | minIntervalMs | 1200ms 内第二个非 approval 被**丢弃且不补发**(1.3s 后仍 1 次) | | | 间隔到期 | 150ms 后第二个放行 | | | `minIntervalMs:0` | 连续两个不同 id 都投递(关闭节流) | | | approval 豁免 | 6 万 ms 间隔下 question 被节流,但 approval 连续 3 次都投递;其后非 approval 仍被节流 | | 开关 | enabled=false | 三类事件均不投递,`stats.delivered=0` | | | events 开关 | 仅 approval 投递,question/plan 被拦 | | 容错 | deliver 同步抛 | `handleEvent` 不抛;后续事件仍投递;`stats.failed>=1`、`lastError` 含错误文本、`logger.warn` 被调用 | | | deliver reject | 同上 | | | deliver skipped | `stats.skipped>=1`、delivered=0、不当作失败 | | | 统计计数 | attempted/delivered/lastAt 正确 | | | 同步语义 | `handleEvent` 返回值无 `then`(非 async);6 组脏事件不抛、0 投递 | | 日志 | 每次尝试一行 | `notifications.log` 写入 `{t,kind,id,ok}`,`id='approval:log-1'`、ok=true | | | 失败行 | `ok:false` 且 `error` 含错误文本 | | | 压缩 | 预置 500 行 + 新 1 行 → 等待压缩完成后 **≤201 行**(实测 200),且最后一行是新记录 | | deliverNow | 绕过节流/quiet | 节流已武装时仍投递;`quietWhenFocused` 不为 true;`sound` 仍为 config 值、volume=config 值;返回 `{ok:true}` | | | 不去重 | 同一 message 连续两次都投递 | | | volume 覆盖 | `deliverNow(msg,{volume:20})` → options.volume=20;不传则用 config | | | 失败不抛 | deliver 抛错时 `deliverNow` 仍 resolve `{ok:false}` | ### `test/routes.test.mjs`(20,契约「HTTP 路由」) | 组 | 用例 | 断言要点 | |---|---|---| | 生命周期 | apply 注册/释放 | 假 `ctx.webServer` 收齐 5 条 `kind:'exact'` 路由;`ctx.effect` 1 个且 label 为 `dsh-interaction-notify: routes + events`;`session/event` 监听 1 个;dispose 后路由清空、监听失效 | | /state | GET | 200;`content-type: application/json; charset=utf-8`;`cache-control: no-store`;`{config,platform,rev,stats}`;默认含 volume 55 | | /set | 合并+落盘 | 200 `{config,rev}`;enabled/sound/volume/events 生效、events 深合并、未触及字段保留、rev 前进;`state.json` 落盘;随后 GET 读回 | | | **volume=35** | `/set {volume:35}` → 响应 35;`GET /state` 读回 35 | | | 非法值 | `sound:'evil'`→chime、`minIntervalMs:'abc'`→1200、`volume:'loud'`→55、`events.question:'yes'`→true;`volume:999`→100 | | | 跨实例持久化 | 新 mount 的实例(同 home)`refresh()` 后读回磁盘上的 `volume:35`、`enabled:false`(覆盖 seed) | | /test | 绕过节流/quiet | 节流已武装时仍投递;`quietWhenFocused!==true`;body.volume=20 覆盖 config;回包 `{ok,stats}` | | | 无 body | 仍投递,默认标题/正文非空 | | | skipped | HTTP 200 且 `ok:false`,`stats.skipped>=1` | | /log | GET | 空时 `[]`;投递后出现日志行且含 `kind` | | /diag | POST | 200 `{ok:true,path}`,path 在 `/diag/probe.json`,payload 原样落盘 | | | 路径穿越 | `name:'../../evil'`:200 时 path 仍锁在 diag 目录内(或被 ≥400 拒绝),home 根无 `evil.json` | | 鉴权 | 非 loopback 403 | 5 条路由 × `remoteAddress=10.11.12.13` 全部 403 | | | loopback 放行 | `127.0.0.1`、`127.9.9.9`、`::1`、`::ffff:127.0.0.1` 全部 200 | | | Origin/Host | 跨源 Origin 403;同源 `http://127.0.0.1:61527` 200;`localhost:61527` 同源 200;伪造 Host(`evil.example`、`127.0.0.1.nip.io:61527`、`192.168.1.20:61527`)403;不可解析 Origin(`null`)403 | | | 方法 405 | 5 条路由反向方法全部 405 | | body | 非法 JSON 400 | `/set`、`/test`、`/diag` | | | 非对象 JSON 400 | `/set` 传 `[1,2,3]`、`"just a string"`、`42` | | | >256 KiB | 300 KiB body 被拒(≥400),之后 `/state` 仍 200(宿主存活) | | | 空 body | `/set` 空 body → 400 | ### `test/e2e.test.mjs`(15,契约「`src/index.js`」+ 端到端) | # | 用例 | 断言要点 | |---|---|---| | 1 | 导出面 | `name`、`inject=['webServer']`、`createPlugin`、`apply` | | 2 | approval 端到端 | 假 ctx 的 `session/event` 监听收到事件 → fake deliver 1 次;消息四字段精确;options 含 sound/volume(55)/quietWhenFocused/clickToReturn | | 3 | question 端到端 | 消息/标题/正文正确 | | 4 | plan 端到端 | 消息/标题/正文正确 | | 5 | 三类混合 | 一次会话三种事件 → 3 次投递,顺序 approval/question/plan | | 6 | 无关事件 | 6 组无关/垃圾事件 → 0 投递、不抛 | | 7 | config seed | `createPlugin({config:{sound:'silent',volume:40}})` → options.sound/volume 生效 | | 8 | enabled=false | 三类事件都不投递 | | 9 | events 开关 | 只投递 approval | | 10 | 节流 | 6 万 ms 间隔下 3 个 question → 只投递 1 个 | | 11 | 去重 | 同事件 emit 两次 → 只投递 1 次 | | 12 | deliver 抛错 | 监听器不抛;后续事件仍投递(共 2 次);`logger.warn` 有记录;`GET /state` 的 `stats.failed>=1` 且 `lastError` 含错误文本 | | 13 | 垃圾事件 | 7 组(含 undefined/null/数字/空数组)不抛、0 投递 | | 14 | /state stats | 真实事件后 `attempted>=2`、`delivered>=2`、`lastAt` 为数字 | | 15 | dispose | 释放后事件不再投递、路由全部移除 | --- ## 3. 覆盖的契约条款 - 触发源表:`approval/asked` / `tool/call(name=ask_user_question)` / `tool/call(name=exit_plan_mode)` → approval/question/plan(detection #1–#10、e2e #2–#5)。 - `resolveInteraction` 返回形状、id 前缀(`approval:` / `question:` / `plan:`)、固定标题、reason/问题截断(120 / 140)、`(共 N 个问题)` 后缀、无 questions 回退文案、无关事件 `null`、脏数据不抛错(detection 全部)。 - 设置默认值 **含 `volume:55`**、`sound` 枚举回退、`volume` 0–100 整数(越界夹紧/非法回退)、`events` 深合并、`enabled=false` 全静默、`load()/save()` 合并、原子写(`.tmp--` + rename,无残留)、读失败回退默认(config 全部)。 - 通知器:同 id 去重(Set 上限 200,丢最旧)、`minIntervalMs` 节流且不排队、**approval 不受节流**、统计计数、`notifications.log` JSON 行 `{t,kind,id,ok,skipped?,error?}`、超 400 行压到最近 200 行、任何异常吃掉并 `logger.warn`、`deliverNow` 绕过 quiet/节流但仍尊重 sound(并支持 volume 覆盖)(notifier 全部)。 - 投递参数:`deliver(message, options)` 的 `options.sound` / **`volume`** / `quietWhenFocused` / `clickToReturn` 由 config 透传(notifier #2、#3、e2e #2/#7)。 - HTTP 路由:5 条路径与方法、`{config,platform,rev,stats}`、`/set` 部分配置(含 volume)、`/test`(title/body/kind/sound/volume 与绕过节流)、`/log` 200 行尾、`/diag` 落盘、loopback+同源 guard(403)、方法 guard(405)、JSON/形状/大小(256 KiB)→400、`writeJson` 头(`application/json; charset=utf-8` + `no-store`)、handler try/catch(routes 全部)。 - Cordis 宿主半:`name`/`inject`/`createPlugin(deps)`/`apply(ctx,config)`、`ctx.effect` 内一次性注册路由+事件并在 disposer 中释放、`ctx.logger('dsh-interaction-notify')` 取 logger(e2e #1、#15;routes 生命周期)。 --- ## 4. 未覆盖 / 风险 / 说明 1. **真实 Windows 投递未在测试中触发(按铁律刻意排除)**:`assets/notify.ps1` / `activate.ps1` / `activate.vbs`、真实 toast+声音、点击通知回到 DSH 窗口属于 Lead 的 E2E(契约验收 3、4)。测试只验证“宿主半在正确时机调用 deliver 且参数正确”。 2. **`src/deliver.js` 未做单元测试**(Lead 所有,本任务不覆盖;测试把所有 deliver 调用替换为假实现)。 3. **文件 mode 0o600 未断言**:Windows/Node 下 `chmod` 语义不映射 POSIX 权限位,断言会误报;同一目录的原子写与无 `.tmp-` 残留已覆盖。 4. **`lib/*.js` 构建一致性未覆盖**(属 T1 `scripts/build.mjs`,验收 1;建议 Lead 用 `node scripts/build.mjs` 后逐文件 diff)。 5. **并发 `save()`(两个 /set 同时写)未覆盖**:契约只说原子写(temp+rename);单进程串行写已覆盖,并发 rename 在 Windows 上的胜出顺序不属契约语义。 6. **Node 版本差异**:`node --test <目录>` 在 Node 25 不可用(见 §1);REPORT 中的命令均在本机实测。若换 Node 20/22,`node --test test/` 可能恢复可用。 7. **测试注入的 `paths` 依赖**:notifier 用例与 routes/e2e 用例给 `createNotifier`/`createPlugin` 传了 `paths`(`src` 支持的测试注入项,CONTRACT 只在 `createPlugin(deps)` 里列了 `paths?`)。目的是把 `state.json`/`notifications.log` 钉在该用例的临时目录,避免 fire-and-forget 日志写跨用例串台(曾复现:上一用例的 250 条日志写入落到下一用例的 `$DSH_HOME`)。行为断言不依赖它;若将来删除该注入点,需改为“等日志写完再切换 `$DSH_HOME`”。 8. **契约文字的一处解释**:`approval` 的“截断 120 字”按“reason 部分 ≤120 字(固定前缀不计)”实现与断言(`src` 与测试一致);若 Lead 原意是“整条正文 ≤120 字”,需同时改实现与 `test/detection.test.mjs#3`。 9. **`/test` 的 `quietWhenFocused:false` 断言**:按“绕过 quiet”理解为测试通知必须传 `quietWhenFocused:false`(测试断言 `!== true`)。 10. **未覆盖的路由细项**:`GET /diag`(契约未定义,仅测 405)、`/log` 的 200 行上限(只验证非空与有界语义在 notifier 层)、`rev` 单调严格递增(只验证不倒退)。