From 5aa9e4173d86010c81eeceffab8bb42a3334b431 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 21 Jul 2026 18:18:51 -0700 Subject: [PATCH] Add in-guest acceptance test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs inside a live Omarchy session (typically a VM installed by omarchy-iso-test) and verifies the desktop actually works: session health, application launches by window class, and shell surfaces via IPC — including typing into the launcher and launching the top hit. Co-Authored-By: Claude Fable 5 --- test/acceptance | 74 ++++++++++++++++++++++ test/acceptance.d/apps-test.sh | 45 ++++++++++++++ test/acceptance.d/base-test.sh | 78 ++++++++++++++++++++++++ test/acceptance.d/session-test.sh | 56 +++++++++++++++++ test/acceptance.d/shell-surfaces-test.sh | 48 +++++++++++++++ 5 files changed, 301 insertions(+) create mode 100755 test/acceptance create mode 100644 test/acceptance.d/apps-test.sh create mode 100644 test/acceptance.d/base-test.sh create mode 100644 test/acceptance.d/session-test.sh create mode 100644 test/acceptance.d/shell-surfaces-test.sh diff --git a/test/acceptance b/test/acceptance new file mode 100755 index 00000000..b12c0c35 --- /dev/null +++ b/test/acceptance @@ -0,0 +1,74 @@ +#!/bin/bash +# +# In-guest acceptance suite. Runs inside a live Omarchy session (typically a +# VM installed by omarchy-iso-test, reached over SSH) and verifies that the +# desktop actually works: session health, application launches, and shell +# surfaces (launcher, emoji picker, menu). +# +# Artifacts (screenshots, logs) land in $OMARCHY_ACCEPTANCE_DIR. + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +TEST_DIR="$ROOT/test/acceptance.d" + +export OMARCHY_ACCEPTANCE_DIR="${OMARCHY_ACCEPTANCE_DIR:-/tmp/omarchy-acceptance}" +mkdir -p "$OMARCHY_ACCEPTANCE_DIR" + +# Discover the graphical session so the suite works over SSH, where none of +# the session environment is inherited. +export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" +export DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=$XDG_RUNTIME_DIR/bus}" +export OMARCHY_PATH="${OMARCHY_PATH:-$ROOT}" +export PATH="$OMARCHY_PATH/bin:$PATH" + +# First boot may still be settling; wait for Hyprland to come up. +boot_deadline=$((SECONDS + ${OMARCHY_ACCEPTANCE_BOOT_TIMEOUT:-300})) + +wait_for_session() { + local signature + + while true; do + signature=$(ls -t "$XDG_RUNTIME_DIR/hypr" 2>/dev/null | head -1) + + if [[ -n $signature ]]; then + export HYPRLAND_INSTANCE_SIGNATURE="$signature" + hyprctl -j monitors >/dev/null 2>&1 && return 0 + fi + + if ((SECONDS >= boot_deadline)); then + echo "not ok - Hyprland session never came up" >&2 + exit 1 + fi + + sleep 2 + done +} + +wait_for_session +printf 'ok - Hyprland session is up (signature %s)\n' "$HYPRLAND_INSTANCE_SIGNATURE" + +if [[ -z ${WAYLAND_DISPLAY:-} ]]; then + display=$(find "$XDG_RUNTIME_DIR" -maxdepth 1 -name "wayland-*" ! -name "*.lock" -printf '%f\n' 2>/dev/null | head -1) + export WAYLAND_DISPLAY="${display:-wayland-1}" +fi + +shopt -s nullglob +tests=() +for test in "$TEST_DIR"/*-test.sh; do + [[ $(basename "$test") == "base-test.sh" ]] && continue + tests+=("$test") +done +shopt -u nullglob + +if (( ${#tests[@]} == 0 )); then + echo "No acceptance tests found in $TEST_DIR" >&2 + exit 1 +fi + +for test in "${tests[@]}"; do + printf '==> %s\n' "${test#$ROOT/}" + bash "$test" +done + +printf 'ok - acceptance suite passed (artifacts in %s)\n' "$OMARCHY_ACCEPTANCE_DIR" diff --git a/test/acceptance.d/apps-test.sh b/test/acceptance.d/apps-test.sh new file mode 100644 index 00000000..1717e0a7 --- /dev/null +++ b/test/acceptance.d/apps-test.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +launch_and_verify() { + local name="$1" command="$2" class="$3" + + # A pre-existing window makes the test ambiguous (and closing it would be + # hostile on a dev machine) — acceptance runs expect a fresh session. + if window_present "$class" >/dev/null 2>&1; then + fail "$name starts with no pre-existing window" "a window matching $class is already open" + fi + + launch_app "$command" + wait_until "$name opens a window" 90 window_present "$class" + sleep 1 + screenshot "app-$name" + + local deadline=$((SECONDS + 30)) + while window_present "$class" >/dev/null 2>&1; do + close_windows "$class" + + if ((SECONDS >= deadline)); then + fail "$name window closes" + fi + + sleep 2 + done + pass "$name window closes" +} + +# name|command|window class regex +apps='terminal|foot|^foot$ +browser|chromium --new-window|(?i)chromium +files|nautilus --new-window|org.gnome.Nautilus +calculator|gnome-calculator|org.gnome.Calculator +notes|obsidian|(?i)obsidian +office|libreoffice|(?i)(soffice|libreoffice) +pdf-viewer|evince|(?i)evince' + +while IFS='|' read -r name command class; do + launch_and_verify "$name" "$command" "$class" +done <<<"$apps" diff --git a/test/acceptance.d/base-test.sh b/test/acceptance.d/base-test.sh new file mode 100644 index 00000000..55151835 --- /dev/null +++ b/test/acceptance.d/base-test.sh @@ -0,0 +1,78 @@ +#!/bin/bash + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + echo "source test/acceptance.d/base-test.sh from an acceptance test; do not run it directly" >&2 + exit 1 +fi + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +ARTIFACTS="${OMARCHY_ACCEPTANCE_DIR:-/tmp/omarchy-acceptance}" + +mkdir -p "$ARTIFACTS" + +pass() { + printf 'ok - %s\n' "$1" +} + +fail() { + local description="$1" + local detail="${2:-}" + + [[ -n $detail ]] && printf '%s\n' "$detail" >&2 + screenshot "failure-$(date +%s)" + printf 'not ok - %s\n' "$description" >&2 + exit 1 +} + +screenshot() { + grim "$ARTIFACTS/$1.png" 2>/dev/null || true +} + +# Poll a command until it succeeds; screenshot and fail on timeout. +wait_until() { + local description="$1" timeout="$2" + shift 2 + + local deadline=$((SECONDS + timeout)) + + until "$@" >/dev/null 2>&1; do + if ((SECONDS >= deadline)); then + fail "$description" "timed out after ${timeout}s waiting for: $*" + fi + sleep 1 + done + + pass "$description" +} + +window_present() { + hyprctl -j clients | jq -e --arg class "$1" '[.[] | select(.class | test($class))] | length > 0' +} + +window_absent() { + ! window_present "$1" +} + +layer_present() { + hyprctl -j layers | jq -e --arg ns "$1" '[.. | objects | select(.namespace? == $ns)] | length > 0' +} + +layer_absent() { + ! layer_present "$1" +} + +# Close every window matching a class regex, by address so multi-window apps +# are fully closed. Tries the quattro Lua dispatcher first, then classic. +close_windows() { + local class="$1" + local addr + + while read -r addr; do + hyprctl dispatch "hl.dsp.window.close({ window = \"address:$addr\" })" >/dev/null 2>&1 || + hyprctl dispatch closewindow "address:$addr" >/dev/null 2>&1 || true + done < <(hyprctl -j clients | jq -r --arg class "$class" '.[] | select(.class | test($class)) | .address') +} + +launch_app() { + setsid -f bash -c "$1" >/dev/null 2>&1 +} diff --git a/test/acceptance.d/session-test.sh b/test/acceptance.d/session-test.sh new file mode 100644 index 00000000..e1642dae --- /dev/null +++ b/test/acceptance.d/session-test.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +# The compositor reports at least one monitor +monitors=$(hyprctl -j monitors | jq 'length') +(( monitors >= 1 )) || fail "compositor reports a monitor" +pass "compositor reports a monitor" + +# The Omarchy shell is running and responsive +wait_until "omarchy-shell responds to ping" 60 omarchy-shell shell ping + +# Core shell plugins are loaded +plugins=$(omarchy-shell shell listPlugins) +for plugin in omarchy.bar omarchy.launcher omarchy.emojis omarchy.menu omarchy.notifications; do + [[ $plugins == *"$plugin"* ]] || fail "shell plugin is loaded: $plugin" "loaded plugins: $plugins" + pass "shell plugin is loaded: $plugin" +done + +# The bar and background are actually on screen +wait_until "bar layer is on screen" 30 layer_present "omarchy-bar" +wait_until "background layer is on screen" 30 layer_present "omarchy-background" + +# Audio stack is up +wait_until "pipewire is running" 30 wpctl status + +# Root filesystem is btrfs as installed +[[ $(findmnt -no FSTYPE /) == "btrfs" ]] || fail "root filesystem is btrfs" +pass "root filesystem is btrfs" + +# Omarchy reports its version +omarchy-version >/dev/null || fail "omarchy-version works" +pass "omarchy-version works" + +# No failed units, system or user. OMARCHY_ACCEPTANCE_IGNORE_UNITS can hold a +# regex of units to overlook (useful on dev machines; a fresh VM should be clean). +failed_units() { + systemctl "$@" --failed --no-legend --plain | awk '{print $1}' | + grep -Ev "${OMARCHY_ACCEPTANCE_IGNORE_UNITS:-^$}" || true +} + +failed_system=$(failed_units --system) +if [[ -n $failed_system ]]; then + fail "no failed system units" "failed units: $failed_system" +fi +pass "no failed system units" + +failed_user=$(failed_units --user) +if [[ -n $failed_user ]]; then + fail "no failed user units" "failed units: $failed_user" +fi +pass "no failed user units" + +screenshot "desktop" diff --git a/test/acceptance.d/shell-surfaces-test.sh b/test/acceptance.d/shell-surfaces-test.sh new file mode 100644 index 00000000..82e986d2 --- /dev/null +++ b/test/acceptance.d/shell-surfaces-test.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +open_and_close() { + local name="$1" plugin="$2" namespace="$3" payload="${4:-}" + + if [[ -n $payload ]]; then + omarchy-shell shell summon "$plugin" "$payload" >/dev/null + else + omarchy-shell shell summon "$plugin" >/dev/null + fi + + wait_until "$name opens" 15 layer_present "$namespace" + sleep 1 + screenshot "$name-open" + + omarchy-shell shell hide "$plugin" >/dev/null + wait_until "$name closes" 15 layer_absent "$namespace" +} + +# Overlays open and close on IPC command +open_and_close "emoji-picker" omarchy.emojis omarchy-emojis +open_and_close "menu" omarchy.menu omarchy-menu '{"menu":"root"}' +open_and_close "clipboard" omarchy.clipboard omarchy-clipboard + +# The launcher does the full loop: open, search by typing, launch the top hit +if window_present "org.gnome.Calculator" >/dev/null 2>&1; then + fail "launcher test starts with no calculator window" "a calculator window is already open" +fi + +omarchy-shell shell summon omarchy.launcher >/dev/null +wait_until "launcher opens" 15 layer_present "omarchy-launcher" +sleep 1 +screenshot "launcher-open" + +wtype "calculator" +sleep 1 +screenshot "launcher-search" +wtype -k Return + +wait_until "launcher launches the top search hit" 60 window_present "org.gnome.Calculator" +wait_until "launcher closes after launching" 15 layer_absent "omarchy-launcher" + +close_windows "org.gnome.Calculator" +wait_until "calculator window closes" 30 window_absent "org.gnome.Calculator"