插件测试、卸载与版本证据
这篇回答一个实际问题:一个 DSH 插件“能启动”以后,怎样证明它真的能安装、工作、失败、取消、卸载和升级。单元测试只能证明局部函数;学习 DSH 扩展时,要把证据按装配层级排列,并明确哪些结论只是静态阅读,哪些结论已经由测试运行得到。
先把“通过”拆成几种结论
| 结论 | 最小证据 | 不能顺便推出什么 |
|---|---|---|
| 模块能编译 | 类型检查或构建成功 | Loader 能找到包、运行时能激活、卸载没有泄漏 |
| 函数行为正确 | 单元测试覆盖输入和输出 | Cordis Fiber、服务注册、真实配置和退出流程正确 |
| 插件能组合 | 真实 Loader 读取 manifest、patch 并启动 | 所有 Profile、平台和构建产物都兼容 |
| 用户能看到正确结果 | 快照或 UI 断言 | 权限、网络、子进程和卸载一定正确 |
| 真实服务可用 | 标记清楚的 API 或 E2E 测试 | 没有 key 时也能运行,或换平台仍然可用 |
| 能干净卸载 | dispose 后资源和事件都停止的断言 | 升级、重复安装和异常中止一定正确 |
因此 README 中不要只写“测试通过”。应写测试类型、固定版本、运行命令、是否需要密钥、覆盖的装配路径,以及未覆盖的外部条件。
六层证据,从便宜到接近用户
第一层是纯函数和协议单元测试,例如配置解析、工具参数校验、事件映射、权限决策和输出格式。它们速度快、失败定位清楚,适合先验证不依赖宿主的规则;但不能用来证明插件被 Loader 正确装配。
第二层是最小 Context 测试。创建一个受控 Cordis Context,挂载插件,读取它公开的服务或事件,执行一次最小行为,再调用 dispose。测试应断言真实关系,例如服务确实被消费者使用、事件确实改变了受保护的状态,而不是只断言 ctx.plugin 或 register 方法存在。
第三层是 Loader 组合测试。使用和发布包相同的 package manifest、Bundle patch、Profile 和入口,走 loadProfile、composeEntries、boot() 或等价的正式装配路径。它能发现裸包解析、id 覆盖、entry 激活、依赖缺失和顺序错误,是普通插件测试与“能作为产品扩展安装”之间的关键证据。
第四层是构建产物测试。先构建包,再让普通 Node 进程加载 lib/、exports 和 bin 指向的入口;不要只在 tsx 或 workspace alias 下测试源码。这样才能发现 ESM、相对路径、生成文件、package.json exports 和构建后依赖声明不一致的问题。
第五层是快照和 Web/E2E 测试。模型可见的提示、工具 schema、工具结果、UI card 和事件 transcript 应通过固定输入产生可审阅的快照;E2E 还必须执行用户动作并检查业务状态或真实事件,打开一个页面或看到“成功”文字不算完成。
第六层是真实 API、平台和安装环境测试。这一层要单独标记密钥、模型、网络、操作系统、Node/pnpm 版本和可复现条件;没有密钥时的自动跳过只能说明“未执行”,不能报告为真实服务通过。
每个插件都要测生命周期
最小生命周期测试是:创建宿主、挂载插件、等待它声明的服务或工具可用、执行一次行为、调用 dispose、等待异步清理完成,然后确认资源确实停稳。dispose() 返回并不自动证明后台任务已经结束,尤其是 detached hook、worker、子进程和网络连接。
至少检查下面这些资源:
ctx.effect()、ctx.on()、service provider、tool registration 和 command registration 是否不再出现在新的上下文中;- timer、interval、文件 watcher、socket、HTTP 请求、worker、子进程和子进程树是否停止;
- 临时文件、锁文件、Unix socket、Windows junction 或其他安装痕迹是否按约定清理;
- detached hook 是否先 abort,再等待
drain()完成,而不是只丢弃 Promise; - dispose 后再次触发事件、调用工具或刷新配置时,是否不会产生新的副作用;
- 重复安装、重复卸载和启动失败时,清理是否幂等,错误是否保留条目和阶段信息。
测试实现可以记录资源计数、事件序列、子进程 PID、watcher 状态和临时目录快照。不要用“没有抛异常”替代资源断言,也不要把固定等待几百毫秒当成异步任务已经结束的证明;优先等待明确的结束 Promise 或可观测状态。
运行时还有一层自动化的关系断言:每个包从自己的 ./invariant 伴生插件向 InvariantRegistry 注册检查。注册台的第一步是保留包名——过滤器把检查关掉也不释放名字;fail() 抛出的 InvariantError 带完整包名归属;失败的检查会销毁子 fiber 并释放保留位。运行时不变量实验按包名 × 过滤器 × 结局推演这条时间线,可以对照「保留位」「安装检查」「错误归属」三列读数逐格核对。
必须覆盖的失败路径
| 场景 | 需要证明的行为 |
|---|---|
| 依赖包缺失 | Loader 在可定位阶段失败,指出缺少的包或条目,不静默跳过 |
| patch id 不存在或字段错误 | 配置组合失败,并保留可定位的路径和 id |
| 插件入口 import 失败 | 已经启动的 Fiber 和宿主资源得到有界清理 |
| 启动超时 | 超时被记录,相关任务可取消,进程不留下悬挂资源 |
| 用户取消 | 协作式取消传到工具、hook、网络和 worker,并等待停稳 |
| 网络中断或 API 错误 | 错误分类清楚,重试策略不重复提交不可重试副作用 |
| 工具被拒绝 | guard() 或权限决策保持拒绝,不能由后续处理器偷偷放行。这个单调性在循环卫生实验里有可触发的版本:后置结算撤不回 guard 的拒绝,执行账目保持平衡 |
| HMR 新 patch 无法组合 | 最后一个可用树继续运行,watcher 和错误通知仍然正常 |
| 重复安装或版本冲突 | 版本范围、包名、配置 id 和安装位置有明确失败行为 |
失败测试的价值是证明系统不会在坏输入下进入“半启动、半卸载、表面成功”的状态。每个失败场景都应写明观察点,例如日志事件、退出码、资源清单或最终会话记录。
工具插件要测什么
官方工具链应分别验证 tools/pre-execute、guard、tools/execute、tools/post-execute、finalizeContent 和 tools/result 的职责。测试要区分“工具执行结果”与“最后交给模型的内容”,也要区分实时观察事件与随后持久化的 Session 事件。
native、code 和 both 影响工具怎样呈现给模型或用户;presentAs() 和 restrict() 是呈现与可见性组合,不等于操作系统权限。测试应同时检查 schema、渲染数据、可见范围、取消和拒绝路径,不能只调用工具函数并断言返回字符串。
并发测试要覆盖 isConcurrencySafe(args) 返回 true 与非 true 的情况。只有严格允许并发的输入才能并发执行;串行工具应证明第二个调用不会越过第一个调用的资源或状态约束。finalizeContent 的测试还应说明它只改变面向模型的最终内容,不能被误当作秘密数据隔离机制。
Hook bridge 要测什么
Hook bridge 不是把任意 shell 脚本当作普通插件。测试应先验证外部协议的 stdin、环境变量、超时、退出码、stdout、stderr 和 AbortSignal,再验证中性 HookOutput 怎样映射为 DSH 的类型化 Decision。
权限合并要覆盖 deny > ask > allow 的优先级、无输出、无效 JSON、非零退出码、超时和取消。updatedInput 即使能够被解析,也不能在没有实现应用逻辑的版本中被测试写成“已修改用户输入”。hook/invoked 与 hook/result 若是 log-only 事件,也应断言它们不会偷偷改变决策。
detached hook 的测试必须在宿主退出和插件 dispose 时调用 abort 并等待 drain,检查所有脱离运行已结束。Windows、macOS 和 Linux 对 shell、环境变量、信号和进程树的语义不同,跨平台证据应分别记录,不要用一个操作系统的绿色结果替代平台矩阵。
README 的最小证据清单
一个准备让别人安装的社区插件或 Bundle,README 至少应包含:
- 项目身份、维护者、许可证、源码仓库和是否属于官方组织;
- 支持的 DSH commit 或版本范围,以及 Node、pnpm、操作系统和架构要求;
- 包名、入口、Bundle manifest、Profile 示例和是否需要 patch/fork;
- 插件公开的服务、事件、工具、Hook bridge 或 CLI 命令,以及它们不保证的内部接口;
- 文件、网络、凭据、子进程、注册表和安装脚本权限;
- 从源码安装和从构建产物安装的命令,构建脚本是否会执行任意代码;
- 单元、Context、Loader、构建、快照、E2E 和真实 API 测试命令及结果范围;
- 已知未覆盖项、失败恢复、卸载命令、残留位置和如何确认资源已停稳。
如果项目使用 dsh.bundle.patch、官方包名风格或官方 UI 位置,只能说明它采用了某种兼容装配格式,不能证明官方背书。README 应把“官方事实”“项目自述”“本地静态检查”和“已运行测试”分成不同小节。
学习仓库怎样记录证据
本学习仓库的固定上游提交是 aa6c361a972c8369148dea7380bb5c21c24e07ec。索引中的源码链接和本文的官方链接都固定到这个提交;自动索引的 import、声明、测试主题是静态定位证据,不等于已运行 DSH。
学习仓库自身可以运行索引覆盖、固定链接、Markdown 和生成器语法检查;这些门禁证明文档自洽。它们不证明官方 DSH build、真实模型调用、第三方安装、跨平台行为或卸载完整,因此报告结果时要把“文档门禁通过”和“运行时未验证”并列写出。
推荐的最小实验
先写一个只注册一个观察事件的插件,使用最小 Context 测试它能挂载和 dispose。然后把同一个入口放进 Bundle patch,用正式 Profile/Loader 组合测试;接着构建产物并用普通 Node smoke 加载。最后加一个失败启动和一个取消/卸载测试,记录资源清单和事件顺序。
当这个最小实验稳定后,再增加工具 schema、UI render、Hook bridge、网络访问或子进程。每增加一种外部资源,就增加对应的启动失败、取消、dispose、重复安装和跨平台说明;不要先堆功能,再试图从一个“插件能跑”的截图推断所有契约。