diff --git a/doctor.sh b/doctor.sh index a3b658e..d2ebeb7 100755 --- a/doctor.sh +++ b/doctor.sh @@ -62,6 +62,22 @@ fi f=$(systemctl --user --failed --no-legend --plain 2>/dev/null | awk '{print $1}' \ | sed 's/^macarchy-failed@\(.*\)\.service$/\1/' | sort -u | paste -sd' ' -) [[ -z $f ]] && ok "no failed user units" || bad "failed user units: $f" +# macarchy-auto-appearance now exits 0 when it cannot compute a sun: a deliberate +# no-op must not park its unit in --failed for ever (macarchy-core#12). That was +# also the only thing a MISCONFIGURED laptop had to tell it, so the doctor asks +# directly instead. Gated on omarchy, because without it the theme switch could +# not happen at all -- false on a CI runner, true on any real machine. +if command -v omarchy >/dev/null 2>&1 && command -v macarchy-auto-appearance >/dev/null 2>&1; then + aa=$(macarchy-auto-appearance status 2>/dev/null) + if [[ $aa == *"enabled=yes"*"error=sun"* || $aa == *"error=sun"*"enabled=yes"* ]]; then + bad "auto-appearance is on but cannot compute the sun (set coordinates; see macarchy-sun)" + else + ok "auto-appearance can decide a theme" + fi +else + skip "auto-appearance sun (no omarchy on this machine)" +fi + # A muted check is worse than none, so the doctor checks that it can speak. [[ -e $HOME/.config/systemd/user/macarchy-failed@.service ]] \ && ok "failure notifier" || bad "failure notifier (macarchy-install/install.sh)" @@ -134,6 +150,16 @@ if [[ -n ${WAYLAND_DISPLAY:-} ]]; then broken=${broken% } [[ -z $broken ]] && ok "Touch Bar modules loaded" \ || bad "Touch Bar module failed to load: $broken (re-run install.sh in the repo that ships it)" + # Same as auto-appearance above: bar-contrast now exits 0 when it has + # nothing to sample, so a session that is missing grim/magick/hyprctl/jq + # would otherwise say nothing at all. Only meaningful with a session, which + # is exactly what this block already has. + if command -v macarchy-bar-contrast >/dev/null 2>&1; then + bc=$(macarchy-bar-contrast status 2>&1) + [[ $bc == *"nothing to sample"* ]] \ + && bad "bar-contrast has nothing to sample: ${bc#*: }" \ + || ok "bar-contrast can sample the screen" + fi pgrep -f "macarchy-als daemon" >/dev/null && ok "macarchy-als" || bad "macarchy-als not running" pgrep -f "macarchy-pinch" >/dev/null && ok "macarchy-pinch" || bad "macarchy-pinch not running" # The tank remembers off across reboots (omarchy-aquarium-toggle:30, an diff --git a/install.sh b/install.sh index 34feeba..57e6cbb 100755 --- a/install.sh +++ b/install.sh @@ -275,6 +275,24 @@ fi # Ordered before the themes on purpose: its installer only seeds a config when # there is none, and the wallpapers it names are the ones apple-glass ships. +# Moved ahead of macos-dynamic-wallpaper: that step STARTS its unit, and the unit +# reads the theme's wallpapers out of ~/.config/omarchy/themes//backgrounds/ +# (macos-dynamic-wallpaper#5). A oneshot that fails once stays failed, so the +# files have to be on disk BEFORE it first runs. This half needs no omarchy -- +# it is an rsync; the `omarchy theme set` half stays where it was, further down. +say "Installing the apple-glass themes" +for t in apple-glass apple-glass-light; do + src="$MACARCHY_DIR/$t" + dst="$HOME/.config/omarchy/themes/$t" + if [[ -d $src ]]; then + mkdir -p "$dst" + rsync -a --delete --exclude .git "$src/" "$dst/" + note "$t synced to ~/.config/omarchy/themes" + else + warn "$t repo missing" + fi +done + say "Installing macos-dynamic-wallpaper (time-of-day backgrounds)" if [[ -x $MACARCHY_DIR/macos-dynamic-wallpaper/install.sh ]]; then (cd "$MACARCHY_DIR/macos-dynamic-wallpaper" && ./install.sh) \ @@ -374,19 +392,6 @@ LUA # ---------------------------------------------------------------- themes -say "Installing the apple-glass themes" -for t in apple-glass apple-glass-light; do - src="$MACARCHY_DIR/$t" - dst="$HOME/.config/omarchy/themes/$t" - if [[ -d $src ]]; then - mkdir -p "$dst" - rsync -a --delete --exclude .git "$src/" "$dst/" - note "$t synced to ~/.config/omarchy/themes" - else - warn "$t repo missing" - fi -done - # The applied theme is a copy; re-apply so edits actually take. On a box # that never chose a theme from this pair, apply the dark one. if command -v omarchy-theme-set >/dev/null || command -v omarchy >/dev/null; then diff --git a/tests/clean-machine.sh b/tests/clean-machine.sh index 230e69e..b4ddab3 100755 --- a/tests/clean-machine.sh +++ b/tests/clean-machine.sh @@ -60,6 +60,12 @@ systemctl --user show -p Version --value >/dev/null 2>&1 || { exit 1 } +# On the way out, whatever the exit status, say WHY every failed user unit failed. +# It has to be a trap: install.sh runs doctor.sh ITSELF, so a MISS reds the run from +# inside the `|| exit 1` below and there is no "after install, before doctor" moment +# to hook. `|| true` and the saved status keep the diagnostics from deciding the run. +trap 'rc=$?; bash tests/dump-failed-units.sh || true; exit $rc' EXIT + ./install.sh || exit 1 # it runs doctor.sh itself at the end, so a MISS reds here sha256sum "$HOME/.config/hypr/autostart.lua" "$HOME/.config/hypr/bindings.lua" >"$MACARCHY_DIR/lua.sha" diff --git a/tests/dump-failed-units.sh b/tests/dump-failed-units.sh new file mode 100755 index 0000000..5d483e7 --- /dev/null +++ b/tests/dump-failed-units.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Say WHY every failed user unit failed. Read-only; prints nothing when nothing failed. +# +# clean-machine.sh could only ever report THAT units failed: install.sh runs +# doctor.sh itself, so a MISS reds the run from inside install.sh and the journals +# that would explain it are never read. Three units sat unexplained on #9 for a day +# because of it. +# +# Its own script, not a function in clean-machine.sh: that script sudo-writes into +# /etc and /usr/local/bin, so nothing can exercise it in a hermetic suite, while +# this is pure stdout over two stubbable commands (tests/test_clean_machine_journal.sh). +set -uo pipefail + +# macarchy-failed@.service is the NOTIFIER for -- its own journal says +# only that it fired. Fold it onto its target, the same sed doctor.sh:63 uses, or +# the dump explains the messenger instead of the message. +dump() { + echo "=== journal: $1 ===" + # `|| true`: a unit with no journal at all must not red the run -- the dump + # is diagnostics, never an assertion. + journalctl --user -u "$1" --no-pager -n 40 2>&1 || true +} + +systemctl --user --failed --no-legend --plain 2>/dev/null | awk '{print $1}' | sort -u \ + | while read -r raw; do + [[ -n $raw ]] || continue + # The target first -- that is the message. But when the NOTIFIER instance + # is itself what failed, its own journal is the only one that says why, + # and folding it away left a block that explained nothing. + # Strip ONLY when the prefix is really there: `${raw%.service}` applied + # unconditionally turns a plain `a.service` into `a` and dumps it twice. + if [[ $raw == macarchy-failed@* ]]; then + target=${raw#macarchy-failed@}; target=${target%.service} + else + target=$raw + fi + dump "$target" + # An `if`, not `[[ … ]] && dump`: as the LAST statement of the loop body + # that compound returns 1 whenever the condition is false, which becomes + # the pipeline's status and then the script's. This is diagnostics; it + # must never be the reason a run goes red. + if [[ $raw != "$target" ]]; then + dump "$raw" + fi + done + +exit 0 diff --git a/tests/test_clean_machine_journal.sh b/tests/test_clean_machine_journal.sh new file mode 100755 index 0000000..219787b --- /dev/null +++ b/tests/test_clean_machine_journal.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# tests/test_clean_machine_journal.sh — the failed-unit journal dump. +# +# clean-machine.sh reports THAT user units failed and never why: it is install.sh +# that runs doctor.sh, so its MISS reds the run from inside, and the journals that +# would explain it are never read. Three units sat unexplained on #9 for exactly +# that reason. +# +# The seam is tests/dump-failed-units.sh's stdout, NOT clean-machine.sh's: that +# script sudo-writes into /etc and /usr/local/bin and enables linger, so it can +# never run in a hermetic suite. The dump lives in its own script so it CAN. +set -uo pipefail +cd "$(dirname "$0")/.." + +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT +mkdir -p "$TMP/bin"; export PATH="$TMP/bin:$PATH" + +fails=0 +check() { local name=$1; shift; if "$@"; then echo "ok $name"; else echo "FAIL $name"; fails=$((fails+1)); fi; } + +# systemctl replays whatever the case wrote; journalctl echoes which unit it was asked for. +cat > "$TMP/bin/systemctl" <<'STUB' +#!/bin/bash +[[ $* == *--failed* ]] || exit 1 +cat "$TMP_FAILED" +STUB +cat > "$TMP/bin/journalctl" <<'STUB' +#!/bin/bash +for a in "$@"; do [[ $prev == -u ]] && echo "MARKER body of $a"; prev=$a; done +STUB +chmod +x "$TMP/bin"/* +export TMP_FAILED="$TMP/failed" + +# --- (a) one failed unit: its name AND its journal reach stdout --------------- +echo 'macarchy-auto-appearance.service loaded failed failed Some daemon' > "$TMP_FAILED" +out=$(./tests/dump-failed-units.sh 2>&1) +check "(a) names the unit" grep -q 'macarchy-auto-appearance.service' <<<"$out" +check "(a) carries its journal" grep -q 'MARKER body of macarchy-auto-appearance.service' <<<"$out" + +# --- (b) nothing failed: no dump at all --------------------------------------- +: > "$TMP_FAILED" +out=$(./tests/dump-failed-units.sh 2>&1) +check "(b) silence when nothing failed" [ -z "$out" ] + +# --- (c) the notifier instance folds onto its target, like doctor.sh:63 ------- +# macarchy-failed@macarchy-touchbar.service.service is the NOTIFIER for +# macarchy-touchbar.service. Dumping the notifier's own journal says nothing; +# doctor.sh already folds the pair into one name and so must this. +echo 'macarchy-failed@macarchy-touchbar.service.service loaded failed failed Notifier' > "$TMP_FAILED" +out=$(./tests/dump-failed-units.sh 2>&1) +check "(c) folds onto the target" grep -q 'MARKER body of macarchy-touchbar.service' <<<"$out" +# ...and the notifier's OWN journal too: when the instance is what failed, that +# is the only place saying why. The target still comes first -- it is the message. +check "(c) dumps the notifier as well" grep -q 'MARKER body of macarchy-failed@macarchy-touchbar.service.service' <<<"$out" +check "(c) target before notifier" bash -c '[ "$(grep -n "MARKER body of macarchy-touchbar.service" <<<"$1" | cut -d: -f1)" -lt "$(grep -n "MARKER body of macarchy-failed@" <<<"$1" | cut -d: -f1)" ]' _ "$out" + +# --- (d) several failed units: one block each --------------------------------- +printf '%s\n' 'a.service loaded failed failed A' 'b.service loaded failed failed B' > "$TMP_FAILED" +out=$(./tests/dump-failed-units.sh 2>&1) +check "(d) one block per unit" [ "$(grep -c 'MARKER body of' <<<"$out")" = 2 ] + +# --- (e) a unit with no journal must not kill the run ------------------------- +echo 'c.service loaded failed failed C' > "$TMP_FAILED" +printf '#!/bin/bash\nexit 1\n' > "$TMP/bin/journalctl" # journalctl fails outright +./tests/dump-failed-units.sh >/dev/null 2>&1; rc=$? +# `$?` must be captured FIRST: inside `check "..." [ "$?" = 0 ]` it expands while +# the argument list is built, so the assertion would silently retarget to +# whatever command happened to run just before it. +check "(e) survives a journalctl failure" [ "$rc" = 0 ] + +(( fails == 0 )) && echo "all ok" || echo "$fails failed" +exit $(( fails > 0 )) diff --git a/tests/test_doctor_noop_signals.sh b/tests/test_doctor_noop_signals.sh new file mode 100755 index 0000000..3a4ba6b --- /dev/null +++ b/tests/test_doctor_noop_signals.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# tests/test_doctor_noop_signals.sh — the checks that replaced a failed unit. +# +# macarchy-core#12 made macarchy-auto-appearance and macarchy-bar-contrast exit 0 +# when they deliberately do nothing, so a oneshot no longer parks itself in +# `systemctl --user --failed` for ever. That was also the only signal a +# MISCONFIGURED laptop had, so the doctor has to ask the two tools directly -- +# and a check that can only ever say "ok" would be worse than the unit it +# replaced. These cases exist to prove each one can still say MISS. +set -uo pipefail +cd "$(dirname "$0")/.." + +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT +export HOME="$TMP/home" XDG_STATE_HOME="$TMP/state" +mkdir -p "$HOME" "$TMP/bin"; export PATH="$TMP/bin:$PATH" + +fails=0 +check() { local name=$1; shift; if "$@"; then echo "ok $name"; else echo "FAIL $name"; fails=$((fails+1)); fi; } + +for stub in pgrep omarchy-aquarium-toggle systemctl journalctl; do + printf '#!/bin/bash\nexit 1\n' > "$TMP/bin/$stub" +done +printf '#!/bin/bash\nexit 0\n' > "$TMP/bin/omarchy" +printf '#!/bin/bash\ncat "$AA_STATUS"\n' > "$TMP/bin/macarchy-auto-appearance" +printf '#!/bin/bash\ncat "$BC_STATUS"\n' > "$TMP/bin/macarchy-bar-contrast" +chmod +x "$TMP/bin"/* +export AA_STATUS="$TMP/aa" BC_STATUS="$TMP/bc" +echo 'sample=#003575 verdict=light' > "$BC_STATUS" + +# --- auto-appearance: on but sunless is a MISS ------------------------------ +echo 'mode=solar enabled=yes error=sun' > "$AA_STATUS" +out=$(WAYLAND_DISPLAY= ./doctor.sh 2>&1) +check "sunless-but-enabled is a MISS" grep -q 'MISS.*cannot compute the sun' <<<"$out" +check "and it says how to fix it" grep -q 'set coordinates' <<<"$out" + +# --- auto-appearance: a normal answer is ok --------------------------------- +echo 'mode=solar enabled=yes want=dark sunrise=06:57 sunset=20:15' > "$AA_STATUS" +out=$(WAYLAND_DISPLAY= ./doctor.sh 2>&1) +check "a working sun is ok" grep -q 'ok.*auto-appearance can decide' <<<"$out" +check "and not also a MISS" bash -c '! grep -q "cannot compute the sun" <<<"$1"' _ "$out" + +# --- auto-appearance: disabled is not a MISS -------------------------------- +# error=sun with the timer OFF is nobody's problem: the user turned it off. +echo 'mode=solar enabled=no error=sun' > "$AA_STATUS" +out=$(WAYLAND_DISPLAY= ./doctor.sh 2>&1) +check "sunless but switched off is not a MISS" bash -c '! grep -q "cannot compute the sun" <<<"$1"' _ "$out" + +# --- no omarchy: skipped with a reason, never a silent pass ----------------- +# Removing the stub is not enough: the developer running this suite HAS a real +# omarchy on PATH, and `command -v` would find it whatever $TMP/bin holds. So +# build a PATH with every directory containing an omarchy dropped -- on a CI +# runner that is a no-op, on a laptop it is the whole point of the case. +without_omarchy() { + local d out= dirs + IFS=: read -ra dirs <<<"$PATH" + for d in "${dirs[@]}"; do + [[ -x $d/omarchy ]] && continue + out="${out:+$out:}$d" + done + printf '%s' "$out" +} +echo 'mode=solar enabled=yes error=sun' > "$AA_STATUS" +rm -f "$TMP/bin/omarchy" +out=$(PATH="$(without_omarchy)" WAYLAND_DISPLAY= ./doctor.sh 2>&1) +check "no omarchy skips, not passes" grep -q 'skip.*auto-appearance sun' <<<"$out" +check "and names why" grep -q 'no omarchy on this machine' <<<"$out" +check "and does not MISS" bash -c '! grep -q "cannot compute the sun" <<<"$1"' _ "$out" +printf '#!/bin/bash\nexit 0\n' > "$TMP/bin/omarchy"; chmod +x "$TMP/bin/omarchy" + +# --- bar-contrast: nothing to sample, inside a session, is a MISS ----------- +echo 'macarchy-bar-contrast: grim missing; nothing to sample' > "$BC_STATUS" +out=$(WAYLAND_DISPLAY=wayland-0 ./doctor.sh 2>&1) +check "nothing to sample is a MISS" grep -q 'MISS.*bar-contrast has nothing to sample' <<<"$out" +check "and quotes the reason" grep -q 'grim missing' <<<"$out" + +# --- bar-contrast: no session, no claim ------------------------------------- +out=$(WAYLAND_DISPLAY= ./doctor.sh 2>&1) +check "no session: bar-contrast unasked" bash -c '! grep -q "bar-contrast" <<<"$1"' _ "$out" + +(( fails == 0 )) && echo "all ok" || echo "$fails failed" +exit $(( fails > 0 ))