Phase 6, the last of the fresh-install spec. 159 scripts lose their .sh: 110 contracts, 47 Vicinae commands, 2 compositor contracts. A shebang and the executable bit already select the interpreter. The extension only ever added something that had to stay in sync, and the rename proved the point twice over in the space of an hour. The spec's stated risk was Vicinae's script discovery. One script was renamed and reloaded on its own before the other 46 followed; it came back as scripts:panama.capture and all 47 resolve. What the probe turned up instead is that the extension was never only a filename: Vicinae's command IDs embed it, so every ID changed. Nothing in this repository refers to them, so nothing breaks. The only trace is Vicinae's metadata.json, whose visited map had two Panama entries that are now orphaned -- two commands lost their usage ranking and will earn it back. Worth knowing before anyone renames these again on a machine that has a keybind pointing at one. Rewriting the references by exact filename missed two things it structurally could not see: a name built from a variable, settings-$page.sh, and a glob, -name '*.sh'. Both were in the contract that counts the generated commands, which promptly reported 47 expected and 0 found. The mechanical part of a rename is the part that looks finished. The three subcommands. panama doctor fronts a health check that already existed and already ran at the end of every install but could not be reached from a terminal. panama upgrade re-runs the installer from anywhere. panama test runs the suite, which had no entry point at all -- 121 files that were the main safety net in this repository and were invisible in it. Writing that runner found three tests nothing was running. calendar_agenda_bridge_test, home_assistant_bridge_test and kdeconnect_bridge_test are unittest suites without the executable bit, so no contract invoked them and the first draft of the runner skipped them silently. All three pass, and have passed unobserved for weeks. The runner collects *_test.py as well now, because a runner with a blind spot is worse than no runner for the same reason a dependency checker with one is: it reports PASS. Six worktrees pruned. Each was re-checked rather than trusted to the spec's list, and two needed it: panama-commands is not on feat/panama-commands but on feat/gnome-tweaks-parity, and fix/panama-displays-review reads [ahead 3] -- ahead of its remote, not of main, with every commit patch-equivalent to landed work. roadmap-completion stays; it has five commits that are genuinely unlanded. The branches are left alone: pruning a worktree costs nothing, deleting a branch is a decision. 121 contracts pass. Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
249 lines
12 KiB
Bash
Executable File
249 lines
12 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 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
|
|
# Bounded waits are deliberately generous. Each of these polls until the
|
|
# compositor reflects a write, and four seconds was enough on an idle machine
|
|
# and not enough on a busy one -- this contract passed alone and failed in a
|
|
# full suite run, which is the worst way for a test to be wrong. A longer bound
|
|
# cannot mask a dead write, because a write that never lands never matches; it
|
|
# only stops a slow one from being reported as a broken one.
|
|
for _ in $(seq 1 150); 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 150); 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 150); 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 recognize 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 ───────────────────────────
|
|
# Issued up to three times rather than once. SystemSettings verifies each write
|
|
# before storing it and serializes overlapping ones, so a restore arriving while
|
|
# the previous batch is still settling can be dropped -- which showed up as this
|
|
# step failing roughly one run in three, on an idle machine, with a longer wait
|
|
# making no difference because there was nothing still in flight to wait for.
|
|
#
|
|
# Retrying cannot hide a broken write path: if the values never converge, no
|
|
# number of attempts makes them, and the failure below still fires.
|
|
restored=false
|
|
for attempt in 1 2 3; do
|
|
qs_for_harness ipc call settings-system-test apply \
|
|
"$([[ "$original_auto_hdr" == 1 ]] && printf true || printf false)" \
|
|
"$original_vrr" "$original_direct" >/dev/null 2>&1
|
|
|
|
for _ in $(seq 1 50); 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 ]] && break
|
|
done
|
|
[[ "$restored" == true ]] || fail 'the original policy values could not be restored through SystemSettings'
|
|
|
|
trap - EXIT
|
|
restore
|
|
printf 'settings hyprland write contract: PASS\n'
|