Omarchy 测试体系深度解析:无图形化测试套件如何在不依赖桌面的环境下保持高质量
本文围绕 Omarchy 仓库的 docs/testing.md 展开,系统讲解该开源 Linux 发行版非图形化测试套件的组织方式:三个测试入口(./test/cli、./test/shell 与图形验收套件)各自的职责边界、测试文件之间约定的"协议"、以及在没有任何 Wayland 合成器的无头 CI 沙箱中依然保持绿色可运行的关键机制。读完本文,你将掌握这套 bash + TAP 测试框架的完整结构,学会如何新增一个 shell 测试、如何在纯 bash 中单元测试 Quickshell 插件的 JavaScript 模型逻辑、如何安全地门控依赖合成器的用例,并理解其"桩件替换外部世界、真实运行被测代码、断言不变式而非快照"等一套可复制的工程约定。
测试全景:三类套件各自的职责
Omarchy 是一个"既有传统 shell 脚本命令(bin/ 下 400 余个 omarchy-* 可执行文件)、又有 Quickshell 图形 Shell(shell/ 目录)"的操作系统级项目,因此文档首先划定了一张套件地图(suite map):
| 套件 | 入口 | 运行环境 | 职责 |
|---|---|---|---|
| CLI 套件 | ./test/cli |
开发机 / 无头 CI | 一个大脚本、一套测试:CLI 路由、元数据 lint、主题管线 |
| Shell 套件 | ./test/shell |
开发机 / 无头 CI | 逐个执行 test/shell.d/*-test.sh,覆盖 shell 插件、bin/ 命令、配置不变式、仍存活的迁移脚本 |
| 图形验收套件 | test/acceptance(见 agents/skills/acceptance-tests.md) |
一次性虚拟机 | 需要真实桌面"做真实事情"的端到端用例 |
关键设计意图在于把"需要真实桌面"的部分刻意排除在常规开发循环之外:验收套件会打开关闭应用、临时改动桌面配置,绝不能在开发者自己的活动会话里跑,它运行在一个可丢弃的 VM 里(通过同源的 omarchy-iso 仓库的 omarchy-iso-test 工具驱动),截图与日志收集在其带时间戳的 test-runs/ 目录下。而这篇文章聚焦的 ./test/all 只聚合前两套非图形化套件,保证开发与 CI 日常就能获得快速反馈。
./test/all:永不因单个失败而遮蔽其他套件
从 test/all 的源码可以看到聚合运行器的实现策略——它不是一个简单的"短路或":
tests=( "$ROOT/test/cli" "$ROOT/test/shell" )
failed=()
for test in "${tests[@]}"; do
printf '==> %s\n' "${test#$ROOT/}"
"$test" || failed+=("${test#$ROOT/}")
done
if (( ${#failed[@]} > 0 )); then
printf '\n%d of %d suites failed:\n' "${#failed[@]}" "${#tests[@]}" >&2
...
exit 1
fi
即使第一个套件失败,test/all 也会继续运行剩下的套件,最后统一在结尾汇总失败套件并以非零码退出。注释里的历史教训点明了为什么必须这样:一次打包失败曾经遮蔽了 134 个文件中的 114 个——如果一遇到失败就中止整个运行,排在失败点之后的所有诊断就全丢了。
./test/cli:CLI 路由、元数据 lint 与主题管线的单一大套件
./test/cli 是一个大脚本、一个套件,独占以下三个领域:
CLI 路由器(bin/omarchy)。它验证帮助文本与分组的渲染、路由解析、别名(alias)、隐藏命令,以及一个安全承诺:末尾的 --help 永远不会执行目标命令。这一点在源码里有大量对应的回归用例(见 test/cli 后半部分):omarchy update --help 不会真的触发更新;--help 出现在一个只部分解析成功的路由之后(如 omarchy parenthelp bogus --help)也只会渲染帮助而绝不执行脚本;-- 之后出现的 --help 属于被转发给命令的参数,不会被吞掉。同类的边界还包括 -h、--json --help、嵌入单个参数中的 --json 字样,以及 --helpme 这类"长得像帮助"的 token 不应误触发帮助。这些都是用临时 bin/ 里手写的小桩脚本(如 omarchy-parenthelp、omarchy-parenthelp-child)加 commands --all --json 的 jq 断言完成的。
元数据 lint。每个 bin/omarchy-* 可执行文件都会被检查:是否带有 # omarchy:summary= 头、是否误用了已废弃或冗余字段(binary、args=、legacy、usage、visibility、mutates、interactive 等均为被移除字段;requires-sudo=false 这类假布尔值也必须省略)。同时用"恶意构造的元数据注释"验证解析器的健壮性:未知字段非致命、只出现在脚本正文(而非头部注释区)的 # omarchy:* 行被忽略、只有部分元数据头时仍能按文件名推断出回退路由。
主题管线。omarchy-theme-set-templates、omarchy-theme-color、omarchy-theme-osc 这些渲染命令在伪造的 $HOME 与临时 colors.toml 上被真实执行,检查模板辅助函数(mix/mix_rgb/mix_strip、hypr_gradient/gradient_start/shell_gradient 及其缺省回退)、语义色与旧版短色板别名的解析、tokyo-night 前景色阶序、内置主题不含 cursor token 等不变式。随后 tmux、GNOME(gsettings)、VS Code、Pi、Claude 等主题同步命令则全部在桩二进制与假 $HOME 上运行(详见下文"桩件替换世界"一节),例如用假 gsettings 日志验证 omarchy-theme-set-gnome 读取 colors.toml 的 mode 生成了 prefer-light。
./test/shell:命名即注册,新测试的默认去处
test/shell 的逻辑很朴素:遍历 $ROOT/test/shell.d/ 下所有 *-test.sh,跳过 base-test.sh 本身,逐个用 bash 执行,收集失败文件后统一汇报。因此在 Omarchy 里新增一个 shell 测试的成本低到只差一个文件名:
只要把
<area>-test.sh丢进test/shell.d/,./test/shell会自动拾取它。
每一个文件就是一个独立的套件,覆盖一个领域:某个 shell 插件、某个 bin/ 命令、某条配置不变式、或者某个仍存活的迁移。仓库当前 test/shell.d/ 下有 228 个这样的测试文件(如 clock-test.sh、app-search-test.sh、ascii-test.sh)。共享的固定装置(fixtures)放在 test/shell.d/fixtures/ 下,例如 manifest-entrypoints、plugin-registry、pointer-move-gate、privileged-heredoc、network-captive-portal 等按主题组织的子目录。
./test/shell 自身为被测试文件的首错退出提供了补偿(见下节),并最终打印 All N test files passed. 或列出失败明细。
base-test.sh 契约:TAP 风格断言与"首错即终"
每一个 shell 测试都以完全相同的样板开头(文档称之为 base-test.sh 契约):
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
test/shell.d/base-test.sh 的实现验证了文档描述的每一个细节:
- 拒绝被直接执行。文件开头用
${BASH_SOURCE[0]} == "$0"判断自己是不是被source的:若被直接运行,打印"请从 shell 测试中 source 本文件"并以退出码 1 结束——它是一个纯库。 - 从自身位置发现仓库根并导出
ROOT。ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd),于是测试一律写$ROOT/bin/...,绝不依赖调用者当前工作目录,也不依赖机器上已安装的 Omarchy——跑的就是这个 checkout。
断言是 TAP 风味的,而且"直白得近乎粗鲁":
pass "description"打印ok - description(stdout)。fail "description" [detail]先(若有 detail 则)打印可选细节,再把not ok - description打到 stderr,然后exit 1直接退出整个文件。同一文件内不做计数、不继续执行——第一个失败的断言就终结该文件,避免后续断言基于已被失败破坏的状态继续报错。require_command <cmd>在缺少所需工具时让文件失败(例如某迁移测试在开头require_command jq)。
作为对"文件级早退"的补偿,运行器(./test/shell)会在一个文件失败后继续跑完其余文件并在最后汇总——也就是文档强调的失败粒度设计:运行内按文件、文件内按断言。这在源码注释中再次被印证:测试文件在首个失败断言即退出,如果运行器也跟着中止,就会隐藏其后所有文件的诊断——一次打包失败曾让 134 个文件中的 114 个"被遮蔽"。
Compositor 门控:让合成器相关测试在无头机器上安全跳过
有些测试要启动 Quickshell 或查询 Hyprland,但套件必须在无头机器(没有 compositor 的 CI 沙箱)上保持绿色。文档给出的答案是 require_compositor "description":
if compositor_reachable; then
# 探测无法跑赢运行中途死亡的 compositor:Quickshell 连接掉线会走 qFatal() 退出,
# 关掉 core dump,让测试正常失败但不留下垃圾转储。
ulimit -c 0 2>/dev/null || true
return 0
fi
pass "no Wayland compositor; skipping $description"
exit 0
当没有 compositor 应答时打印 ok - no Wayland compositor; skipping ... 并 exit 0——skip 就是一次通过的测试;否则返回,让文件继续执行。
compositor_reachable 的实现恰恰是最容易被忽略的坑:仅检查 WAYLAND_DISPLAY 是不够的,因为它只能证明"环境变量被继承了"。沙箱会把整个环境透传进来、却封锁 $XDG_RUNTIME_DIR,于是 Quickshell 能通过裸的变量检查、却在 QGuiApplication 内部(任何 QML 加载之前)abort——每次启动都产生一份本应被 skip 的 core dump。所以真实的探测分两步(见 test/shell.d/base-test.sh):
- 检查 socket 真实存在:
WAYLAND_DISPLAY非空,且把相对 socket 名拼到${XDG_RUNTIME_DIR:-}下后[[ -S $socket ]]成立; - 再问 Hyprland 本人:因为"运行中途死掉的 compositor 会把自己的 socket 留在原地",仅靠 socket 会误判。只有设置了
HYPRLAND_INSTANCE_SIGNATURE(此时才能询问)才执行hyprctl -j monitors,并重试 3 次(每次间隔 0.5s)——Hyprland 在重配输出时可能漏答一次查询,一次漏答不等于 compositor 已死,重试方式与omarchy-launch-shell保持一致。
还有一个配套细节:探测通过后 require_compositor 会设置 ulimit -c 0。原因是 Quickshell 在连接中途掉线时走 qFatal() 退出,测试应当失败、但不应留下 core dump 作为碎屑。
文档给出门控的边界原则:只门控真正需要门控的部分——把 require_compositor 放进"运行期一半需要活会话"的文件里,而对同一区域的静态分析,则放进无条件下(之前或旁边)运行的代码中。一个很好的实例是 clock-test.sh:日历/时钟插件的全部日期算法(ISO 周、当年进度、memento mori、月网格、格式环)都作为纯函数在无 compositor 下断言,仅把"widget 接线"用 QML 源码正则去静态验证,而没有让整个文件等待活会话。
从 bash 单元测试 JavaScript:run_node_test 桥
Quickshell 插件把业务逻辑留在纯 .js 模块里(如 shell/plugins/menu/MenuModel.js、shell/plugins/bar/BarModel.js),这些模块末尾都带一个被守卫的导出块:
if (typeof module !== "undefined") module.exports = { ... }
QML 直接 import 它并忽略守卫;Node 则把它当作 CommonJS 加载。这种"双国籍"正是 shell 模型逻辑能在没有 compositor 的情况下被单元测试的根本原因。
run_node_test(同样实现在 test/shell.d/base-test.sh)就是连接 bash 世界与 node 世界的桥:它把一段 JS 前导(prelude)拼到 heredoc 前面,再整体管道给 node。前导部分镜像了 bash 侧的断言协议——pass、fail、assert、assertEqual、assertDeepEqual,输出同样的 ok/not ok 行,同样首错即退——并且额外提供三个环境对象:
root(来自被导出的ROOT);path;requireFromRoot(relativePath),即require(path.join(root, relativePath))。
测试端因此可以写得很干净(文档示例即为可运行代码):
run_node_test <<'JS'
const menu = requireFromRoot('shell/plugins/menu/MenuModel.js')
const parsed = menu.parseMenuJsonc('{ "items": { "root": { "label": "Go" }, }, }')
assertEqual(parsed.length, 1, 'menu parses JSONC with trailing commas')
JS
真实用例在仓库里随处可见。clock-test.sh 用 calendar.normalizedWeekStart、isoWeek、yearProgressPercent、parseBirthYear、clockFormatRing 等纯函数对"设置读取的容错、日期算术的边界(跨年、闰年、''s 中的转义撇号是否算秒)"做了穷举式断言;app-search-test.sh 则加载 shell/services/AppSearch.js,验证 fuzzyScore 的松散子序列匹配不会让 "Calculator" 匹配 "contact"、短缩写 "gc" 能命中 "Google Contacts",再对 shell/plugins/menu/Menu.qml 与 shell/services/AppLibrary.qml 的源码做正则级静态断言(如应用启动经由 root.appLibrary.launch(...) 走 uwsm-app -- gtk-launch,而不是 entry.execute())。文档给出的经验数据是:大约四分之一的 shell 测试文件都采用这种纯函数测试法,把 compositor 门控的用例留给"只有活会话才能证明"的部分。
值得借鉴的工程约定
桩件替换世界,真实运行被测代码
测试会构建一个临时的 bin/ 目录,里面放满桩可执行文件(sudo、tmux、gsettings 以及被测脚本要调用的 Omarchy 助手命令),它们的行为是把收到的参数追加写进日志文件;测试把它前置到 PATH 上,然后运行真实的被测脚本,最后用 grep 断言调用日志、以及脚本写出的文件内容。
test/cli 中有一个完整的例子:手写 tmux 桩(list-sessions 回显 session、set-environment/set-option 把 $* 写进 ${TMUX_LOG:-/dev/null}),于是 omarchy-theme-set-tmux 在假 HOME 下真实执行后,测试能 grep 到 set-environment -g COLORFGBG 0;15,从而证明"tmux 同步确实从 colors.toml 读到了明/暗模式"。同理还有假 gsettings(org.gnome.desktop.interface color-scheme prefer-light)、假 omarchy-cmd-present、假 omarchy-toggle-enabled,以及用 VS Code 的 extensions.json/package.json/符号链接来验证 omarchy-theme-set-vscode 注册本地扩展的完整行为。
伪造 $HOME,使用真实的 $OMARCHY_PATH
任何触碰用户状态的测试都这样运行:HOME 指向一个 mktemp -d 生成的目录(用 trap ... EXIT 保证清理),并设 OMARCHY_PATH="$ROOT",于是被测脚本操作的是 checkout 本身,而永远不会碰到开发者机器上的真实配置。这正对应 test/cli 里的 make_tmpdir + TMPDIRS 数组 + trap cleanup EXIT 的组合——每个临时目录都注册在案、退出时统一删除。
迁移脚本直接运行:从建旧状态到验证幂等
迁移测试不走任何抽象层:在假 $HOME 里先搭好旧版状态(例如往 shell.json 里写入旧 widget id omarchy.model-usage),然后 bash -euo pipefail "$ROOT/migrations/<ts>.sh" 直接跑真实迁移,再断言结果状态。完整的做法包含三种运行:
- 跑一遍,断言迁移产物正确;
- 再跑第二遍证明幂等(重复执行不会破坏已迁移状态);
- 对非旧版状态跑一遍,证明它不动用户已有的自定义配置。
agents-rename-migration-test.sh 就是活例子:它先往假 HOME 写入包含字符串形式条目、对象形式条目(带 syncMode/syncDir 设置)以及 disabledPlugins 的旧版 shell.json,再用 jq 断言 omarchy.model-usage 被改名为 omarchy.agents 且设置被保留、被禁用的 widget 依旧禁用;它还给 omarchy-agent-usage-update 做了一个"每次运行追加一行"的桩,用于验证迁移确实触发了更新。
base-test.sh 约定里对"测试活多久"有一条务实的纪律,值得原样引用:
- 保留该迁移测试——只要迁移仍在编写或修 bug 中、只要它调用了接口仍可能变化的 Omarchy 助手、或者只要它是安全敏感的提权修复;
- 一旦某个一次性重写已在打了 tag 的 release 中发布并冻结,就删掉该测试——即便那次重写用到了
sudo、pacman或limine-mkinitcpio; - 但迁移脚本本身要保留,为的是晚升级的用户;
omarchy-migrate、登录通知器(login notifier)与omarchy-upgrade-to-quattro的测试始终保留。
一句话逻辑:测试守卫的是"接口还可能变、出错代价高"的时刻;冻结后的一次性重写,历史版本已经固化为用户的既有状态,测试就完成了使命。
断言不变式,而不是断言快照
配置类测试只钉住"测试名字所承诺的那条性质"——比如"这个 widget 保持与那个相邻"——而不是把整个文件/整个结构逐字比较。这样与测试意图无关的改动不会让测试红掉。时钟测试对 shell.json 默认布局的校验是最小的:只用 jq 抽出中心栏 id 列表断言包含 omarchy.clock、不包含独立的 omarchy.calendar,而不是冻结整份 JSON。这个原则同样体现在它用"去注释后的 QML 源码 + 正则"做接线断言:如果一条被注释掉的旧代码就能满足断言,widget 实际坏了测试也会通过,所以必须先剥掉注释再匹配。
实战:新增一个测试的最小路径
综合以上契约,往 Omarchy 仓库加一个针对新 bin/ 命令的非图形化测试,标准流程是:
- 在 test/shell.d/ 下新建
<area>-test.sh,头部照抄 base-test 契约的五行样板; - 需要的共享断言直接从 base-test 继承(
pass/fail/require_command/require_compositor/run_node_test); - 若需要桩或固定装置,放进 test/shell.d/fixtures/ 对应子目录;
- 命令行运行
./test/shell(单套件)或./test/all(CLI + shell 两套一起,某套失败也不阻塞另一套),跑完看结尾的失败汇总;退出码非零即表示有套件/文件失败; - 需要真实桌面的用例不要放进这里,而应参照 agents/skills/acceptance-tests.md 在一次性 VM 中驱动
test/acceptance.d/下的验收测试。
这套体系的核心思路贯穿始终:无头机器上跑得动(compositor 探测 + skip 即 pass)、失败不互相遮蔽(test/all 与 test/shell 两级都收集汇总)、真实代码被真实执行(桩只替换外部命令,不替换被测逻辑),以及 bash 与 Node 共用同一套 TAP 断言协议(让纯 JS 模型逻辑无需任何图形环境即可获得单元测试覆盖)。对于想给自家 Linux 工具链或桌面项目搭建轻量、可在 CI 无头环境运行的测试框架的开发者,Omarchy 的这份设计与实现是极具参考价值的范本。
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 StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00