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:
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
Executable
+95
@@ -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"
|
||||
Executable
+88
@@ -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'
|
||||
@@ -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
|
||||
|
||||
Executable
+108
@@ -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'
|
||||
Executable
+72
@@ -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'
|
||||
@@ -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
|
||||
|
||||
Executable
+103
@@ -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.
|
||||
|
||||
Executable
+89
@@ -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'
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+126
@@ -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'
|
||||
Reference in New Issue
Block a user