Files
Panama/tests/quickshell/settings-hyprland-write-contract.sh
T
Gabriel Brown 1f62256024 Make a fresh install actually produce a working desktop
Two things stood between this repository and a machine that could
install it.

The installer aborted on its own first question. The hostname prompt
defaults to N, and the N branch ran `exit` -- so pressing Enter, the
obvious answer when you do not want to rename your machine, skipped the
entire installation and said nothing about it. Declining now just
declines. The installer is also safe to re-run, which is the upgrade
path too: it reports which stages failed instead of scrolling the
failure past twenty minutes ago, and restores the idle settings on every
exit path rather than only on success.

The package lists had drifted badly from what the configs and helpers
actually use. jq alone has thirty-one call sites across the helpers and
the contracts; kitty has a full shipped config and a dock pin; tmux and
btop have shipped themes the colour scheme switches; ddcutil, qrencode
and orca back features added today. None were declared. Neither were
fontconfig, pciutils, libselinux-utils, libnotify, wireplumber, fwupd or
python3-dnf, all of which shipped scripts invoke by name. A fresh
machine following this repository's own instructions would have got a
desktop whose features quietly were not there -- the helpers report "not
installed" rather than crashing, which is good behaviour and completely
silent.

So the lists are corrected and a contract now checks that every external
command Panama's scripts invoke is installed by Panama's packages.

Writing it was instructive about its own blind spots. The first version
reported `then`, `esac` and `done` as missing packages, burying the real
findings. The second passed while jq was undeclared, because the pattern
required three characters and jq is two -- a dependency checker with a
blind spot for short names is worse than none, since it reports PASS.
The third missed ddcutil, which is only ever invoked as `timeout 10
ddcutil` and so never appears statement-initial. It now also reads
`command -v X`, which is how these helpers probe for a tool and
therefore the clearest statement of a dependency there is. Verified it
catches jq, ddcutil and qrencode individually.

Also replaced a fixed 0.3s sleep in the write contract with a bounded
wait. It was failing about one run in three with "a rejected value did
not surface an error" when the error had simply not arrived yet, which
reads as a missing guard rather than a slow one.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 11:29:52 -04:00

232 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# Panama Settings must actually change the compositor, not merely believe it did.
#
# The pre-existing settings-system-contract.sh applies the values the compositor
# already holds and asserts they are unchanged, so it passes whether the write
# works or does nothing at all. That is how `hyprctl keyword` silently failing --
# it prints "keyword can't work with non-legacy parsers" to stdout and exits 0 --
# went unnoticed on this Lua-configured Hyprland.
#
# This contract flips each policy to a value it does not currently hold, reads it
# back from the compositor, and restores the original. A write path that no-ops
# cannot pass it.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/settings-system-harness.qml"
# A verified write commits to preferences, and preferences live at
# $XDG_CONFIG_HOME/panama/settings.json. Without an isolated config home this
# contract would persist its deliberately-wrong test values into the user's real
# store, where the next `hyprctl reload` would faithfully apply them. The
# compositor is still the live one -- that is the point of the contract -- and
# the EXIT trap restores it.
config_home="$(mktemp -d /tmp/panama-write-config.XXXXXX)"
fail() {
printf 'settings hyprland write contract: %s\n' "$1" >&2
exit 1
}
qs_for_harness() {
XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"
}
read_option() {
hyprctl -j getoption "$1" | jq -r .int
}
# Every value this contract touches is captured up front, before anything is
# changed. Capturing later risks recording a value an earlier failed run left
# behind and then "restoring" the daily-driver desktop to it.
original_auto_hdr="$(read_option render:cm_auto_hdr)"
original_vrr="$(read_option misc:vrr)"
original_direct="$(read_option render:direct_scanout)"
original_rounding="$(read_option decoration:rounding)"
original_gaps="$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')"
original_blur="$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)"
original_opacity="$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float)"
original_layout="$(hyprctl -j getoption input:kb_layout | jq -r .str)"
restore() {
# Restore through hyprctl rather than the harness: if the harness write path
# is the thing that is broken, the daily-driver desktop must still come back.
# Unconditional and idempotent, so it is safe on both the pass and fail path.
hyprctl eval "hl.config({
render = { cm_auto_hdr = $original_auto_hdr, direct_scanout = $original_direct },
misc = { vrr = $original_vrr },
general = { gaps_out = $original_gaps },
input = { kb_layout = \"$original_layout\" },
decoration = {
rounding = $original_rounding,
inactive_opacity = $original_opacity,
blur = { enabled = $original_blur }
}
})" >/dev/null 2>&1 || true
qs_for_harness kill >/dev/null 2>&1 || true
rm -rf "$config_home"
}
trap restore EXIT
# Pick a target each policy does not currently hold, staying inside the values
# SystemSettings allow-lists (VRR 0|3, direct scanout 0|2).
target_auto_hdr=$([[ "$original_auto_hdr" == 1 ]] && printf false || printf true)
target_auto_hdr_int=$([[ "$target_auto_hdr" == true ]] && printf 1 || printf 0)
target_vrr=$([[ "$original_vrr" == 3 ]] && printf 0 || printf 3)
target_direct=$([[ "$original_direct" == 2 ]] && printf 0 || printf 2)
# settings-commit-reset-contract drives the SAME harness file with
# PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1, where the compositor write seam is
# stubbed out. Quickshell identifies an instance by its config path, so if that
# run's instance has not fully exited, the `ipc show` wait below is satisfied by
# ITS target -- and every write in this contract lands on the isolated instance
# and never reaches the compositor. That is exactly what "a typed batch did not
# reach the compositor" looks like when this fails in a full suite run but
# passes on its own.
#
# So wait for the harness to be clear first, and say so plainly if it is not,
# rather than silently talking to the wrong shell.
harness_instances() {
# rg -c prints nothing at all when there are no matches, so an unguarded
# substitution yields "" rather than "0" and every comparison against a
# count fails.
local count
count="$(qs list 2>/dev/null | rg -c "^ Config path: $harness\$" || true)"
printf '%s' "${count:-0}"
}
for _ in $(seq 1 50); do
[[ "$(harness_instances)" == "0" ]] && break
sleep 0.1
done
[[ "$(harness_instances)" == "0" ]] \
|| fail 'another instance of the settings harness is still running -- this contract would talk to it instead of its own, and its writes may be deliberately stubbed'
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do
if qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$'; then
break
fi
sleep 0.1
done
qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$' || fail 'test IPC target did not start'
# ── The write must reach the compositor ──────────────────────────────────────
qs_for_harness ipc call settings-system-test apply "$target_auto_hdr" "$target_vrr" "$target_direct" >/dev/null
applied=false
for _ in $(seq 1 40); do
if [[ "$(read_option render:cm_auto_hdr)" == "$target_auto_hdr_int" \
&& "$(read_option misc:vrr)" == "$target_vrr" \
&& "$(read_option render:direct_scanout)" == "$target_direct" ]]; then
applied=true
break
fi
sleep 0.1
done
[[ "$applied" == true ]] || fail "policy write did not reach the compositor: \
cm_auto_hdr=$(read_option render:cm_auto_hdr) (want $target_auto_hdr_int), \
vrr=$(read_option misc:vrr) (want $target_vrr), \
direct_scanout=$(read_option render:direct_scanout) (want $target_direct)"
# ── A successful write must not report an error ──────────────────────────────
last_error="$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)"
[[ -z "$last_error" ]] || fail "a successful write reported an error: $last_error"
# ── A rejected value must be refused, not silently accepted ──────────────────
qs_for_harness ipc call settings-system-test apply "$target_auto_hdr" 7 "$target_direct" >/dev/null
# The refusal is reported asynchronously, so wait for it rather than sleeping a
# fixed 0.3s and hoping. That sleep made this fail roughly one run in three,
# reporting "a rejected value did not surface an error" when the error simply
# had not arrived yet -- which reads as a missing guard rather than a slow one.
rejected=""
for _ in $(seq 1 60); do
rejected="$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)"
[[ -n "$rejected" ]] && break
sleep 0.1
done
[[ "$(read_option misc:vrr)" == "$target_vrr" ]] || fail 'an out-of-allow-list VRR value reached the compositor'
[[ -n "$rejected" ]] || fail 'a rejected VRR value did not surface an error'
# ── Every getoption answer shape must be handled, not just integers ──────────
# The compositor reports each option in a different JSON field depending on its
# type, and gaps come back as a four-value box. A verifier that only understood
# "int" would report every other type as rejected.
qs_for_harness ipc call settings-system-test applyJson \
'{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null
# 10 seconds, not 4. The write path verifies each option by reading it back off
# the compositor and retries a refused batch, so a busy machine legitimately
# takes longer than a quick apply -- and this contract runs in a suite alongside
# other tests driving the same compositor. Failing at 4 seconds reported a
# product bug ("did not reach the compositor") for what was queueing.
# Re-issued periodically, because this contract and the LIVE shell both write to
# the same compositor. When the running Panama re-applies its own preferences --
# which it does on any store change -- it overwrites the values this test just
# set, and the read-back below then sees Panama's shipped defaults with the
# writer reporting no error at all. That combination is the signature: a
# rejected write leaves an error, a clobbered one does not.
typed=false
for attempt in $(seq 1 100); do
if (( attempt % 30 == 0 )); then
qs_for_harness ipc call settings-system-test applyJson \
'{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null
fi
if [[ "$(read_option decoration:rounding)" == "7" \
&& "$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')" == "23" \
&& "$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)" == "false" \
&& "$(hyprctl -j getoption decoration:inactive_opacity | jq -r '.float | (.*100|round)')" == "85" ]]; then
typed=true
break
fi
sleep 0.1
done
if [[ "$typed" != true ]]; then
# Report what the writer thinks as well as what the compositor holds. Those
# two disagreeing is a rejected write; both showing defaults is a write that
# never happened, and the messages should not look identical.
fail "a typed batch did not reach the compositor: rounding=$(read_option decoration:rounding), \
gaps=$(hyprctl -j getoption general:gaps_out | jq -r .css), \
blur=$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool), \
opacity=$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float), \
writer-reported error=\"$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)\""
fi
# Verification must recognise those shapes as success, not report them rejected.
last_error="$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)"
if [[ -n "$last_error" ]]; then
fail "a verified typed write was reported as failed: $last_error"
fi
# ── A string that violates its schema pattern must never reach hl.config ──────
[[ "$(qs_for_harness ipc call settings-system-test applyJson '{"keyboardLayout": "us\"; os.execute(\"touch /tmp/panama-pwned\")--"}')" == "false" ]] \
|| fail 'a keyboard layout violating the schema pattern was accepted'
[[ ! -e /tmp/panama-pwned ]] || fail 'a settings value was executed as Lua'
[[ "$(hyprctl -j getoption input:kb_layout | jq -r .str)" == "$original_layout" ]] \
|| fail 'the keyboard layout changed despite a rejected value'
# ── Restoring through the real path must also work ───────────────────────────
qs_for_harness ipc call settings-system-test apply \
"$([[ "$original_auto_hdr" == 1 ]] && printf true || printf false)" \
"$original_vrr" "$original_direct" >/dev/null
restored=false
for _ in $(seq 1 40); do
if [[ "$(read_option render:cm_auto_hdr)" == "$original_auto_hdr" \
&& "$(read_option misc:vrr)" == "$original_vrr" \
&& "$(read_option render:direct_scanout)" == "$original_direct" ]]; then
restored=true
break
fi
sleep 0.1
done
[[ "$restored" == true ]] || fail 'the original policy values could not be restored through SystemSettings'
trap - EXIT
restore
printf 'settings hyprland write contract: PASS\n'