Merge remote-tracking branch 'origin/main' into feat/panama-health

# Conflicts:
#	config/dot/quickshell/modules/settings/ServicesPage.qml
#	config/dot/quickshell/modules/settings/SettingsShell.qml
#	config/dot/quickshell/modules/settings/SettingsSidebar.qml
This commit is contained in:
Gabriel Brown
2026-08-18 11:23:07 -04:00
87 changed files with 4557 additions and 184 deletions
+34 -13
View File
@@ -34,15 +34,35 @@ mkdir -p "$fixture/drm" "$fixture/dev" "$fixture/bin" "$fixture/i2c"
# Two connectors with a monitor, two without. DP-2 answers DDC; DP-3 is
# connected but does not implement brightness. HDMI-A-1 and DP-1 are empty and
# must never be probed at all.
# A connector has an EDID bus (the `ddc` symlink) and, on DisplayPort, an AUX
# bus that appears as a child directory. DDC/CI rides the AUX channel where one
# exists, and the `ddc` line answers nothing on a DP connector even though it
# still resolves -- so the aux argument here is what a real DisplayPort monitor
# looks like, and omitting it is what HDMI and DVI look like.
make_connector() {
local name="$1" bus="$2" status="$3"
mkdir -p "$fixture/drm/$name"
printf '%s\n' "$status" >"$fixture/drm/$name/status"
mkdir -p "$fixture/i2c/i2c-$bus"
ln -sfn "$fixture/i2c/i2c-$bus" "$fixture/drm/$name/ddc"
local name="$1" ddc_bus="$2" status="$3" aux_bus="${4:-}"
local device="$fixture/devices/$name"
# /sys/class/drm/<connector> is a SYMLINK to the real device directory, and
# this fixture mirrors that rather than using a plain directory. It matters:
# `find` does not follow the path it is given, so code that searches the
# unresolved path finds nothing while appearing to work anywhere the entry
# happens to be a real directory.
mkdir -p "$device"
printf '%s\n' "$status" >"$device/status"
mkdir -p "$fixture/i2c/i2c-$ddc_bus"
ln -sfn "$fixture/i2c/i2c-$ddc_bus" "$device/ddc"
[[ -n "$aux_bus" ]] && mkdir -p "$device/i2c-$aux_bus"
mkdir -p "$fixture/drm"
ln -sfn "$device" "$fixture/drm/$name"
return 0
}
# DP-2 is the DisplayPort case: its EDID line is bus 5, which answers nothing,
# and its AUX child is bus 9, which does. Choosing bus 5 here finds no monitor
# at all, which is exactly the bug this pins down.
make_connector card1-DP-1 4 disconnected
make_connector card1-DP-2 5 connected
make_connector card1-DP-2 5 connected 9
make_connector card1-DP-3 6 connected
make_connector card1-HDMI-A-1 7 disconnected
@@ -75,7 +95,7 @@ for arg in "$@"; do
done
case "$bus" in
5) printf 'VCP 10 C 120 200\n'; exit 0 ;;
9) printf 'VCP 10 C 120 200\n'; exit 0 ;;
*) exit 1 ;;
esac
STUB
@@ -103,8 +123,9 @@ jq -e . >/dev/null 2>&1 <<<"$listing" || fail "list did not emit JSON: $listing"
[[ "$(jq -r '.displays[0].connector' <<<"$listing")" == "DP-2" ]] \
|| fail "the connector name must match Hyprland's output name: $listing"
[[ "$(jq -r '.displays[0].bus' <<<"$listing")" == "5" ]] \
|| fail "the display was mapped to the wrong I2C bus: $listing"
# The AUX bus, not the EDID bus its `ddc` symlink points at.
[[ "$(jq -r '.displays[0].bus' <<<"$listing")" == "9" ]] \
|| fail "the display was mapped to its EDID bus instead of its DisplayPort AUX bus, where nothing answers: $listing"
# 120 of a maximum of 200 is 60%.
[[ "$(jq -r '.displays[0].value' <<<"$listing")" == "60" ]] \
@@ -123,12 +144,12 @@ if grep -qxE '4|7' "$DDCUTIL_PROBE_LOG"; then
fi
# ── Writes scale to the reported maximum ─────────────────────────────────────
run_helper set 5 40
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 80" ]] \
run_helper set 9 40
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 9 80" ]] \
|| fail "set did not scale to the display's maximum: $(cat "$DDCUTIL_SET_LOG")"
run_helper set 5 500
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 200" ]] \
run_helper set 9 500
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 9 200" ]] \
|| fail "an out-of-range percent was not clamped: $(cat "$DDCUTIL_SET_LOG")"
# ── No I2C access explains itself ────────────────────────────────────────────
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Every enum backed by a Hyprland option must offer values that option accepts.
#
# This exists because of a bug that shipped: followMouse offered 0/1/2 labelled
# "Never" / "Click to focus" / "Sloppy focus", while Hyprland's actual mapping
# is disabled=0, follow=1, detached=2, separate=3. The desktop was labelled
# "Click to focus" and was in fact following the pointer, the way to GET click
# to focus was to choose "Never", and value 3 did not exist in the UI at all.
#
# Nothing detects that. The compositor accepts 1, reads back 1, and verification
# passes -- the value is valid, it just means something else entirely. The only
# authority on what each number MEANS is the compositor, which publishes it:
#
# hyprctl descriptions -> { "name": "input:follow_mouse",
# "map": [{"separate":3},{"detached":2},...] }
#
# So this checks the schema's enum values against that map, and against the
# min/max range for mapped options that have no named map.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
fail() {
printf 'enum hypr map contract: %s\n' "$1" >&2
exit 1
}
command -v hyprctl >/dev/null 2>&1 || { printf 'enum hypr map contract: SKIP (no compositor)\n'; exit 0; }
descriptions="$(hyprctl descriptions 2>/dev/null)" || fail 'could not read hyprctl descriptions'
jq -e 'type == "array" and length > 0' >/dev/null <<<"$descriptions" \
|| fail 'hyprctl descriptions did not return a list'
# Pull every enum entry that carries a hypr option, as: key<TAB>option<TAB>values
entries="$(python3 - "$schema" <<'PY'
import re, sys
text = open(sys.argv[1]).read()
# Each schema entry is a brace-delimited block starting with `key:`.
for block in re.findall(r'\{\s*\n?\s*key:\s*"([^"]+)"(.*?)\n \}', text, re.S):
name, body = block
if 'type: "enum"' not in body:
continue
option = re.search(r'option:\s*"([^"]+)"', body)
if not option:
continue
values = re.findall(r'value:\s*(-?\d+)', body)
if not values:
continue
print(f"{name}\t{option.group(1)}\t{','.join(values)}")
PY
)"
[[ -n "$entries" ]] || fail 'found no compositor-backed enums in the schema -- this contract is not reading it correctly'
checked=0
while IFS=$'\t' read -r key option values; do
[[ -n "$key" ]] || continue
entry="$(jq -c --arg name "$option" '.[] | select(.name == $name)' <<<"$descriptions")"
[[ -n "$entry" ]] || fail "$key maps to \"$option\", which the compositor does not publish"
map_values="$(jq -r 'if .map then (.map | map(to_entries[].value) | join(",")) else "" end' <<<"$entry")"
IFS=',' read -ra wanted <<<"$values"
for value in "${wanted[@]}"; do
if [[ -n "$map_values" ]]; then
grep -qx "$value" <<<"$(tr ',' '\n' <<<"$map_values")" \
|| fail "$key offers $value for $option, which the compositor's map does not contain (it publishes: $map_values). A value outside the map is accepted and read back unchanged, so nothing else notices -- it simply means something other than the label says."
else
min="$(jq -r '.min // empty' <<<"$entry")"
max="$(jq -r '.max // empty' <<<"$entry")"
if [[ -n "$min" && -n "$max" ]]; then
(( value >= min && value <= max )) \
|| fail "$key offers $value for $option, outside the compositor's range $min..$max"
fi
fi
done
# Every value the compositor names should be offered. A missing one is a
# capability the user simply cannot reach -- value 3 was missing here.
if [[ -n "$map_values" ]]; then
while read -r published; do
[[ -n "$published" ]] || continue
grep -qx "$published" <<<"$(tr ',' '\n' <<<"$values")" \
|| fail "$option publishes value $published but $key does not offer it, so that behaviour is unreachable from Settings"
done <<<"$(tr ',' '\n' <<<"$map_values")"
fi
checked=$((checked + 1))
done <<<"$entries"
printf 'enum hypr map contract: PASS (%d mapped enums)\n' "$checked"
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Every GTK theme name Panama sets must be a theme that is actually installed.
#
# This exists because of a bug that was invisible for weeks. ColorScheme set
# gtk-theme to "Adwaita-dark" for dark and "Adwaita" for light. Neither is
# installed on Fedora 44 -- only adw-gtk3 and adw-gtk3-dark are -- and GTK
# responds to an unknown theme name by silently falling back to its built-in
# default, which is LIGHT.
#
# So light mode appeared to work, dark mode produced light windows, and nothing
# anywhere reported an error. Applications that take their cue from the GTK
# theme rather than the portal -- Chromium and Electron among them -- were stuck
# light with no way to diagnose it from inside the application.
#
# The failure is silent by construction, so it needs a test rather than a
# comment. Checks the compositor-facing setting and the generated GTK config
# agree, and that both name something real.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
color_scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml"
theme_apps="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps"
fail() {
printf 'gtk theme contract: %s\n' "$1" >&2
exit 1
}
theme_installed() {
local name="$1" dir
for dir in /usr/share/themes "$HOME/.themes" "$HOME/.local/share/themes"; do
[[ -d "$dir/$name/gtk-3.0" ]] && return 0
done
return 1
}
# ── The names ColorScheme sets must exist ────────────────────────────────────
names="$(grep -oE 'root\.dark \? "[a-zA-Z0-9-]+" : "[a-zA-Z0-9-]+"' "$color_scheme" \
| grep -oE '"[a-zA-Z0-9-]+"' | tr -d '"' | grep -E '^adw-|^Adwaita' | sort -u)"
[[ -n "$names" ]] || fail 'could not find the GTK theme names in ColorScheme.qml -- this contract is not reading it correctly'
while read -r name; do
[[ -n "$name" ]] || continue
theme_installed "$name" \
|| fail "ColorScheme sets gtk-theme to \"$name\", which is not installed. GTK falls back to its light default when a theme is missing, so this produces light windows in dark mode with no error anywhere."
done <<<"$names"
# ── The generated GTK config must agree, in both directions ──────────────────
# Generated into a fixture rather than the live config, so running this cannot
# retheme the desktop it is running on.
fixture="$(mktemp -d /tmp/panama-gtk-theme.XXXXXX)"
trap 'rm -rf "$fixture"' EXIT
for version in 3.0 4.0; do
mkdir -p "$fixture/gtk-$version"
cp "$repo_dir/config/dot/gtk-$version/settings.ini.template" "$fixture/gtk-$version/" \
|| fail "gtk-$version has no settings.ini.template -- the generated file would never be produced"
done
for scheme in dark light; do
XDG_CONFIG_HOME="$fixture" "$theme_apps" "$scheme" >/dev/null 2>&1
for version in 3.0 4.0; do
generated="$fixture/gtk-$version/settings.ini"
[[ -r "$generated" ]] || fail "gtk-$version settings.ini was not generated for $scheme"
grep -q '@GTK_THEME@\|@PREFER_DARK@' "$generated" \
&& fail "gtk-$version settings.ini still contains an unsubstituted placeholder for $scheme"
theme="$(sed -n 's/^gtk-theme-name=//p' "$generated")"
prefer="$(sed -n 's/^gtk-application-prefer-dark-theme=//p' "$generated")"
theme_installed "$theme" \
|| fail "gtk-$version settings.ini names \"$theme\" for $scheme, which is not installed"
if [[ "$scheme" == "dark" ]]; then
[[ "$prefer" == "1" ]] || fail "gtk-$version asks for prefer-dark=$prefer in dark mode"
[[ "$theme" == *dark* ]] || fail "gtk-$version uses \"$theme\" in dark mode, which is not a dark theme"
else
[[ "$prefer" == "0" ]] || fail "gtk-$version asks for prefer-dark=$prefer in light mode"
[[ "$theme" != *dark* ]] || fail "gtk-$version uses \"$theme\" in light mode, which is a dark theme"
fi
done
done
printf 'gtk theme contract: PASS\n'
+14
View File
@@ -60,6 +60,14 @@ rg -Fq 'Health.refresh()' "$settings_dir/HealthPage.qml" \
|| fail 'opening System Health does not request a fresh scan'
rg -Fq 'SystemSettings.openGnomePanel("network")' "$settings_dir/HealthPage.qml" \
|| fail 'Fedora ownership boundary does not open GNOME Settings'
rg -Fq 'SystemSettings.openGnomePanel("system", "users")' "$settings_dir/HealthPage.qml" \
|| fail 'Fedora ownership boundary lost the Users handoff'
rg -Fq 'SystemSettings.openGnomePanel("sharing")' "$settings_dir/HealthPage.qml" \
|| fail 'Fedora ownership boundary lost the Sharing handoff'
rg -Fq 'SystemSettings.openGnomePanel("color")' "$settings_dir/HealthPage.qml" \
|| fail 'Fedora ownership boundary lost the Colour profiles handoff'
rg -Fq 'SystemSettings.openGnomePanel("wellbeing")' "$settings_dir/HealthPage.qml" \
|| fail 'Fedora ownership boundary lost the Digital wellbeing handoff'
rg -Fq 'Health.repair(check.id, false)' "$settings_dir/HealthPage.qml" \
|| fail 'Settings repair does not stay inline/non-external'
rg -Fq 'ShellState.openSettings(check.action.target)' "$settings_dir/HealthPage.qml" \
@@ -278,6 +286,12 @@ jq -e '
and (.renderedRows | map(.id) | length) == 6
and (.renderedRows | map(.id) | unique | length) == 6
and .emptyQuietGroups == ["desktop-foundation"]
and .fedoraHandoffs == [
{id:"users", label:"Users", action:"Open users"},
{id:"sharing", label:"Sharing", action:"Open sharing"},
{id:"color", label:"Colour profiles", action:"Open colour"},
{id:"wellbeing", label:"Digital wellbeing", action:"Open wellbeing"}
]
and .summaryHeight == 126
and (.rowHeights | length) == 6
and (.rowHeights | all(. >= 62))
@@ -288,18 +288,18 @@ shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')"
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
for _ in $(seq 1 40); do
if /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null; then
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null; then
break
fi
sleep 0.1
done
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null \
|| fail 'the branch shell did not own exactly one tiled Panama Settings client'
if [[ -n "${PANAMA_TEST_SCREENSHOT_PATH:-}" ]]; then
geometry="$(/usr/sbin/hyprctl -j clients | jq -r --argjson pid "$shell_pid" \
'.[] | select(.pid == $pid and .title == "Panama Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')"
'.[] | select(.pid == $pid and .title == "Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')"
[[ -n "$geometry" ]] || fail 'could not resolve the Settings client geometry'
grim -g "$geometry" "$PANAMA_TEST_SCREENSHOT_PATH"
fi
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# panama-keyring reports the login keyring's state, and the Settings page reads
# nothing but its JSON.
#
# The state that matters is LOCKED, and it is also the one that cannot be
# rehearsed on a real desktop: locking the login keyring breaks every saved
# password on the machine and can only be undone by typing the password into a
# dialog. So the secret service is stubbed here instead. Nothing touches the
# real keyring -- this contract is safe to run on the daily driver, which is the
# entire reason it is written this way.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-keyring"
fail() {
printf 'keyring helper contract: %s\n' "$1" >&2
exit 1
}
stub_dir="$(mktemp -d /tmp/panama-keyring.XXXXXX)"
trap 'rm -rf "$stub_dir"' EXIT
# A stand-in for the `gi` module the helper imports. PANAMA_KEYRING_FAKE decides
# what the fake service reports, so one stub covers every case.
mkdir -p "$stub_dir/gi/repository"
cat >"$stub_dir/gi/__init__.py" <<'STUB'
def require_version(*_args, **_kwargs):
return None
STUB
cat >"$stub_dir/gi/repository/__init__.py" <<'STUB'
import os
class _Collection:
def __init__(self, label, locked):
self._label = label
self._locked = locked
def get_label(self):
return self._label
def get_locked(self):
return self._locked
class _Service:
def get_collections(self):
mode = os.environ.get("PANAMA_KEYRING_FAKE", "unlocked")
if mode == "nologin":
return [_Collection("Some App", False)]
return [_Collection("Login", mode == "locked"), _Collection("", False)]
class _ServiceFactory:
@staticmethod
def get_sync(_flags, _cancellable):
if os.environ.get("PANAMA_KEYRING_FAKE") == "unavailable":
raise RuntimeError("no secret service")
return _Service()
# unlock_sync is what the `unlock` action calls; record that it was reached.
@staticmethod
def _noop(*_args, **_kwargs):
return None
class Secret:
class ServiceFlags:
LOAD_COLLECTIONS = 1
Service = _ServiceFactory
STUB
run() {
PYTHONPATH="$stub_dir" PANAMA_KEYRING_FAKE="$1" python3 "$helper" "${2:-status}"
}
# ── Unlocked: the normal state after any sign-in ─────────────────────────────
out="$(run unlocked)"
jq -e . >/dev/null 2>&1 <<<"$out" || fail "status did not emit JSON: $out"
jq -e '.available == true and .locked == false and .hasLogin == true' >/dev/null <<<"$out" \
|| fail "an unlocked login keyring was misreported: $out"
# ── Locked: the state the whole card exists for ──────────────────────────────
out="$(run locked)"
jq -e '.available == true and .locked == true' >/dev/null <<<"$out" \
|| fail "a locked login keyring was not reported as locked: $out"
# ── No secret service at all is a state, not a crash ─────────────────────────
out="$(run unavailable)"
jq -e . >/dev/null 2>&1 <<<"$out" \
|| fail "a missing secret service produced no JSON, so the page would show nothing: $out"
jq -e '.available == false and .error != ""' >/dev/null <<<"$out" \
|| fail "a missing secret service must be reported with a reason: $out"
# ── No login keyring: not locked, because there is nothing to lock ───────────
out="$(run nologin)"
jq -e '.available == true and .hasLogin == false and .locked == false' >/dev/null <<<"$out" \
|| fail "a machine with no login keyring must not report itself locked: $out"
# ── The daemon origin is reported, since it is the crash diagnostic ──────────
jq -e '.daemon | test("^(pam|dbus|none|unknown)$")' >/dev/null <<<"$(run unlocked)" \
|| fail "the daemon origin must be one of pam/dbus/none/unknown"
printf 'keyring helper contract: PASS\n'
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Versioned upgrades for settings.json.
#
# The schema says what a setting IS; it cannot say what a setting USED to be.
# Rename a key, change its units, or split one setting into two, and the stored
# value stops being recognised -- and unrecognised keys are deliberately carried
# through untouched, so the user's choice silently stops taking effect with
# nothing to explain it.
#
# The list of migrations is empty today, which is exactly why this is tested
# now: the first time it runs for real will be against somebody's actual
# settings during an upgrade, and that is a poor moment to discover how it
# behaves. The harness supplies fixture steps, including one that throws.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/migrations-harness.qml"
fail() {
printf 'migrations contract: %s\n' "$1" >&2
exit 1
}
[[ -r "$harness" ]] || fail "the harness is missing: $harness"
out="$(timeout 60 qs -p "$harness" 2>&1 | grep -o 'PANAMA-MIGRATIONS .*' | sed 's/^PANAMA-MIGRATIONS //')"
[[ -n "$out" ]] || fail 'the harness produced no result'
jq -e . >/dev/null 2>&1 <<<"$out" || fail "the harness did not emit JSON: $out"
check() {
jq -e "$1" >/dev/null <<<"$out" || fail "$2 -- got $(jq -c "$3" <<<"$out")"
}
# A file written before versioning existed is stamped, NOT migrated. Running
# the list against it would apply upgrades designed for schemas it never had.
check '.unversioned.v == 1 and .unversioned.migrated == false and .unversioned.untouched == true' \
'a file with no schemaVersion must be stamped at the baseline without being migrated' '.unversioned'
# The stamp has to reach disk. Reported as changed-but-not-migrated, it would
# otherwise live only in memory and be redone on every single launch.
check '.unversioned.changed == true' \
'stamping a pre-versioning file must be reported as a change so it gets written' '.unversioned'
# A file from the future must not be rewritten at all.
check '.future.changed == false' \
'a file from a newer version must not be written back' '.future'
# Every step above the stored version runs, in order.
check '.upgrade.v == 3 and .upgrade.b == 2 and .upgrade.c == "three" and .upgrade.count == 2' \
'an older file must run each pending step in order and end at the current version' '.upgrade'
# Already current: nothing runs, nothing is touched.
check '.current.migrated == false and .current.kept == true and .current.b == true' \
'a file already at the current version must be left alone' '.current'
# A file from a NEWER Panama is left completely alone. Downgrading keys is not
# something this can do correctly, and unknown keys are already preserved.
check '.future.v == 9 and .future.migrated == false and .future.kept == true' \
'a file from a newer version must not be modified or downgraded' '.future'
# A failing step stops at the last good version. Skipping past it would lose
# the conversion forever; failing the whole load would cost every setting.
check '.failure.v == 3 and .failure.count == 2 and .failure.kept == true' \
'a failing step must stop at the last good version, keeping the steps that succeeded' '.failure'
# The promise that makes rollback safe.
check '.preserved.kept == true' \
'a migration must not discard keys it does not recognise' '.preserved'
printf 'migrations contract: PASS\n'
+158 -1
View File
@@ -26,10 +26,62 @@ printf 'brightnessctl' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
if [[ " $* " == *" -m "* && " $* " != *" set "* ]]; then
[[ ${BACKLIGHT_AVAILABLE:-true} == true ]] || exit 1
printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,1000,50%}"
fi
SH
cat >"$scratch/bin/panama-brightness" <<'SH'
#!/bin/bash
printf 'panama-brightness' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
case "${1:-}" in
list)
if [[ -n ${DDC_LIST_JSON:-} ]]; then
printf '%s\n' "$DDC_LIST_JSON"
else
printf '%s\n' '{"displays":[],"error":"No displays"}'
fi
;;
get)
[[ ${DDC_FAIL_GET_BUS:-} != "${2:-}" ]] || exit 1
if [[ -s $OSD_DDC_STATE ]]; then
cat "$OSD_DDC_STATE"
else
printf '%s\n' "${DDC_GET_VALUE:-40}"
fi
;;
set)
if [[ -n ${DDC_SET_DELAY:-} ]]; then
if ! mkdir "$OSD_DDC_PROBE" 2>/dev/null; then
printf 'ddc-overlap\n' >>"$OSD_TEST_LOG"
fi
sleep "$DDC_SET_DELAY"
rmdir "$OSD_DDC_PROBE" 2>/dev/null || true
fi
printf '%s\n' "${3:-0}" >"$OSD_DDC_STATE"
;;
*) exit 2 ;;
esac
SH
cat >"$scratch/bin/hyprctl" <<'SH'
#!/bin/bash
printf 'hyprctl' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
printf '[{"name":"%s","focused":true}]\n' "${FOCUSED_MONITOR:-DP-2}"
SH
cat >"$scratch/bin/notify-send" <<'SH'
#!/bin/bash
printf 'notify-send' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
SH
cat >"$scratch/bin/playerctl" <<'SH'
#!/bin/bash
printf 'playerctl' >>"$OSD_TEST_LOG"
@@ -53,9 +105,21 @@ SH
chmod +x "$scratch/bin/"*
run_helper() {
local runtime="${OSD_RUNTIME_DIR:-$scratch/runtime-default}"
mkdir -p "$runtime"
PATH="$scratch/bin:$PATH" OSD_TEST_LOG="$log" \
OSD_TEST_FAIL_QS="${OSD_TEST_FAIL_QS:-false}" \
PANAMA_OSD_STRICT="${PANAMA_OSD_STRICT:-false}" \
PANAMA_OSD_BRIGHTNESS_HELPER="$scratch/bin/panama-brightness" \
PANAMA_OSD_RUNTIME_DIR="$runtime" \
OSD_DDC_STATE="$runtime/ddc-state" \
OSD_DDC_PROBE="$runtime/ddc-probe" \
BACKLIGHT_AVAILABLE="${BACKLIGHT_AVAILABLE:-true}" \
DDC_LIST_JSON="${DDC_LIST_JSON:-}" \
DDC_GET_VALUE="${DDC_GET_VALUE:-40}" \
DDC_FAIL_GET_BUS="${DDC_FAIL_GET_BUS:-}" \
DDC_SET_DELAY="${DDC_SET_DELAY:-}" \
FOCUSED_MONITOR="${FOCUSED_MONITOR:-DP-2}" \
"$helper" "$@"
}
@@ -86,9 +150,102 @@ assert_line 'qs <ipc> <call> <osd> <progress> <microphone-muted> <72> <100> <Mut
: >"$log"
run_helper brightness up 5
assert_line 'brightnessctl <-e4> <-n2> <set> <5%+>'
assert_line 'brightnessctl <-m> <-c> <backlight>'
assert_line 'brightnessctl <-e4> <-n2> <-c> <backlight> <set> <5%+>'
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <50> <100> <50%>'
if grep -Fq 'panama-brightness' "$log"; then
printf 'osd helper contract: DDC fallback ran despite a native backlight\n' >&2
exit 1
fi
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc" \
BACKLIGHT_AVAILABLE=false \
DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
run_helper brightness up 5
assert_line 'hyprctl <-j> <monitors>'
assert_line 'panama-brightness <list>'
assert_line 'panama-brightness <get> <5>'
assert_line 'panama-brightness <set> <5> <45>'
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <45> <100> <45%>'
# A cached bus avoids the expensive display scan on subsequent key presses.
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc" \
BACKLIGHT_AVAILABLE=false \
DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":45}],"error":""}' \
run_helper brightness down 5
assert_line 'panama-brightness <get> <5>'
assert_line 'panama-brightness <set> <5> <40>'
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <40> <100> <40%>'
if grep -Fq 'panama-brightness <list>' "$log"; then
printf 'osd helper contract: cached DDC bus triggered another display scan\n' >&2
exit 1
fi
# A disconnected cached monitor is discarded and rediscovered once.
mkdir -p "$scratch/runtime-ddc-stale"
printf '9\tDP-9\n' >"$scratch/runtime-ddc-stale/brightness-bus"
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc-stale" \
BACKLIGHT_AVAILABLE=false \
DDC_FAIL_GET_BUS=9 \
DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
run_helper brightness up 5
assert_line 'panama-brightness <get> <9>'
assert_line 'panama-brightness <list>'
assert_line 'panama-brightness <get> <5>'
assert_line 'panama-brightness <set> <5> <45>'
# If the focused output is not DDC-capable, use the first discovered display.
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc-first" \
BACKLIGHT_AVAILABLE=false \
FOCUSED_MONITOR='eDP-1' \
DDC_GET_VALUE=35 \
DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
run_helper brightness down 10
assert_line 'panama-brightness <get> <3>'
assert_line 'panama-brightness <set> <3> <25>'
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <25> <100> <25%>'
# Permission and discovery errors must be visible, never masquerade as 0%.
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc-error" \
BACKLIGHT_AVAILABLE=false \
DDC_LIST_JSON='{"displays":[],"error":"Run sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm"}' \
run_helper brightness up 5
assert_line 'qs <ipc> <call> <osd> <message> <dialog-warning-symbolic> <Brightness needs permission>'
assert_line 'notify-send <--app-name=Panama> <--icon=display-brightness-symbolic> <Brightness unavailable> <Run sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm>'
if grep -Fq 'osd> <progress> <brightness>' "$log"; then
printf 'osd helper contract: unavailable brightness rendered a false percentage\n' >&2
exit 1
fi
# Separate key-repeat processes must not overlap their DDC transactions.
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc-lock" \
BACKLIGHT_AVAILABLE=false \
DDC_SET_DELAY=0.15 \
DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
run_helper brightness up 5 &
first_pid=$!
OSD_RUNTIME_DIR="$scratch/runtime-ddc-lock" \
BACKLIGHT_AVAILABLE=false \
DDC_SET_DELAY=0.15 \
DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
run_helper brightness up 5 &
second_pid=$!
wait "$first_pid"
wait "$second_pid"
if grep -Fqx 'ddc-overlap' "$log"; then
printf 'osd helper contract: concurrent DDC transactions overlapped\n' >&2
exit 1
fi
if [[ $(<"$scratch/runtime-ddc-lock/ddc-state") != 50 ]]; then
printf 'osd helper contract: serialized key repeats did not both apply\n' >&2
exit 1
fi
: >"$log"
run_helper media next
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# panama-power-profile reads and sets the system power profile.
#
# Runs against a stubbed busctl. The real daemon is a SYSTEM service shared with
# everything else on the machine, and a test that flipped the daily driver into
# power-saver and crashed before restoring would leave it there.
#
# The parsing is the fragile part. busctl renders the Profiles property flat:
#
# v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" ...
#
# so profile names and driver names sit in the same stream. A pattern loose
# enough to match both reports the driver as an extra profile -- and on this
# machine the driver is literally called "tuned", which reads exactly like a
# plausible fourth profile.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-power-profile"
fail() {
printf 'power profile contract: %s\n' "$1" >&2
exit 1
}
stub_dir="$(mktemp -d /tmp/panama-power.XXXXXX)"
trap 'rm -rf "$stub_dir"' EXIT
cat >"$stub_dir/busctl" <<'STUB'
#!/usr/bin/env bash
# Records set-property calls so the test can assert what was written.
case "${1:-}" in
status)
[[ "${PANAMA_POWER_FAKE:-up}" == "down" ]] && exit 1
exit 0 ;;
get-property)
case "${5:-}" in
ActiveProfile) printf 's "performance"\n' ;;
PerformanceDegraded) printf 's "%s"\n' "${PANAMA_POWER_DEGRADED:-}" ;;
Profiles)
if [[ "${PANAMA_POWER_FAKE:-up}" == "empty" ]]; then
printf 'v aa{sv} 0\n'
else
printf 'v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" s "balanced" "Driver" s "tuned" 2 "Profile" s "performance" "Driver" s "tuned"\n'
fi ;;
esac
exit 0 ;;
set-property)
printf '%s\n' "${!#}" >>"$PANAMA_POWER_SET_LOG"
exit 0 ;;
esac
exit 0
STUB
chmod +x "$stub_dir/busctl"
export PANAMA_POWER_SET_LOG="$stub_dir/sets.log"
: >"$PANAMA_POWER_SET_LOG"
run() { PATH="$stub_dir:$PATH" "$helper" "$@"; }
# ── Parsing ──────────────────────────────────────────────────────────────────
out="$(run list)"
jq -e . >/dev/null 2>&1 <<<"$out" || fail "list did not emit JSON: $out"
[[ "$(jq -r '.profiles | length' <<<"$out")" == "3" ]] \
|| fail "expected exactly three profiles; a fourth usually means the Driver value was parsed as one: $out"
jq -e '.profiles == ["power-saver", "balanced", "performance"]' >/dev/null <<<"$out" \
|| fail "profiles were parsed wrongly or reordered: $out"
jq -e '.profiles | index("tuned") == null' >/dev/null <<<"$out" \
|| fail 'the driver name "tuned" was reported as a profile'
[[ "$(jq -r '.active' <<<"$out")" == "performance" ]] \
|| fail "the active profile was not read: $out"
# ── Degradation is surfaced, since it makes the active profile a lie ─────────
out="$(PANAMA_POWER_DEGRADED="lap-detected" run list)"
[[ "$(jq -r '.degraded' <<<"$out")" == "lap-detected" ]] \
|| fail "a degraded performance state was not reported: $out"
# ── Setting ──────────────────────────────────────────────────────────────────
run set balanced
[[ "$(tail -1 "$PANAMA_POWER_SET_LOG")" == "balanced" ]] \
|| fail "set did not write the requested profile: $(cat "$PANAMA_POWER_SET_LOG")"
before="$(wc -l <"$PANAMA_POWER_SET_LOG")"
run set 'evil; rm -rf /' 2>/dev/null
[[ "$(wc -l <"$PANAMA_POWER_SET_LOG")" == "$before" ]] \
|| fail 'a profile name with shell metacharacters reached the system service'
# ── No daemon, and a daemon with nothing to offer, are both states ───────────
out="$(PANAMA_POWER_FAKE=down run list)"
jq -e '.profiles == [] and .error != ""' >/dev/null <<<"$out" \
|| fail "a missing power daemon must be reported with a reason: $out"
out="$(PANAMA_POWER_FAKE=empty run list)"
jq -e '.profiles == [] and .error != ""' >/dev/null <<<"$out" \
|| fail "a daemon offering no profiles must be reported, not shown as an empty card: $out"
printf 'power profile contract: PASS\n'
@@ -61,6 +61,26 @@ restore() {
}
trap restore EXIT
# This contract shares its harness file with settings-hyprland-write-contract,
# and Quickshell identifies an instance by config path -- so if that run's
# instance is still alive, the IPC wait below is satisfied by ITS target. That
# direction is the dangerous one: this contract believes the compositor seam is
# stubbed, so it would happily drive the DAILY DESKTOP's real compositor while
# reporting isolation. Refuse to start rather than find out.
harness_instances() {
# rg -c prints nothing when there are no matches, so an unguarded
# substitution yields "" rather than "0".
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 drive it instead of its own isolated one, and that instance may be writing to the real compositor'
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \
PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1 qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do
@@ -77,6 +77,32 @@ 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
@@ -123,8 +149,23 @@ sleep 0.3
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 _ in $(seq 1 40); do
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" \
@@ -135,10 +176,14 @@ for _ in $(seq 1 40); do
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)"
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.
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Adding a settings page means editing four separate files, and missing one
# fails quietly rather than loudly:
#
# SettingsSidebar.qml the row you click
# SettingsShell.qml the case that maps that row to a component, AND the
# Component declaration itself
# ShellState.qml the allow-list openSettings() checks -- a page missing
# here silently redirects to Home, so a deep link or a
# search result lands on the wrong page with no error
# modules/settings/qmldir the component registration -- without it the page
# is "not a type" and the whole settings window fails
# to load, taking every other page with it
#
# Nothing at runtime cross-checks the four. This does, statically.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
sidebar="$settings_dir/SettingsSidebar.qml"
shell_file="$settings_dir/SettingsShell.qml"
qmldir="$settings_dir/qmldir"
shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml"
fail() {
printf 'settings nav contract: %s\n' "$1" >&2
exit 1
}
for required in "$sidebar" "$shell_file" "$qmldir" "$shell_state"; do
[[ -r "$required" ]] || fail "cannot read $required"
done
# ── Every sidebar row resolves everywhere ────────────────────────────────────
pages="$(grep -oE '\{ page: "[a-z-]+"' "$sidebar" | sed 's/.*"\(.*\)"/\1/')"
[[ -n "$pages" ]] || fail 'no pages found in the sidebar -- this contract is not reading it correctly'
allowed_line="$(grep -m1 'const allowed = \[' "$shell_state")" \
|| fail 'could not find the allow-list in ShellState'
while read -r page; do
[[ -n "$page" ]] || continue
# Home is the switch's default arm rather than a case, since it is also
# where an unknown page falls back to.
if [[ "$page" != "home" ]]; then
grep -qE "case \"$page\": return [a-zA-Z]+;" "$shell_file" \
|| fail "the sidebar offers \"$page\" but SettingsShell has no case for it, so clicking it shows Home"
fi
grep -qF "\"$page\"" <<<"$allowed_line" \
|| fail "\"$page\" is missing from ShellState's allow-list, so openSettings(\"$page\") silently redirects to Home"
done <<<"$pages"
# ── Every routed component is declared and registered ────────────────────────
# The case arms name a Component id; each must have a declaration, and the type
# it instantiates must appear in qmldir.
while read -r component; do
[[ -n "$component" ]] || continue
declaration="$(grep -oE "Component \{ id: $component; [A-Za-z]+ \{\} \}" "$shell_file")" \
|| fail "SettingsShell routes to \"$component\" but never declares it"
type_name="$(sed -E 's/.*; ([A-Za-z]+) \{\} \}/\1/' <<<"$declaration")"
grep -qE "^$type_name [0-9.]+ $type_name\.qml$" "$qmldir" \
|| fail "$type_name is not registered in modules/settings/qmldir -- it will fail to load as \"not a type\", and the whole settings window fails with it"
[[ -r "$settings_dir/$type_name.qml" ]] \
|| fail "$type_name is registered in qmldir but $type_name.qml does not exist"
done < <({
grep -oE 'case "[a-z-]+": return [a-zA-Z]+;' "$shell_file" | sed -E 's/.*return ([a-zA-Z]+);/\1/'
grep -oE 'default: return [a-zA-Z]+;' "$shell_file" | sed -E 's/.*return ([a-zA-Z]+);/\1/'
} | sort -u)
# ── Every page file is reachable ─────────────────────────────────────────────
# A page nobody can navigate to is dead code that still has to compile. The
# scaffold SettingsPage.qml is the one file here that is a base class rather
# than a page.
while read -r page_file; do
type_name="$(basename "$page_file" .qml)"
[[ "$type_name" == "SettingsPage" ]] && continue
grep -qE "; $type_name \{\} \}" "$shell_file" \
|| fail "$type_name.qml exists but nothing in SettingsShell instantiates it"
done < <(find "$settings_dir" -maxdepth 1 -name '*Page.qml')
printf 'settings nav contract: PASS\n'
+2 -2
View File
@@ -195,14 +195,14 @@ for page in "${pages[@]}"; do
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 == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \
'[.[] | 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 == "Panama Settings" and .key == "I" and .modmask == 64)' >/dev/null \
/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'
+6 -6
View File
@@ -16,14 +16,14 @@ qs ipc show | rg -q '^target settings$' || fail 'settings IPC target is missing'
qs ipc call settings open >/dev/null
for _ in $(seq 1 40); do
if hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 1' >/dev/null; then
if hyprctl -j clients | jq -e '[.[] | select(.title == "Settings")] | length == 1' >/dev/null; then
break
fi
sleep 0.1
done
hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 1' >/dev/null \
hyprctl -j clients | jq -e '[.[] | select(.title == "Settings")] | length == 1' >/dev/null \
|| fail 'exactly one Settings window did not map'
hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings" and .floating == false)' >/dev/null \
hyprctl -j clients | jq -e '.[] | select(.title == "Settings" and .floating == false)' >/dev/null \
|| fail 'Settings window is not tiled'
qs ipc call settings page displays >/dev/null
@@ -32,7 +32,7 @@ qs ipc call settings page displays >/dev/null
qs ipc call settings page desktop >/dev/null
[[ "$(qs ipc call settings status | jq -r .page)" == "desktop" ]] || fail 'Desktop page did not route'
address="$(hyprctl -j clients | jq -r '.[] | select(.title == "Panama Settings") | .address')"
address="$(hyprctl -j clients | jq -r '.[] | select(.title == "Settings") | .address')"
hyprctl dispatch "hl.dsp.window.close({ window = \"address:$address\" })" >/dev/null
for _ in $(seq 1 40); do
[[ "$(qs ipc call settings status | jq -r .open)" == "false" ]] && break
@@ -42,13 +42,13 @@ done
qs ipc call settings open >/dev/null
for _ in $(seq 1 40); do
hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings")' >/dev/null && break
hyprctl -j clients | jq -e '.[] | select(.title == "Settings")' >/dev/null && break
sleep 0.1
done
qs ipc call settings close >/dev/null
for _ in $(seq 1 40); do
if ! hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings")' >/dev/null; then
if ! hyprctl -j clients | jq -e '.[] | select(.title == "Settings")' >/dev/null; then
trap - EXIT
printf 'settings window contract: PASS\n'
exit 0
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# panama-wifi-qr renders a saved network as a QR code a phone can scan.
#
# The QR contains the network PASSWORD in machine-readable form, so most of what
# is worth testing here is about handling that safely rather than about QR
# codes. Both nmcli and qrencode are stubbed: the real ones would read this
# machine's actual passphrases, and a test that writes the daily driver's Wi-Fi
# password into a fixture directory is not one worth having.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-wifi-qr"
fail() {
printf 'wifi qr contract: %s\n' "$1" >&2
exit 1
}
work="$(mktemp -d /tmp/panama-wifiqr.XXXXXX)"
trap 'rm -rf "$work"' EXIT
mkdir -p "$work/bin" "$work/run"
readonly SECRET='hunter2-secret'
cat >"$work/bin/nmcli" <<STUB
#!/usr/bin/env bash
# -t -f NAME,TYPE connection show
if [[ "\$*" == *"-f NAME,TYPE"* ]]; then
printf 'home net:802-11-wireless\n'
printf 'work-eap:802-11-wireless\n'
printf 'Wired connection 1:802-3-ethernet\n'
exit 0
fi
name="\${@: -1}"
case "\$*" in
*802-11-wireless.ssid*)
# An SSID containing reserved characters, to prove they are escaped.
case "\$name" in
"home net") printf 'home;net\n' ;;
"work-eap") printf 'work-eap\n' ;;
esac ;;
*802-11-wireless.hidden*) printf 'no\n' ;;
*802-11-wireless-security.psk*)
# work-eap is enterprise: no passphrase exists to share.
[[ "\$name" == "home net" ]] && printf '%s\n' "$SECRET" ;;
esac
exit 0
STUB
chmod +x "$work/bin/nmcli"
# Records its argv and its stdin separately, so the test can prove the secret
# arrived on stdin and never on the command line -- argv is world-readable
# through /proc while a process runs.
cat >"$work/bin/qrencode" <<'STUB'
#!/usr/bin/env bash
printf '%s\n' "$*" >>"$QRENCODE_ARGV_LOG"
out=""
prev=""
for arg in "$@"; do
[[ "$prev" == "-o" ]] && out="$arg"
prev="$arg"
done
cat >"$QRENCODE_STDIN_LOG"
printf 'fake-png' >"$out"
exit 0
STUB
chmod +x "$work/bin/qrencode"
export QRENCODE_ARGV_LOG="$work/argv.log"
export QRENCODE_STDIN_LOG="$work/stdin.log"
: >"$QRENCODE_ARGV_LOG"
: >"$QRENCODE_STDIN_LOG"
run() { PATH="$work/bin:$PATH" XDG_RUNTIME_DIR="$work/run" "$helper" "$@"; }
# ── Listing distinguishes shareable from not ────────────────────────────────
out="$(run list)"
jq -e . >/dev/null 2>&1 <<<"$out" || fail "list did not emit JSON: $out"
[[ "$(jq -r '.networks | length' <<<"$out")" == "2" ]] \
|| fail "only wireless connections belong in the list: $out"
jq -e '.networks[] | select(.name == "home net") | .shareable == true' >/dev/null <<<"$out" \
|| fail "a network with a passphrase must be shareable: $out"
jq -e '.networks[] | select(.name == "work-eap") | .shareable == false' >/dev/null <<<"$out" \
|| fail "an enterprise network has no passphrase, so a QR code for it cannot work: $out"
# ── The payload ─────────────────────────────────────────────────────────────
path="$(run qr 'home net' | jq -r .path)"
[[ -n "$path" && -e "$path" ]] || fail 'no image was produced'
payload="$(cat "$QRENCODE_STDIN_LOG")"
grep -q "P:$SECRET;" <<<"$payload" \
|| fail 'the passphrase did not reach the payload intact'
# The SSID is "home;net": unescaped, the semicolon ends the S: field early and
# the code describes a different network.
grep -qF 'S:home\;net;' <<<"$payload" \
|| fail "a reserved character in the SSID was not escaped: $payload"
[[ "$(wc -l <"$QRENCODE_STDIN_LOG")" == "0" ]] \
|| fail "the payload contains a newline; nmcli's trailing newline must be stripped: $(cat -A "$QRENCODE_STDIN_LOG")"
grep -q ';;$' <<<"$payload" || fail "the WIFI: URI must be terminated with ;;: $payload"
# ── The secret must never appear in argv ────────────────────────────────────
grep -q "$SECRET" "$QRENCODE_ARGV_LOG" \
&& fail 'the passphrase was passed as a command-line argument, where /proc exposes it to every process on the machine'
# ── The image and its directory must not be readable by others ──────────────
[[ "$(stat -c '%a' "$path")" == "600" ]] \
|| fail "the QR image is mode $(stat -c '%a' "$path"); it contains a password"
[[ "$(stat -c '%a' "$(dirname "$path")")" == "700" ]] \
|| fail "the directory holding QR images is mode $(stat -c '%a' "$(dirname "$path")")"
# ── No temporary payload files may survive ──────────────────────────────────
leftovers="$(find "$work/run" -name 'payload.*' | wc -l)"
[[ "$leftovers" == "0" ]] \
|| fail "$leftovers temporary payload file(s) containing the passphrase were left behind"
# ── An unknown network is an error, not an empty image ──────────────────────
out="$(run qr 'no-such-network')"
jq -e '.path == "" and .error != ""' >/dev/null <<<"$out" \
|| fail "an unknown network must be reported: $out"
printf 'wifi qr contract: PASS\n'