This contract failed intermittently with "a typed batch did not reach the compositor", reporting Panama's shipped defaults, and passed on retry. The earlier guard against a lingering harness was not the cause -- no instance was alive. Adding the writer's own error to the failure message settled it: the writer reports NO error while the compositor holds defaults. A rejected write leaves an error behind; a write that succeeded and was then overwritten does not. Both this contract and the LIVE shell write to the same compositor, and the running Panama re-applies its own preferences on any store change -- landing exactly the values that were being mistaken for "the write never happened". So the apply is re-issued periodically while waiting, which makes the test survive being overwritten without weakening what it asserts, and the failure message now distinguishes the two cases instead of describing both as a write that never arrived. The wait is also 10 seconds rather than 4. The write path verifies every option by reading it back and retries a refused batch, so a loaded machine legitimately takes longer -- and this runs in a suite alongside other tests driving the same compositor. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
222 lines
11 KiB
Bash
Executable File
222 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
|
|
sleep 0.3
|
|
[[ "$(read_option misc:vrr)" == "$target_vrr" ]] || fail 'an out-of-allow-list VRR value reached the compositor'
|
|
[[ -n "$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)" ]] \
|
|
|| 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'
|