dsh-testhud
Verifieddsh-testhud · v0.1.1 · MIT
On-screen progress HUD for automated tests in DeepSeek Harness: what is being tested, each step's expectation vs actual result, elapsed time, and whether the waiting human may take back the mouse and keyboard.
Install
dsh plugin add dsh-testhud Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Creators
Readme
dsh-testhud
把自动测试的进展画在被测界面之上的浮层:置顶、鼠标穿透、不抢焦点,配一个任何 session 都能直接调的 test_hud 工具。

为什么要有它
agent 驱动 GUI、比对截图、批量渲染,一跑就是几分钟。这段时间里等的人什么都不知道:跑到第几步、这一步期待什么、是不是已经失败了,以及最要紧的那个问题——现在能不能动鼠标键盘了。
这块浮层就回答这些,画在屏幕上、不抢焦点:
- 在测什么:标题、测试对象、开始时间与已用时。
- 每一步的期待与实际:一行一条,✓ / ✗ / ⏳ / •。
- 常驻第一行的控制权提示:跑的时候「正在进行:先别动鼠标键盘」,结束「✓ 已结束,可以收回鼠标键盘的控制权了」。它是整块浮层里字重最重、也是唯一带底色的一行 —— 不能碰的时候是色板红
#FF3B30底配白字,可以接手了是色板绿#34C759底配黑字(字色由状态定,不按对比度挑,见「当前配色」)。 - 不碍事:鼠标穿透、置顶、不进 Dock 也不进 ⌘Tab,并且自动挑屏幕上最空的角,尽量少盖住被测界面。
- 位置可以手动调:抓住顶部那条色带拖即可。色带是面板上仅有的两处接鼠标的地方之一(它是把手),其余部分照旧穿透;内容溢出时内容区也能接鼠标,见「尺寸与溢出」。鼠标移到这两处会变成"张手"、按下去变"抓紧",松开还原 —— 光标形状本身就是"这儿能拖"的提示。
- 色带左端有三个圆点:红点关掉浮层、黄点折叠成只剩色带一条(再点一下回来)、绿点放大到可用区域上限 —— 高度撑满、宽度也撑满(再点回按内容自适应;只撑高度常常没效果,因为内容一多,自然高度本身就已经等于上限了)。颜色固定为色板红
#FF3B30/ 黄#FFCC00/ 绿#34C759,外面套一圈 1.0 点纯白描边 —— 红点与红色带现在是同一个色板色,描边是它唯一的辨识手段。
安装
dsh plugin --profile <profile> add github:yijiezhong/dsh-testhud # 直接从 GitHub 装
dsh plugin --profile <profile> add link:/path/to/dsh-testhud # 从本地 checkout 装
dsh plugin --profile <profile> add dsh-testhud # 从 registry 装(已发布时)
Desktop 应用走同一条路(插件设置页),由插件管理器热挂载:新增的 bundle 立刻组合进来、宿主不重启。CLI 起的 profile 只在启动时组合 bundle 列表,如果你的 profile 不是 patchReload: live,装完重启一次。
bundle 必须真的列进 profile 的 dsh.profile.bundles —— dsh plugin add 会替你加;是否进了组合用 dsh --profile <profile> --dump-config 看有没有 # == dsh-testhud 层标记。之后该 profile 里的每个 session 都有 test_hud 工具,以及一小段"跑长测试要报进展"的系统提示约定。
@deepseek-ai/dsh-tools 声明为 peerDependency:dsh 把它解析到运行中安装的那一份(所以用的一定是宿主自己的 defineTool),并且当运行版本落在声明的范围之外时拒绝加载整个 bundle,而不是半挂上。node_modules 里那份只作 devDependency,供脱离宿主做独立测试用。
卸载:
dsh plugin --profile web remove dsh-testhud
agent 工具
action |
作用 | 参数 |
|---|---|---|
start |
开一次测试并拉起浮层 | title(必填)、target、anchor |
step |
上报一步 | name(必填)、expect、actual、state = ok/fail/run/info |
note |
底部补一行说明 | text |
done |
写结论、结束这次测试 | failed(布尔)、conclusion |
stop |
立刻关掉浮层 | — |
浮层宽高都随内容自适应(见「尺寸与溢出」),只有步骤区滚动、头部固定;done 之后再停 12 秒才消失,够看清结论。
约定
只要在做自动化验证就必须有浮层:不许"只跑命令不起浮层"。连续几轮验证时,上一轮 done 之后、开始下一个动作之前先重新 start —— 等待的人要能随时看到"现在在测什么、能不能接手"。
收尾也一样重要:结束时 done,然后关掉为测试启动的那个 app(优雅退出,别强杀),并把浏览器 / DSH 界面切回前台 —— 不要被测 app 留在屏幕上挡着(用户明确要求)。
命令行
同一套逻辑、同一个进度文件,给 shell 脚本和 CI 用:
testhud start "Movable 工具栏布局自检" "Movable 主窗口" [anchor]
testhud step "工具栏居中" "分组中心 == 窗口中心" "实测 962pt,居中" ok
testhud note "剩下交给用户判断"
testhud done ok "3 项检查全过"
testhud stop
testhud status
配置
在 profile 的 cordis.patch.yml 里按行 id 覆盖:
- id: testhud
config:
announceToAgent: true # 是否给每个 session 注入"长测试要报进展"的约定
defaultAnchor: auto # auto | center | left | right
topInset: 155 # 上边两个角从屏幕顶部往下让出多少点
停哪儿按这个顺序决定:
- 不挡被测对象。面板拿三个位置去和前台应用的窗口比重叠面积,取最小的那个 —— 这一条压过下面所有规则。
- 屏幕居中(在居中不挡任何东西的前提下)。
- 否则退到侧边,先左后右。
纵向一律靠上:顶边距屏幕顶 topInset 点(默认 155),底边停在状态栏 / Dock 之上(Look.bottomInset,44 点)。横向不贴边(Look.sideInset,28 点),所以不会压住侧边栏或滚动条。锚点是 auto | center | left | right;旧配置里的 top-left / top-right 仍然认,映射到 left / right。
topInset 是因为浮层通常压在浏览器上:屏幕最上面那 ~150 点是别人的标签栏、地址栏、收藏栏,浮层贴在最上面会把这些盖住。默认 155 让浮层从浏览器 chrome 之下开始,与页面自己的头部齐平;填 0 则退化成"让开菜单栏后再留 14 点屏幕边距"。这个值从屏幕顶算起(定位用全屏 frame,不是 visibleFrame),传多少就落在多少。命令行可用环境变量 DSH_TESTHUD_TOP_INSET 覆盖。
尺寸与溢出
宽高都随内容自适应(HUD.layout()):
- 宽度按"不折行时最宽的一行" + 左右内边距(
Look.inset,16 点)算;下限 460 点(Look.minWidth),上限 = 屏幕可用宽度 − 左右各 28 点(Look.sideInset)。 - 高度按内容算,装得下就正好是内容高度;上限 = 从面板顶边(距屏幕顶
topInset,默认 155)到可见区底边、再让开 44 点(Look.bottomInset)的那一段,不再限制在屏高的 62%。黄点折叠到只剩色带,绿点直接撑到高度上限。
面板高度有上限,内容多了上面的部分会被推到可视区外。此时面板内容区会出现一个透明拖动命中层(ScrollHandle 窗口 + ScrollGrip 视图):按住往下拉能把上面的内容拉回来(自然滚动方向),松手后 0.28 秒 ease-out 平滑回到底部 —— 即"最后一行自动回到窗口最下方"。该命中层只在内容真被截掉时才启用(其余时候高度为 0),所以内容装得下时面板照旧完全鼠标穿透。鼠标移上去是"张手"、按下变"抓紧",松开还原。观测点:~/.dsh/dsh-testhud/scroll.json(含 scrollY / maxY / atBottom / panning / bouncing / cursor —— 最后一项是因为截图里没有鼠标指针,光标只能这样验)。
当前配色:只用 Apple 色卡的 22 色
面板里出现的每一个颜色都必须能在 Apple 色卡里找到 —— 色卡(Apple色板/1Apple配色色卡.key,本机位于 $HOME/PARA/8.Code/AIDoc/设计规范/)第 1 页那 22 色(九中性 + 三蓝 + 十功能色)。代码里唯一的取色来源是 hud/testhud.swift 的 ApplePalette,全部用 sRGB 构造(calibrated* 会被色彩空间转换改掉数值)。
变色龙 V2(2026-10-09):面板底纯透明 —— 不铺任何底色,也不显示背景快照,面板上只剩下文字与一圈细边框。这样被覆盖的内容原样可见,而浮层文字靠三件事在任何背景上都能被认出来(描边 + 阴影 + 颜色):
| 部位 | 浅色系(面板背后偏亮) | 深色系(面板背后偏暗) |
|---|---|---|
| 主体背景 | 半透明(alphaFloor 0.50) |
同左 |
边框(1.0 点,Look.panelBorderWidth) |
纯黑 #000000 |
纯白 #FFFFFF |
| 正文(标题 / 步骤名 / 结论 / 期待 / 实际,同一颜色) | 自适应选色,见下 | 同左 |
| 文字阴影 | 默认关闭(textShadowBlur = 0) |
同左 |
| 文字描边 | 默认关闭(strokeWidthPercent = 0) |
同左 |
| 步骤行首符号 | ✓ #34C759 / ✗ #FF3B30 / ⏳ #FF9500 / • 同正文色 |
同左 |
正文色是自适应选出来的(Theme.pickTextColor,2026-10-09)—— 按背景性质分四档,档内只在两个候选之间比一次对比度:
| 背景 | 选谁 |
|---|---|
| 本身有颜色(站点蓝、暖橙、深绿…) | 黑 / 白里对比更高的一极(有彩候选容易与背景同色系糊掉) |
| 中性但亮度居中(纯中灰) | 黑 / 白(蓝字在中灰上只有 2.5:1) |
| 中性偏亮 | 苹果蓝 #0071E3 与靛 #5856D6 中对比更高的 |
| 中性偏暗 | 亮蓝 #2997FF 与薄荷 #00C7BE 中对比更高的 |
实测八个背景(白底黑字 / 黑底白字 / 纯白 / 纯黑 / 中灰 / 站点蓝 / 暖橙 / 深绿)分别选出 靛 / 薄荷 / 靛 / 薄荷 / 黑 / 黑 / 黑 / 白,逐个用视觉确认可读。层次不再靠灰度,全部交给字重(同一字号、四档字重)。
阴影取"背景那一极"(浅色系白、深色系黑):它与背景融为一体,作用是把压在文字底下的背景笔画"推开"。描边默认关闭(strokeWidthPercent = 0)—— 三种方案实拍对比过:描边会让 18pt 的中文字形发胖、边缘发脏,而纯阴影既干净、可辨识度也不差;能力留着(设成负值即启用)。
色带与浅深档无关,按状态取色板红 / 绿:进行中 = 红 #FF3B30 + 白字、已结束 = 绿 #34C759 + 黑字;色带不透明度 0.75。三个圆点也取色板值(红 #FF3B30 / 黄 #FFCC00 / 绿 #34C759),配 1.0 点纯白描边。色带是面板上唯一仍不透明的一块。
行首符号用 ✓ / ✗ / ⏳ / • 而不是 ✅ / ❌:彩色 emoji 无视 foregroundColor,会画出自己那套色板外的颜色,只能换字符。同理,调用方(agent)写进步骤名 / 期待 / 实际的文字若自带 emoji,那些字形不受 ApplePalette 管。
仍然"算出来"的只有一件事:走浅色系还是深色系。判据是采样区域的中位亮度(不是平均值 —— 密集文字会把平均值抬起来,实测深底白字的区域因此被误判成浅色系、配出黑字)。阈值 0.60 是实测校准值:ScreenCaptureKit 采出来的图过了色调映射(实测纯白 1.000 被采成 0.886、深灰 #1D1D1F 的 0.106 被采成 0.373),拿教科书里的 0.5 直接比会判反。采样每 0.7 秒量一次它即将盖住的那块矩形(前台应用切换、面板尺寸或内容变化时立刻重采)。
源码里仍留着更早两版取色(banner() / tinted() / textColor(),以及"半透明底 + 背景快照"那条路线),已无调用方;恢复前先问用户。
视觉设计
版面是按 CRAP 定的规矩,改它的时候请守住:
- Contrast(对比):颜色承担两件事 —— 状态(色带与行首符号),以及正文色(色板蓝,用来与背景的黑/白字分开,见上表)。层次靠四档字重,绝不靠第二种字号 —— 每一行都是
Look.base(18 pt)。 - Repetition(重复):所有内容贴同一条左边界(
Look.inset,16 点),间距只有三个值:组间 14、步骤间 10、步骤内 2–4。 - Alignment(对齐):色带通栏,文字用
headIndent回到内容左边界;步骤的期待/实际用真正的headIndent缩进而不是空格(比例字体下空格永远对不齐)。 - Proximity(亲密性):头部(标题 / 测试对象 / 计时)、步骤、结论是三组,组内紧、组间松。
全部文字同号 —— hud/testhud.swift 里的 Look.base(18 pt),层次只靠字重:控制权提醒 heavy、标题与状态行 bold、步骤名 semibold、期待/实际 regular。浏览器里 DSH 的正文是 --dsh-content-font-size(默认 14 px,可设 12–17);浮层比它大一档,因为半透明底上的浅色字比页面上的黑字显小。改 base 一个数,整块浮层跟着变。其余版面数值:内边距 16 点(Look.inset)、面板圆角 14 点(Look.cornerRadius)、边框 1.0 点。
依赖
- 一个提供
@deepseek-ai/dsh-tools的 dsh 宿主——声明为 peer(写这份时是~0.2.0-rc.2),因此defineTool用的是宿主自己那份;运行版本落在范围外时宿主会拒绝加载本 bundle,而不是半挂上。本包没有别的运行时依赖。 - 浮层本体要 macOS(AppKit)。进度文件与工具本身在哪都能用。
- 要 Xcode 命令行工具(
swiftc):浮层在第一次使用时现编到~/.dsh/dsh-testhud/bin/testhud(本机实测约 3~4 秒),之后复用——所以包里带的是源码,不是二进制。没有swiftc时工具照样记录每一步、照样给结论,只是如实告诉你"浮层没画出来"。
开发自测
先观测,不要猜
bin/testhud-inspect.py 是量这块浮层像素的观测工具。它读浮层每次布局时自己导出的几何(~/.dsh/dsh-testhud/panel-frame.json),精确裁出那个矩形,报告面板内部的纹理,以及矩形内每一条不属于面板自己内容的 OCR 行。
用它,不要凭截图反推面板坐标:面板的位置和尺寸都是动态的,从 OCR 结果里倒推会把面板外面的内容当成穿透文字。对比度按颜色簇投票算,不取极端像素;低于 1.5:1 的行一律报成"行框跨界的伪影"。
连着几轮"什么都没变"的时候,先怀疑量具,别继续改被测对象。 结果逐像素完全一致、或者几个互不相同的实现给出同一个"失败",都是量具坏了的信号,不是被测对象的。
背景里必须有文字
采样用的背景要真的有文字 —— 一份源码、一屏终端输出、一屏聊天记录都行。纯色区域(空白页、空终端)测不出这个浮层要避免的那种失败:它自己的文字和底下的文字字号相近、颜色相近,两层字叠在一处。干净的背景证明不了任何事。
# 用一个一次性 profile 验,别动你日常那个
dsh --profile hudtest --from-default-profile headless --dump-config > /dev/null
dsh plugin --profile hudtest add link:"$PWD"
dsh --profile hudtest --dump-config | grep -A2 testhud # 行进了组合
dsh --profile hudtest "调用 test_hud:start / step / done" # 真实 session 必须看得见并调得动这个工具
rm -rf ~/.dsh/profiles/hudtest # 收尾
node_modules/@deepseek-ai/dsh-tools 只在脱离 dsh 单独 node lib/index.js 冒烟测试时需要;在 dsh 进程里由宿主解析。它不在发布内容里。
实现
lib/index.js Cordis 宿主插件:注册 test_hud 工具 + 一段系统提示分区
lib/hud.js 进度文件(~/.dsh/test-progress.json)与浮层进程(编译 / 启动 / 停止)
hud/testhud.swift 浮层本体:无边框 NSPanel、.screenSaver 层级、鼠标穿透,每 0.4 秒读一次进度文件
(另有两个只覆盖某一块的透明辅助窗口 —— 色带把手、内容区拖动命中层。主面板整体穿透,
而 `ignoresMouseEvents` 是窗口级的,一个窗口做不到"这块穿透那块不穿透")
bin/testhud.js 同一套核心的命令行
进度文件就是朴素 JSON,谁都能写:
{ "title": "…", "target": "…", "status": "running|done|failed", "startedAt": 1789518613.07,
"steps": [{ "name": "…", "expect": "…", "actual": "…", "state": "ok|fail|run|info" }], "note": "…" }
限制
- 一台机器一块浮层:进度文件是固定路径,两场测试同时跑会共用一块浮层,后写的覆盖先写的。
- 浮层按设计鼠标穿透,只有两处例外:顶部色带(拖拽把手,不用它就没法用手调位置),以及内容溢出时的内容区拖动命中层。鼠标落在它们上面时抓到的是浮层,不是底下的应用。要它消失用
stop/done。 - 色带左端 78 点留给那三个圆点,色带文字从那里往右排 —— 长短不一的提示语不会压到圆点上。
- 手动拖动会把面板留在你放的位置(
layout()只在高度变化时保持顶边,不会把它拉回候选位置),直到面板进程重启为止。拖动被夹在鼠标所在那块屏的可见范围内 —— 否则会被拖到两块显示器之间的空白地带,那里什么都看不见,也就再也点不回来了。 - 屏幕被全屏窗口占满时,四个角都压着东西,
auto会退到左上角;想让开正在测的区域就显式传anchor。 - 最坏情况下浮层文字会与背景文字抢读:背景是密集文字时(例如整屏终端输出),两层字会叠在一处。现在的对策是"半透明底 + 自适应正文色":底把背景压淡一层,正文色按背景性质挑(见「当前配色」)。想换成"纯透明底、只靠文字本身闯":把
Look.transparentPanel设成true(那种模式下才值得打开文字阴影textShadowBlur)。 - 面板文案是双语的(控制权色带、
测试对象:/Target:、期待:/Expect:、实际:/Actual:、结论:/Conclusion:):系统首选语言以en开头就显示英文,否则中文;DSH_TESTHUD_LANG=en可强制覆盖(截图与测试用)。标题与步骤文字原样显示调用方写的内容。
许可
MIT