test(tui): snapshot semantic terminal state

This commit is contained in:
Tianyi Cui
2026-07-18 22:31:04 +08:00
parent 864b7bfd65
commit 8e366c3071
34 changed files with 2437 additions and 107 deletions
@@ -0,0 +1,57 @@
# RFC: TUI 语义终端状态快照
Status: implemented
[English](2026-07-18-tui-terminal-state-snapshots.md) | 中文
## 问题
TUI 是有状态的渲染器。用户最终看到的结果取决于 ANSI 解析、差分帧、换行、回滚缓冲、视口位置、终端宽度、焦点、光标状态,以及各工具的呈现意图。收集 `Terminal.write()` 片段的单元测试可以验证事件处理,却无法验证终端最终显示的画面。同一画面也可能由不同的写入片段产生,因此固定这些片段会制造误报。
组件行快照止于 ANSI 进入终端之前,无法覆盖光标移动、清屏、样式、浮层组合和重排。栅格截图会带入与 TUI 契约无关的字体和平台渲染噪声。因此,TUI 既需要一种确定、便于评审的终端状态表示,也需要一项范围更小、覆盖真实进程与 PTY 边界的测试。
## 决策
TUI 覆盖分为三个互补层次:
1. `tui.spec.ts` 直接测试事件映射、输入路由、资源释放和错误行为。
2. `tui.snapshot.ts` 将生产 TUI 挂载到无界面终端模拟器,并比较语义终端状态金标。
3. `tui-keyless-smoke.e2e.ts` 在 PTY 中启动真实 Loader 组合,驱动一段完整的脚本化会话,使其依次经过流式输出和 `ask_user_question`,再通过 `/exit` 退出并验证终端清理。生产 coding-agent 配置还保留欢迎信息与退出,以及启动失败两类 PTY 场景。
包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。快照会等待 pi-tui 的同步输出结束标记,再读取状态。因此,每个检查点表示已经完成的帧,而不是依赖计时的写入前缀。
每份金标把终端状态投影为文本:尺寸、活动缓冲区和视口坐标、生命周期与光标状态、各行、换行标记,以及非默认样式区间。滚动内容较多的卡片捕获已使用缓冲区;浮层捕获可见视口。文本和样式相互分离,评审人无需解码 ANSI 字节即可区分内容变化与呈现变化。
每个检查点还会对完整终端状态强制执行主题无关性:禁止 RGB 颜色、禁止 ANSI 0–15 以外的调色板项,也禁止显式背景色。选择行使用终端默认色进行反显,因此仍然有效。测试套件拥有封闭的检查点清单:类型会拒绝未声明的名称,清单检查会拒绝缺失的检查点和遗留的 `.golden.txt` 文件。
### 必需场景矩阵
| 范围 | 代表性检查点 | 固定的契约 |
|---|---|---|
| 会话 | 回放、流式输出、完成 | 恢复后的 Markdown 与推理、实时增量、计划、token 用量,以及达到 token 上限时的完成状态 |
| Code Mode | `run_code` 待完成与已完成 | 生产 Code Mode 注册表与呈现器、源程序、捕获日志和结果 |
| 动态工作流 | `workflow` 待完成与已完成 | 生产工作流呈现器、元数据、阶段、并行 agent、脚本和结构化结果 |
| Cordis 工具 | inspect/mount/unmount 待完成与已完成 | 生产 `cordis_inspect`、`cordis_mount` 和 `cordis_unmount` 呈现器及其生命周期结果 |
| 高级工具卡片 | 折叠与展开 | 终端、diff、通用、subagent、后台任务和 skill 卡片形态,以及输出截断 |
| 交互 | 问题与校验 | 受限多选浮层的组合、焦点、滚动、选择和校验错误 |
| 表层与布局 | 压缩前、窄幅替换、宽幅替换 | 表层替换会移除退役内容;调整尺寸只会重排保留的表层,不会让旧内容重新出现 |
| 失败与关闭 | 错误与帮助、终端已释放 | 帮助与未知命令、实时错误和轮次错误去重、中断、光标恢复及终端停止 |
面向模型的高级场景明确使用真实 `ToolRegistry` 配置,以及生产 Code Mode、工作流和 Cordis 工具呈现器。只有通用卡片形态矩阵使用合成呈现器 fixture;在这里,呈现器视图本身就是 TUI 的输入契约。测试仍由会话事件驱动,因此回放、流式输出、结果到达、表层替换和生命周期顺序都会经过与生产环境相同的投影路径。
`vitest.snapshot.config.ts` 会包含 TUI 测试套件,因此 `pnpm run test:snapshot` 可以无密钥比较快照。`pnpm run test:snapshot:refresh` 会重写从终端状态派生的金标,而不会联系模型;对于 transcript(文本记录)来源需要录制的测试套件,`test:snapshot:record` 仍有其原有含义。两条刷新路径都会在同一次运行中继续比较生成后的文件。
## 曾考虑的替代方案
- **快照原始终端写入**:不予采纳,因为差分渲染可能在画面不变时改变写入边界,而且光标与清屏序列难以评审。
- **快照进入终端输出之前的组件渲染行**:不予采纳,因为它无法测试 ANSI 解析、光标移动、浮层、视口行为,也无法测试独立组件在同一帧中的相互作用。
- **提交栅格截图**:不予采纳,因为字体、字形度量、抗锯齿和宿主终端主题会使结果依赖平台,也会增加语义样式变更的评审难度。
- **只使用 PTY 端到端测试**:不予采纳,因为原始 PTY 输出是一系列历史绘制操作,而不是可查询的最终状态。PTY 测试保留真实 Loader、输入与清理边界,模拟器负责广泛的状态覆盖。
- **复制 pi-tui 未发布的虚拟终端测试 helper**:不予采纳,因为已安装的包并未导出该 helper。围绕公开 `@xterm/headless` API 编写小型适配器,可以显式声明依赖,并让本包拥有状态投影。
## 后果
- TUI 视觉回归会产生便于阅读的单元格和样式 diff;必需场景矩阵也让高级功能成为一等测试对象,而不是偶然覆盖。
- 测试依赖固定到 pi-tui 使用的 xterm 版本。适配器使用 xterm 的拟议缓冲区 API,因此升级 xterm 时必须重新运行并评审语义投影。
- 模拟器可以建模 ANSI 终端状态,但无法证明每种终端实现独有的行为。真实 PTY 会话覆盖进程选择、键盘输入、用户交互和清理,无需复制完整矩阵。
- 金标有意固定指定尺寸下的换行与视口行为。布局的预期变更通过无密钥刷新命令更新,并接受常规快照评审。