首页
/ Omarchy 测试体系深度解析:无图形化测试套件如何在不依赖桌面的环境下保持高质量

Omarchy 测试体系深度解析:无图形化测试套件如何在不依赖桌面的环境下保持高质量

2026-09-08 16:54:50作者:段琳惟

本文围绕 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-parenthelpomarchy-parenthelp-child)加 commands --all --json 的 jq 断言完成的。

元数据 lint。每个 bin/omarchy-* 可执行文件都会被检查:是否带有 # omarchy:summary= 头、是否误用了已废弃或冗余字段(binaryargs=legacyusagevisibilitymutatesinteractive 等均为被移除字段;requires-sudo=false 这类假布尔值也必须省略)。同时用"恶意构造的元数据注释"验证解析器的健壮性:未知字段非致命、只出现在脚本正文(而非头部注释区)的 # omarchy:* 行被忽略、只有部分元数据头时仍能按文件名推断出回退路由。

主题管线omarchy-theme-set-templatesomarchy-theme-coloromarchy-theme-osc 这些渲染命令在伪造的 $HOME 与临时 colors.toml 上被真实执行,检查模板辅助函数(mix/mix_rgb/mix_striphypr_gradient/gradient_start/shell_gradient 及其缺省回退)、语义色与旧版短色板别名的解析、tokyo-night 前景色阶序、内置主题不含 cursor token 等不变式。随后 tmux、GNOME(gsettings)、VS Code、Pi、Claude 等主题同步命令则全部在桩二进制与假 $HOME 上运行(详见下文"桩件替换世界"一节),例如用假 gsettings 日志验证 omarchy-theme-set-gnome 读取 colors.tomlmode 生成了 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.shapp-search-test.shascii-test.sh)。共享的固定装置(fixtures)放在 test/shell.d/fixtures/ 下,例如 manifest-entrypointsplugin-registrypointer-move-gateprivileged-heredocnetwork-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 结束——它是一个纯库。
  • 从自身位置发现仓库根并导出 ROOTROOT=$(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):

  1. 检查 socket 真实存在WAYLAND_DISPLAY 非空,且把相对 socket 名拼到 ${XDG_RUNTIME_DIR:-} 下后 [[ -S $socket ]] 成立;
  2. 再问 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.jsshell/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 侧的断言协议——passfailassertassertEqualassertDeepEqual,输出同样的 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.shcalendar.normalizedWeekStartisoWeekyearProgressPercentparseBirthYearclockFormatRing 等纯函数对"设置读取的容错、日期算术的边界(跨年、闰年、''s 中的转义撇号是否算秒)"做了穷举式断言;app-search-test.sh 则加载 shell/services/AppSearch.js,验证 fuzzyScore 的松散子序列匹配不会让 "Calculator" 匹配 "contact"、短缩写 "gc" 能命中 "Google Contacts",再对 shell/plugins/menu/Menu.qmlshell/services/AppLibrary.qml 的源码做正则级静态断言(如应用启动经由 root.appLibrary.launch(...)uwsm-app -- gtk-launch,而不是 entry.execute())。文档给出的经验数据是:大约四分之一的 shell 测试文件都采用这种纯函数测试法,把 compositor 门控的用例留给"只有活会话才能证明"的部分。

值得借鉴的工程约定

桩件替换世界,真实运行被测代码

测试会构建一个临时的 bin/ 目录,里面放满桩可执行文件sudotmuxgsettings 以及被测脚本要调用的 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 读到了明/暗模式"。同理还有假 gsettingsorg.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" 直接跑真实迁移,再断言结果状态。完整的做法包含三种运行:

  1. 跑一遍,断言迁移产物正确;
  2. 再跑第二遍证明幂等(重复执行不会破坏已迁移状态);
  3. 非旧版状态跑一遍,证明它不动用户已有的自定义配置。

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 中发布并冻结,就删掉该测试——即便那次重写用到了 sudopacmanlimine-mkinitcpio
  • 迁移脚本本身要保留,为的是晚升级的用户;
  • omarchy-migrate、登录通知器(login notifier)与 omarchy-upgrade-to-quattro 的测试始终保留

一句话逻辑:测试守卫的是"接口还可能变、出错代价高"的时刻;冻结后的一次性重写,历史版本已经固化为用户的既有状态,测试就完成了使命。

断言不变式,而不是断言快照

配置类测试只钉住"测试名字所承诺的那条性质"——比如"这个 widget 保持与那个相邻"——而不是把整个文件/整个结构逐字比较。这样与测试意图无关的改动不会让测试红掉。时钟测试对 shell.json 默认布局的校验是最小的:只用 jq 抽出中心栏 id 列表断言包含 omarchy.clock、不包含独立的 omarchy.calendar,而不是冻结整份 JSON。这个原则同样体现在它用"去注释后的 QML 源码 + 正则"做接线断言:如果一条被注释掉的旧代码就能满足断言,widget 实际坏了测试也会通过,所以必须先剥掉注释再匹配。

实战:新增一个测试的最小路径

综合以上契约,往 Omarchy 仓库加一个针对新 bin/ 命令的非图形化测试,标准流程是:

  1. test/shell.d/ 下新建 <area>-test.sh,头部照抄 base-test 契约的五行样板;
  2. 需要的共享断言直接从 base-test 继承(pass/fail/require_command/require_compositor/run_node_test);
  3. 若需要桩或固定装置,放进 test/shell.d/fixtures/ 对应子目录;
  4. 命令行运行 ./test/shell(单套件)或 ./test/all(CLI + shell 两套一起,某套失败也不阻塞另一套),跑完看结尾的失败汇总;退出码非零即表示有套件/文件失败;
  5. 需要真实桌面的用例不要放进这里,而应参照 agents/skills/acceptance-tests.md 在一次性 VM 中驱动 test/acceptance.d/ 下的验收测试。

这套体系的核心思路贯穿始终:无头机器上跑得动(compositor 探测 + skip 即 pass)、失败不互相遮蔽test/alltest/shell 两级都收集汇总)、真实代码被真实执行(桩只替换外部命令,不替换被测逻辑),以及 bash 与 Node 共用同一套 TAP 断言协议(让纯 JS 模型逻辑无需任何图形环境即可获得单元测试覆盖)。对于想给自家 Linux 工具链或桌面项目搭建轻量、可在 CI 无头环境运行的测试框架的开发者,Omarchy 的这份设计与实现是极具参考价值的范本。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
898
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
921
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.8 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
391