# Testing How the non-graphical test suites are organized: what each runner owns, the protocol test files speak, and the conventions that keep them runnable on any machine — including headless CI sandboxes with no compositor. The graphical acceptance suite is a separate thing that drives a live session in a disposable VM; see [`agents/skills/acceptance-tests.md`](../agents/skills/acceptance-tests.md). ## Suite map `./test/all` runs both suites below and keeps going when one fails, so a single failure cannot hide the other suite behind it. It reports the failed suites at the end and exits non-zero. - **`./test/cli`** — one big script, one suite. It owns the CLI router: help and group rendering, route resolution, aliases, hidden commands, and the guarantee that a trailing `--help` never executes the target. It also owns the metadata lint — every `omarchy-*` executable under `bin/` is checked for a `# omarchy:summary=` header and against removed or redundant fields — plus the theme pipeline: template rendering (`omarchy-theme-set-templates`, `omarchy-theme-color`, `omarchy-theme-osc`), the theme sync commands (tmux, GNOME, VS Code, Pi, Claude) run against stub binaries and a fake `$HOME`. - **`./test/shell`** — runs every `test/shell.d/*-test.sh` (except `base-test.sh` itself). Each file is an independent suite covering one area: a shell plugin, a `bin/` command, a config invariant, or a still-live migration. This is where new tests go. - **Acceptance** — everything that needs a real desktop doing real things. Deliberately excluded from `./test/all`; it runs in a VM, not the development session. A new shell test only needs the right name: drop `-test.sh` into `test/shell.d/` and `./test/shell` picks it up automatically. Shared fixtures live under `test/shell.d/fixtures/`. ## The base-test.sh contract Every shell test starts the same way: ```bash #!/bin/bash set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" ``` `base-test.sh` refuses to be executed directly — it is a library. It discovers the repo root from its own location and exports it as `ROOT`, so tests reference files as `$ROOT/bin/...` and never depend on the caller's working directory or an installed Omarchy. Assertions are TAP-flavored and blunt: - `pass "description"` prints `ok - description`. - `skip "description"` prints `ok - description # SKIP`. Include why the check could not run. Like `pass`, it returns normally; use `exit 0` afterwards only when the rest of the file cannot run either. - `fail "description" [detail]` prints the optional detail and `not ok - description` to stderr, then **exits the file**. There is no counting or continuing within a file: the first failed assertion ends it, which keeps later assertions from reporting against state the failure already invalidated. - `require_command ` fails the file when a needed tool is absent. The runner compensates for that early exit: `./test/shell` continues past a failing file and summarizes the failures at the end. Aborting the whole run at the first bad file once let a single packaging failure mask 114 of 134 files. Failure granularity is therefore per file inside a run, per assertion inside a file. The runner also lists files with skipped checks, including files that ran some checks or later failed. Skips do not fail a run. When no files fail, a run with skips is reported as completed without failures rather than having passed every check. ## Compositor-dependent tests Some tests launch Quickshell or query Hyprland, but the suite must stay green on headless machines. `require_compositor "description"` handles this: when no compositor answers it calls `skip` with the reason and exits 0, and otherwise returns so the file proceeds. The skip marker lets the runner distinguish unavailable runtime coverage from checks that passed. The probe is more than an environment check, because `WAYLAND_DISPLAY` only proves the variable was inherited. Sandboxes pass the environment through while blocking `$XDG_RUNTIME_DIR`, so Quickshell clears a bare variable check and then aborts inside QGuiApplication — a core dump per launch where a skip belonged. So `compositor_reachable` checks the socket actually exists, then asks Hyprland itself (`hyprctl -j monitors`, retried, and only when `HYPRLAND_INSTANCE_SIGNATURE` makes it askable), since a compositor that died mid-session leaves its socket behind. When the compositor is reachable, `require_compositor` also sets `ulimit -c 0`: Quickshell leaves through `qFatal()` if its connection drops mid-run, and the test should fail without writing a core dump as debris. Gate only what needs gating — put `require_compositor` in files whose runtime half needs a live session, and keep static analysis of the same area in code that runs unconditionally before or beside it. ## Unit-testing shell JavaScript from bash The Quickshell plugins keep their logic in plain `.js` modules (`shell/plugins/menu/MenuModel.js`, `bar/BarModel.js`, ...) that end in a guarded `if (typeof module !== "undefined") module.exports = {...}` block. QML imports them directly and ignores the guard; Node loads them as CommonJS. That dual citizenship is what makes the shell's model logic unit-testable without a compositor. `run_node_test` is the bridge: it prepends a JS prelude to a heredoc and pipes the result into `node`. The prelude mirrors the bash assertion protocol (`pass`, `fail`, `assert`, `assertEqual`, `assertDeepEqual` — same `ok`/`not ok` lines, same exit-on-first-failure) and provides `root` (from the exported `ROOT`), `path`, and `requireFromRoot(relativePath)`: ```bash 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 ``` Roughly a quarter of the shell test files use this to test parsing, merging, and layout logic as pure functions, reserving compositor-gated tests for what only a live session can prove. ## Conventions worth copying - **Redirect background output.** Send background fixtures' stdout to a log or `/dev/null` and clean up the processes on exit. An inherited output pipe can hold the runner open after the test exits. - **Stub the world, run the real code.** Tests build a scratch `bin/` of stub executables (`sudo`, `tmux`, `gsettings`, helper commands) that log their arguments to a file, prepend it to `PATH`, and then run the real script under test. Assertions grep the call log and the files the script wrote. - **Fake `$HOME`, real `$OMARCHY_PATH`.** Anything touching user state runs with `HOME` pointed at a `mktemp -d` directory (cleaned up via `trap ... EXIT`) and `OMARCHY_PATH="$ROOT"`, so tests exercise the checkout without touching the developer's machine. - **Migrations run directly.** A migration test builds the legacy state in a fake `$HOME`, runs `bash -euo pipefail "$ROOT/migrations/.sh"`, and asserts the resulting state — including running it twice to prove idempotence, and once against non-legacy state to prove it leaves user customization alone. Keep that test while the migration is still being written or bugfixed, if it calls an Omarchy helper whose interface can still change, or if it is a security-sensitive privileged repair. Once a one-shot rewrite has shipped in a tagged release and is frozen, drop the test even when that rewrite used sudo, pacman, or limine-mkinitcpio. Keep the migration itself for late-updaters. Tests of `omarchy-migrate`, the login notifier, and `omarchy-upgrade-to-quattro` stay. - **Assert the invariant, not the snapshot.** Config tests pin the property a test is named for (this widget stays adjacent to that one) rather than whole structures, so unrelated churn does not fail them.