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
340 lines
13 KiB
Bash
Executable File
340 lines
13 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
set -euo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
|
|
fail() {
|
|
printf 'settings pages contract: %s\n' "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
pages=(Home Displays Connectivity Sound Notifications ScreenIntelligence Health About)
|
|
for page in "${pages[@]}"; do
|
|
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
|
|
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
|
|
|
|
root_type="$(awk '
|
|
/^import / { next }
|
|
/^[[:space:]]*\/\// { next }
|
|
/^[[:space:]]*$/ { next }
|
|
match($0, /^[[:space:]]*([A-Za-z][A-Za-z0-9]*)[[:space:]]*\{/, found) {
|
|
print found[1]
|
|
exit
|
|
}
|
|
' "$page_file")"
|
|
[[ "$root_type" == "SettingsPage" ]] \
|
|
|| fail "${page}Page.qml root is ${root_type:-unknown}, expected SettingsPage"
|
|
! rg -q '^[[:space:]]*Flickable[[:space:]]*\{' "$page_file" \
|
|
|| fail "${page}Page.qml still copies the page Flickable scaffold"
|
|
done
|
|
|
|
require_row() {
|
|
local file="$1"
|
|
local row_type="$2"
|
|
local setting="$3"
|
|
|
|
python3 - "$file" "$row_type" "$setting" <<'PY' || \
|
|
fail "$(basename "$file") is missing $row_type for $setting"
|
|
import re
|
|
import sys
|
|
|
|
text = open(sys.argv[1], encoding="utf-8").read()
|
|
row_type = re.escape(sys.argv[2])
|
|
setting = re.escape(sys.argv[3])
|
|
pattern = rf"{row_type}\s*\{{(?:(?!\n\s*[A-Z][A-Za-z0-9]*\s*\{{).)*?setting\s*:\s*\"{setting}\""
|
|
raise SystemExit(0 if re.search(pattern, text, re.S) else 1)
|
|
PY
|
|
}
|
|
|
|
home_page="$repo_dir/config/dot/quickshell/modules/settings/HomePage.qml"
|
|
require_row "$home_page" ChoiceRow temperatureUnit
|
|
require_row "$home_page" SliderRow weatherRefreshMinutes
|
|
# vitalsIntervalMs moved to Appearance, beside the toggles it governs. It sat
|
|
# on Home while showCpu/showMemory/showGpu sat on Appearance -- one concept
|
|
# across two pages, which the ownership rule forbids and which made a search
|
|
# for it open a page that did not contain it.
|
|
appearance_page="$repo_dir/config/dot/quickshell/modules/settings/AppearancePage.qml"
|
|
require_row "$appearance_page" SliderRow vitalsIntervalMs
|
|
|
|
notifications_page="$repo_dir/config/dot/quickshell/modules/settings/NotificationsPage.qml"
|
|
for setting in notificationTimeoutMs notificationTimeoutCriticalMs notificationHistoryLimit maxVisibleToasts; do
|
|
require_row "$notifications_page" SliderRow "$setting"
|
|
done
|
|
python3 - "$notifications_page" <<'PY' || fail 'critical notification timeout does not render zero as Never'
|
|
import re
|
|
import sys
|
|
|
|
text = open(sys.argv[1], encoding="utf-8").read()
|
|
block = re.search(
|
|
r'SliderRow\s*\{(?:(?!\n\s*[A-Z][A-Za-z0-9]*\s*\{).)*?'
|
|
r'setting\s*:\s*"notificationTimeoutCriticalMs"(?P<tail>.*?)\n\s*\}',
|
|
text,
|
|
re.S,
|
|
)
|
|
raise SystemExit(0 if block and re.search(r'zeroLabel\s*:\s*"Never"', block.group(0)) else 1)
|
|
PY
|
|
|
|
intelligence_page="$repo_dir/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml"
|
|
require_row "$intelligence_page" ChoiceRow screenshotDir
|
|
require_row "$intelligence_page" ChoiceRow recordingDir
|
|
require_row "$intelligence_page" ChoiceRow recorderArgs
|
|
|
|
# The source-only contract is safe during a shared Quickshell quiet window.
|
|
# The existing compositor integration checks remain available explicitly.
|
|
if [[ "${PANAMA_SETTINGS_STATIC_ONLY:-0}" == "1" ]]; then
|
|
printf 'settings pages contract: PASS (static)\n'
|
|
exit 0
|
|
fi
|
|
|
|
state_home="$(mktemp -d /tmp/panama-settings-pages-state.XXXXXX)"
|
|
source_config_path="$repo_dir/config/dot/quickshell"
|
|
config_path="$state_home/quickshell"
|
|
harness="$config_path/settings-pages-harness.qml"
|
|
test_bin="$state_home/bin"
|
|
shell_log="$state_home/quickshell.log"
|
|
production_config_path="$HOME/.config/quickshell/shell.qml"
|
|
harness_pid=""
|
|
harness_shell_id=""
|
|
production_before=""
|
|
|
|
cleanup_bootstrap() {
|
|
rm -rf "$state_home"
|
|
}
|
|
trap cleanup_bootstrap EXIT
|
|
|
|
mkdir -p "$test_bin"
|
|
cp -a "$source_config_path" "$config_path"
|
|
python3 - "$config_path/shell.qml" "$harness" "$$" <<'PY'
|
|
import sys
|
|
|
|
source_path, harness_path, identity = sys.argv[1:]
|
|
source = open(source_path, encoding="utf-8").read()
|
|
needle = "ShellRoot {\n"
|
|
replacement = (
|
|
needle
|
|
+ f' readonly property string settingsPagesHarnessIdentity: "settings-pages-contract-{identity}"\n'
|
|
)
|
|
if source.count(needle) != 1:
|
|
raise SystemExit("shell.qml does not have exactly one ShellRoot")
|
|
with open(harness_path, "w", encoding="utf-8") as handle:
|
|
handle.write(source.replace(needle, replacement, 1))
|
|
PY
|
|
|
|
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
case "${1:-}" in
|
|
catalog)
|
|
printf '%s\n' '{"ok":false,"error":"test-helper"}'
|
|
;;
|
|
toggle|brightness)
|
|
printf '%s\n' '{"ok":true}'
|
|
;;
|
|
esac
|
|
EOF
|
|
chmod +x "$config_path/scripts/panama-home-assistant"
|
|
|
|
cat >"$test_bin/hyprctl" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then
|
|
printf '%s\n' '[{"focused":true,"name":"TEST-1","description":"Settings contract","width":1920,"height":1080,"refreshRate":60,"scale":1,"currentFormat":"XRGB8888","colorManagementPreset":"srgb","vrr":false}]'
|
|
exit 0
|
|
fi
|
|
if [[ "${1:-}" == "keyword" ]]; then
|
|
exit 0
|
|
fi
|
|
exec /usr/sbin/hyprctl "$@"
|
|
EOF
|
|
chmod +x "$test_bin/hyprctl"
|
|
|
|
cat >"$test_bin/flatpak" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
if [[ "${1:-}" == "info" ]]; then
|
|
exit 1
|
|
fi
|
|
exit 97
|
|
EOF
|
|
chmod +x "$test_bin/flatpak"
|
|
|
|
qs_for_test() {
|
|
if [[ "${1:-}" == "ipc" && "$harness_pid" =~ ^[0-9]+$ ]]; then
|
|
PATH="$test_bin:$PATH" XDG_STATE_HOME="$state_home" \
|
|
qs -p "$harness" ipc --pid "$harness_pid" "${@:2}"
|
|
else
|
|
PATH="$test_bin:$PATH" XDG_STATE_HOME="$state_home" \
|
|
qs -p "$harness" "$@"
|
|
fi
|
|
}
|
|
|
|
instances_for_path() {
|
|
local expected_path="$1" listing
|
|
|
|
listing="$(qs list --all 2>/dev/null)" || return 1
|
|
awk -v expected="$expected_path" '
|
|
/^Instance / { pid = ""; shell_id = "" }
|
|
/^[[:space:]]*Process ID:/ { pid = $3 }
|
|
/^[[:space:]]*Shell ID:/ { shell_id = $3 }
|
|
/^[[:space:]]*Config path:/ {
|
|
path = $0
|
|
sub(/^[[:space:]]*Config path: /, "", path)
|
|
if (path == expected && pid ~ /^[0-9]+$/ && shell_id != "")
|
|
print pid "|" shell_id
|
|
}
|
|
' <<<"$listing"
|
|
}
|
|
|
|
harness_identity_matches() {
|
|
local current
|
|
|
|
[[ "$harness_pid" =~ ^[0-9]+$ && -n "$harness_shell_id" ]] || return 1
|
|
current="$(instances_for_path "$harness")" || return 1
|
|
grep -Fxq "$harness_pid|$harness_shell_id" <<<"$current"
|
|
}
|
|
|
|
production_is_preserved() {
|
|
local current record pid shell_id
|
|
|
|
current="$(instances_for_path "$production_config_path")" || return 1
|
|
while IFS='|' read -r pid shell_id; do
|
|
[[ -n "$pid" ]] || continue
|
|
kill -0 "$pid" >/dev/null 2>&1 || return 1
|
|
record="$pid|$shell_id"
|
|
grep -Fxq "$record" <<<"$current" || return 1
|
|
done <<<"$production_before"
|
|
}
|
|
|
|
stop_harness() {
|
|
local remaining
|
|
|
|
if harness_identity_matches; then
|
|
kill "$harness_pid" >/dev/null 2>&1 || true
|
|
for _ in $(seq 1 80); do
|
|
! kill -0 "$harness_pid" >/dev/null 2>&1 && break
|
|
sleep 0.05
|
|
done
|
|
if kill -0 "$harness_pid" >/dev/null 2>&1 && harness_identity_matches; then
|
|
kill -KILL "$harness_pid" >/dev/null 2>&1 || true
|
|
for _ in $(seq 1 20); do
|
|
! kill -0 "$harness_pid" >/dev/null 2>&1 && break
|
|
sleep 0.05
|
|
done
|
|
fi
|
|
fi
|
|
remaining="$(instances_for_path "$harness")" || return 1
|
|
harness_pid=""
|
|
harness_shell_id=""
|
|
[[ -z "$remaining" ]]
|
|
}
|
|
|
|
cleanup() {
|
|
local cleanup_ok=0
|
|
|
|
stop_harness || cleanup_ok=1
|
|
production_is_preserved || cleanup_ok=1
|
|
if (( cleanup_ok == 0 )); then
|
|
rm -rf "$state_home"
|
|
else
|
|
printf 'settings pages contract: isolated harness cleanup failed; retained %s\n' \
|
|
"$state_home" >&2
|
|
fi
|
|
return "$cleanup_ok"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
start_test_shell() {
|
|
local harness_instances production_pid production_shell_id
|
|
|
|
harness_instances="$(instances_for_path "$harness")" \
|
|
|| fail 'could not inspect Quickshell instances before starting the runtime harness'
|
|
[[ -z "$harness_instances" ]] \
|
|
|| fail 'an unexpected process already uses the runtime harness path'
|
|
for _attempt in 1 2; do
|
|
qs_for_test --daemonize >"$shell_log" 2>&1
|
|
for _ in $(seq 1 80); do
|
|
harness_instances="$(instances_for_path "$harness")" \
|
|
|| fail 'could not inspect the runtime harness instance'
|
|
[[ -n "$harness_instances" ]] && break
|
|
sleep 0.05
|
|
done
|
|
if [[ "$(wc -l <<<"$harness_instances")" == 1 && -n "$harness_instances" ]]; then
|
|
IFS='|' read -r harness_pid harness_shell_id <<<"$harness_instances"
|
|
[[ "$harness_pid" =~ ^[0-9]+$ ]] \
|
|
|| fail 'runtime harness did not expose a numeric PID'
|
|
|
|
while IFS='|' read -r production_pid production_shell_id; do
|
|
[[ -n "$production_pid" ]] || continue
|
|
[[ "$harness_shell_id" != "$production_shell_id" ]] || {
|
|
stop_harness
|
|
fail 'runtime harness shares a Shell ID with production'
|
|
}
|
|
done <<<"$production_before"
|
|
production_is_preserved || {
|
|
stop_harness
|
|
fail 'production changed before isolated page routing began'
|
|
}
|
|
|
|
for _ in $(seq 1 80); do
|
|
if qs_for_test ipc show 2>/dev/null | rg '^target settings$' >/dev/null; then
|
|
return
|
|
fi
|
|
sleep 0.1
|
|
done
|
|
fi
|
|
stop_harness || fail 'failed runtime harness attempt did not stop cleanly'
|
|
done
|
|
sed -n '1,200p' "$shell_log" >&2
|
|
fail 'isolated branch shell did not start'
|
|
}
|
|
|
|
production_before="$(instances_for_path "$production_config_path")" \
|
|
|| fail 'could not list Quickshell instances for the production baseline'
|
|
production_is_preserved || fail 'could not capture a stable production instance set'
|
|
start_test_shell
|
|
qs_for_test ipc call home-assistant fixture ready >/dev/null
|
|
shell_pid="$harness_pid"
|
|
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
|
|
|
|
pages=(home appearance displays connectivity home-phone desktop sound notifications screen-intelligence shortcuts services about)
|
|
for page in "${pages[@]}"; do
|
|
qs_for_test ipc call settings page "$page" >/dev/null
|
|
for _ in $(seq 1 20); do
|
|
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "$page" ]] && break
|
|
sleep 0.1
|
|
done
|
|
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "$page" ]] || fail "$page did not route"
|
|
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
|
|
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null \
|
|
|| fail "$page created a missing, floating, or duplicate Settings window"
|
|
done
|
|
|
|
qs_for_test ipc call settings page '__unsupported__' >/dev/null
|
|
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "home" ]] || fail 'unsupported page did not fall back to Home'
|
|
|
|
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Settings" and .key == "I" and .modmask == 64)' >/dev/null \
|
|
|| fail 'Super+I is not registered as Panama Settings'
|
|
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \
|
|
|| fail 'Super+Shift+S is not registered as Screen Intelligence'
|
|
|
|
desktop_file="$HOME/.local/share/applications/panama-settings.desktop"
|
|
[[ -L "$desktop_file" || -f "$desktop_file" ]] || fail 'searchable desktop entry is not installed'
|
|
desktop-file-validate "$desktop_file" >/dev/null || fail 'desktop entry is invalid'
|
|
gnome_desktop_file="$HOME/.local/share/applications/gnome-settings-hyprland.desktop"
|
|
[[ -L "$gnome_desktop_file" || -f "$gnome_desktop_file" ]] || fail 'searchable GNOME Settings fallback is not installed'
|
|
desktop-file-validate "$gnome_desktop_file" >/dev/null || fail 'GNOME Settings fallback entry is invalid'
|
|
intelligence_desktop_file="$HOME/.local/share/applications/panama-screen-intelligence.desktop"
|
|
[[ -L "$intelligence_desktop_file" || -f "$intelligence_desktop_file" ]] || fail 'Screen Intelligence desktop entry is not installed'
|
|
desktop-file-validate "$intelligence_desktop_file" >/dev/null || fail 'Screen Intelligence desktop entry is invalid'
|
|
|
|
trap - EXIT
|
|
cleanup || fail 'runtime harness did not stop without disturbing production'
|
|
[[ ! -e "$state_home" ]] || fail 'temporary Settings state was not removed after shell exit'
|
|
production_pids="$(cut -d'|' -f1 <<<"$production_before" | paste -sd, -)"
|
|
[[ -n "$production_pids" ]] || production_pids="none"
|
|
printf 'settings pages contract: PASS (production PIDs preserved: %s)\n' "$production_pids"
|