Cline CLI TUI 无头测试实践:用 tuistory 驱动终端会话并编写 e2e 测试
在 Cline CLI(apps/cli)仓库中,交互式的 TUI 界面长期难以在无头(headless)环境下自动测试:传统的做法是用 Unix script 工具加定时的 printf 按键、再靠 sleep 和原始输出流的 grep 来断言,脆弱且无法感知界面遮挡关系。本文基于仓库内的技能文档 .cline/skills/tuistory/SKILL.md,完整讲解如何使用 tuistory 以命名后台 PTY 会话驱动 Cline TUI——包括隔离环境启动、observe → act → observe 交互循环、后台进程管理,以及用其库 API 编写可运行的 e2e 测试(bun run test:e2e:tuistory),帮助你在云端 Agent 或 CI 环境中稳定地复现 TUI 缺陷、采集界面证据。
tuistory 是什么:Ghostty 终端模拟器之上的命名 PTY 会话
tuistory 可以把任意终端命令包装进一个命名的后台 PTY 会话,其底层由 Ghostty 终端模拟器提供终端仿真能力。它的设计定位可以类比为“终端版的 Playwright/tmux”:
- Agent 侧:通过一系列短小的 CLI 调用(wait / type / press / snapshot / screenshot / read)与交互,每次调用立即返回,不会阻塞工具调用;
- 人类侧:可以随时
tuistory attach -s <name>附加到同一个会话进行观察或干预; - 无头友好:不需要真实终端,也不需要图形显示(无
DISPLAY也能工作),这使它成为云端 Agent 操作 Cline TUI 的首选方式。
在 Cline 仓库中,tuistory 以 devDependency 的形式锁定在 @cline/cli 包中,版本为 ^0.10.1,见 apps/cli/package.json。因此从 apps/cli 目录内执行命令时,pinned 的二进制可以直接解析:
cd apps/cli
bunx tuistory --help # 命令、选项与语法的权威来源
仓库中还有一个传统对照组:src/cli.interactive.e2e.test.ts 用 script 工具把按键按固定 sleep 时间表管道进去,然后对原始输出做 ANSI 剥离和 grep(其 normalizeTerminalOutput 需要手工剥 CSI/OSC 转义、CR、退格等噪声字节);而 src/cli.tuistory.e2e.test.ts 的文件头注释明确说明了迁移动机——每个测试把 CLI 放进 Ghostty 仿真 PTY,反应式地等待屏幕内容出现(waitForText 在文本一渲染就立即 resolve),并且断言的是仿真后的屏幕状态而非原始字节流。这正是后文“屏幕状态断言”能力的来源。
隔离环境启动 Cline TUI
驱动 TUI 的第一步是把它启动在一个隔离环境里,避免污染真实用户配置(~/.cline)。参考技能文档给出的标准命令:
cd apps/cli
DATA_DIR=$(mktemp -d) && HOME_DIR=$(mktemp -d)
bunx tuistory -s cline --cols 120 --rows 36 \
--env HOME=$HOME_DIR --env CLINE_DATA_DIR=$DATA_DIR \
--env CLINE_DISABLE_CLINE_PASS_NOTICE=1 --env CLINE_TELEMETRY_DISABLED=1 \
-- bun src/index.ts --provider anthropic -m claude-sonnet-4-6 -k test-key
各环境变量的作用,结合参考测试 apps/cli/src/cli.tuistory.e2e.test.ts 中的 createCliEnv() 可以进一步补全:
| 环境变量 | 作用 |
|---|---|
HOME、CLINE_DATA_DIR |
指向临时目录,隔离用户主目录与数据目录;测试中还派生出 CLINE_DB_DATA_DIR、CLINE_SESSION_DATA_DIR、CLINE_TEAM_DATA_DIR、CLINE_PROVIDER_SETTINGS_PATH、CLINE_HOOKS_LOG_PATH 等,全部落在同一个临时树内 |
CLINE_DISABLE_CLINE_PASS_NOTICE=1 |
关闭 ClinePass 推广弹窗。源码注释特别指出:基于流 grep 的旧测试套件感知不到这个覆盖在聊天视图上的弹窗,而 tuistory 的屏幕快照反映的是“用户实际看到的内容”,所以这个开关在无头验证中必不可少 |
CLINE_TELEMETRY_DISABLED=1、CLINE_NO_AUTO_UPDATE=1 |
关闭遥测与自动更新,避免后台噪声 |
清除 CI / VITEST |
父进程 vitest 会设置 CI/VITEST,若不显式清除,子进程 CLI 会被识别为非交互终端,无法按真实交互模式渲染 |
关于凭据:命令中的假密钥 -k test-key 足以让完整的聊天 UI 渲染出来,只有真正发起 Agent 回合时才会失败。如果需要回放录制的 LLM 回合,仓库提供 VCR 机制——在 apps/cli/src/index.ts 入口处即调用 initVcr(process.env.CLINE_VCR),配合环境变量 CLINE_VCR=playback + CLINE_VCR_CASSETTE=<cassette路径> 即可(该机制的完整约定见 apps/cli/src/tests/helpers/env.ts,其中 clineEnv() 会在检测到 cassette 时默认注入 CLINE_VCR: "playback" 与 CLINE_VCR_FILTER: "")。真实回合则必须提供提供方凭据(如 ANTHROPIC_API_KEY、CLINE_API_KEY)。
observe → act → observe 交互循环
会话启动后,标准操作模式是“观察 → 行动 → 再观察”,技能文档给出的完整循环如下:
# 反应式等待聊天视图出现 —— 永远不要用 sleep
bunx tuistory -s cline wait "What can I do for you?" --timeout 30000
# 行动后,始终观察结果屏幕状态
bunx tuistory -s cline type "/settings"
bunx tuistory -s cline snapshot --trim
bunx tuistory -s cline press enter
bunx tuistory -s cline snapshot --trim
# 当前屏幕的带样式 PNG 截图(输出文件路径)—— 适合作为证据产物
bunx tuistory -s cline screenshot
# 完整原始输出流(snapshot 只显示可见屏幕)
bunx tuistory read -s cline --all
# 拆除自己启动的会话(双 Ctrl+C 可干净退出 TUI)
bunx tuistory -s cline press ctrl c
bunx tuistory -s cline press ctrl c
bunx tuistory -s cline close
这里每个动作都有对应的源码级证据。参考测试 apps/cli/src/cli.tuistory.e2e.test.ts 的 afterEach 里正是用“双 Ctrl+C + waitIdle + close()”三步完成会话拆除,注释解释了原因:第一次 Ctrl+C 只显示“再按一次退出”的提示,第二次才真正退出,随后 PTY 才被销毁(L105-L121)。snapshot 与 read 的分工也体现在文档规则中:snapshot 反映用户实际所见(被遮挡的文本不计入),read 返回自上次读取以来的原始流。
把 tuistory 当后台进程管理器(替代 tmux)
不局限于 TUI,任何长驻/交互式进程都可以放进命名会话而不会挂起你的工具调用:
bunx tuistory -s my-server -- bun run dev:sidecar # 立即返回
bunx tuistory -s my-server wait "/listening|ready/i" --timeout 30000
bunx tuistory read -s my-server # 读取自上次以来的新输出
bunx tuistory -s my-server restart # 改完代码后重启
要点是:启动命令立即返回;wait 支持正则(/pattern/i 为忽略大小写,默认区分大小写)并以 --timeout 兜底;restart 在迭代开发时免去手动关闭再启动。
关键规则(Key Rules)
技能文档总结的操作纪律,逐条都有实现层面的依据:
- 选项在
--之前,命令在之后。 第一个--之后的所有内容原样传给子进程:tuistory -s name --cols 150 -- bun src/index.ts是正确写法。 - 每次行动后都 snapshot。 TUI 是有状态的,对话框和错误信息会覆盖在你预期的视图之上;
snapshot反映的是用户真实所见(被遮挡文本不算),这与 grep 原始流有本质区别。 - wait 而不是 sleep。
wait "text"/wait "/regex/i"(默认区分大小写)的反应速度与终端刷新同步;不知道预期什么时用wait-idle。始终传--timeout。 - 按键会立刻送达。 与 sleep 型脚本不同,排队的第二次按键可能漏进下一个视图——例如一个 Enter 会同时“接受斜杠补全 + 提交”。参考测试中的
/settings用例特别注释了这一点:旧script套件按两次 Enter 并各自 sleep 250ms,而反应式按键投递下第二次 Enter 会漏进 settings 视图并激活聚焦行(L171-L181)。 - 不要关闭不是你启动的会话。 会话是与人(
tuistory attach -s name)和其他 Agent 共享的;默认让会话继续运行,用read/wait/snapshot做无损检查。 --cols/--rows影响 TUI 布局(断言对宽度敏感,参考测试统一用 120×36);--pixel-ratio 2可得到更清晰的截图。
用库 API 编写 e2e 测试
程序化 API 在进程内运行、无需守护进程,参考实现是 apps/cli/src/cli.tuistory.e2e.test.ts(运行命令 bun run test:e2e:tuistory,脚本定义于 apps/cli/package.json 的 test:e2e:tuistory,指向 vitest.tuistory.e2e.config.ts:匹配 src/**/*.tuistory.e2e.test.ts,测试与 hook 超时均为 60s)。
启动会话的核心代码:
import { launchTerminal } from "tuistory";
const session = await launchTerminal({
command: "bun",
args: ["src/index.ts", "--provider", "anthropic", "-k", "test-key"],
cwd: cliRoot,
env: isolatedEnv, // 见参考测试中的 createCliEnv()
cols: 120,
rows: 36,
waitForDataTimeout: 30_000, // CLI 冷启动需要编译大型 TS 图
});
await session.waitForText("What can I do for you?", { timeout: 30_000 });
const screen = await session.text({ trimEnd: true }); // 仿真后的屏幕状态
await session.type("/settings");
await session.press("enter");
session.close(); // 始终在测试 teardown 中 close
几个从参考测试中可以读到的关键细节:
waitForDataTimeout必须放大。 CLI 冷启动时要编译大型 TypeScript 图,默认 5s 的首数据超时不够,参考测试把启动与 UI 超时分别定为LAUNCH_TIMEOUT_MS = 30_000和UI_TIMEOUT_MS = 15_000(L25-L26)。- 屏幕状态断言能验证“陈旧 UI 已消失”。 例如 Tab 切换 Plan/Act 模式的用例:先
waitForText("● Plan ○ Act (Tab)"),再同时断言新指示器存在、旧指示器不存在(expect(screen).not.toContain("○ Plan ● Act (Tab)"),L133-L149)。这是基于流 grep 的框架做不到的——旧状态仍埋在滚动回卷缓冲里。 session.text()支持谓词等待与样式过滤。 参考测试用session.text({ waitFor: (text) => !text.includes("Compaction") })等待 settings 面板从 General 页签切到 MCP 页签(L189-L193);技能文档还指出session.text({ only: { bold: true } })可按样式过滤,session.read()返回自上次读取以来的原始流。- teardown 纪律。 参考测试用模块级
sessions数组登记所有会话,afterEach中统一“双 Ctrl+C →waitIdle(3s)→close()”,并容忍会话已死的情况,同时递归清理所有临时目录(L105-L121)。 - 断言可穿透到磁盘副作用。 ClinePass 推广弹窗用例在按键关闭弹窗后,进一步用
waitIdle等待落盘并断言cli-notices.json中写入了"cline-cli-cline-pass-intro": true标记(L235-L243),说明 tuistory 测试不仅能验证界面,也能验证界面背后的持久化行为。
小结与延伸阅读
tuistory 给 Cline CLI 带来的核心能力是:用反应式等待替代 sleep、用仿真屏幕状态替代原始流 grep、用命名 PTY 会话替代 tmux 手工管理,从而让无头环境(云端 Agent、CI)里的 TUI 测试变得确定且可断言。若需要进一步深入,可以按以下路径阅读仓库源码:
- apps/cli/src/cli.tuistory.e2e.test.ts:完整的程序化 API 参考实现(含环境隔离、Plan/Act 切换、
/settings导航、ClinePass 弹窗五个用例); - apps/cli/src/cli.interactive.e2e.test.ts:旧式
script+ 定时按键套件,可对照理解两者差异; - apps/cli/src/tests/helpers/env.ts:
clineEnv()环境构造器与 VCR cassette 约定; - apps/cli/DEVELOPMENT.md:
test:e2e:tuistory的运行说明与 tuistory 工作流速查。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00