diff --git a/README.md b/README.md index bf775d3..b69cde7 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -159 of them, under `tests/`. Run the lot, or a subset by pattern: +160 of them, under `tests/`. Run the lot, or a subset by pattern: ```sh panama test # everything diff --git a/config/dot/quickshell/services/SystemSettings.qml b/config/dot/quickshell/services/SystemSettings.qml index a778514..578cdd2 100644 --- a/config/dot/quickshell/services/SystemSettings.qml +++ b/config/dot/quickshell/services/SystemSettings.qml @@ -644,7 +644,12 @@ Singleton { "rustdesk": ["rustdesk"], "kdeconnect": ["kdeconnect-app"], "mission-center": ["flatpak", "run", "io.missioncenter.MissionCenter"], - "bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"] + "bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"], + // The Accessibility page's "Start Orca" button. It was calling + // openApplication("orca") with no "orca" here, so it set an error + // and launched nothing -- a dead button. orca ships in + // hyprland-packages, so this just runs it. + "orca": ["orca"] }; const command = commands[id]; if (!command) { diff --git a/tests/quickshell/declared-dependencies-contract b/tests/quickshell/declared-dependencies-contract index 18d1467..e57775f 100755 --- a/tests/quickshell/declared-dependencies-contract +++ b/tests/quickshell/declared-dependencies-contract @@ -56,6 +56,14 @@ SELF_INSTALLED='^(bun|claude|node|npm|pnpm)$' # Anything added here needs that same property: absence must be loud. OPTIONAL='^(docker)$' +# Provided by a package swap rather than a list entry. ffmpeg cannot be a list +# entry: RPM Fusion's ffmpeg CONFLICTS with the ffmpeg-free that Fedora ships +# preinstalled, so `dnf install ffmpeg` fails the whole transaction. The codec +# section swaps ffmpeg-free -> ffmpeg with --allowerasing instead. Either way +# /usr/bin/ffmpeg exists -- ffmpeg-free provides it too -- so panama-transcode +# always has it; it is simply never a name in a package list. +SWAPPED='^(ffmpeg)$' + # jq programs are quoted arguments, but the scanner is line-based and cannot # tell a filter from a command. `not` is a jq builtin appearing inside one. JQ_BUILTINS='^(not|empty|error|env|input|inputs)$' @@ -116,6 +124,7 @@ while read -r script; do [[ "$cmd" =~ $SESSION ]] && continue [[ "$cmd" =~ $SELF_INSTALLED ]] && continue [[ "$cmd" =~ $OPTIONAL ]] && continue + [[ "$cmd" =~ $SWAPPED ]] && continue [[ "$cmd" =~ $JQ_BUILTINS ]] && continue pkg="$(package_for "$cmd")" diff --git a/tests/quickshell/settings-buttons-contract b/tests/quickshell/settings-buttons-contract new file mode 100755 index 0000000..d20d91a --- /dev/null +++ b/tests/quickshell/settings-buttons-contract @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +# A settings button that calls a target the backend does not handle is a dead +# control: it looks identical to a working one until someone presses it and +# nothing happens. Two dispatch maps in SystemSettings.qml decide whether a +# button lands -- openApplication(id) and openGnomePanel(panel) -- and both +# silently return false on an unknown name. This pins every id and panel a QML +# button passes to being present in those maps. +# +# It exists because two such buttons shipped: "Start Orca" called +# openApplication("orca") with no "orca" in the map, and the dictation card +# called a helper subcommand that did not exist. This catches the first shape; +# the helper-subcommand shape is caught by each service's own contract. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +qs="$repo_dir/config/dot/quickshell" +sysset="$qs/services/SystemSettings.qml" + +fail() { printf 'settings buttons contract: %s\n' "$1" >&2; exit 1; } +[[ -r "$sysset" ]] || fail "missing $sysset" + +python3 - "$qs" "$sysset" <<'PY' +import re, sys, pathlib +qs, sysset = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]) +text = sysset.read_text() + +# openApplication's command map keys. +m = re.search(r'function openApplication.*?const commands = \{(.*?)\};', text, re.S) +app_ids = set(re.findall(r'"([a-z0-9-]+)"\s*:', m.group(1))) if m else set() + +# openGnomePanel's allow-list. +m = re.search(r'isGnomePanelAllowed.*?return \[(.*?)\]\.indexOf', text, re.S) +panels = set(re.findall(r'"([a-z-]+)"', m.group(1))) if m else set() + +problems = [] +for page in sorted((qs/'modules').rglob('*.qml')): + t = page.read_text() + for app_id in re.findall(r'openApplication\("([^"]+)"\)', t): + if app_id not in app_ids: + problems.append(f'{page.name}: openApplication("{app_id}") -- not in the command map, so the button does nothing') + for panel in re.findall(r'openGnomePanel\("([a-z-]+)"', t): + if panel not in panels: + problems.append(f'{page.name}: openGnomePanel("{panel}") -- not allow-listed, so the handoff opens nothing') + +if problems: + print(f"settings buttons contract: {len(problems)} dead button(s)") + for p in problems: print(" -", p) + sys.exit(1) +print(f"settings buttons contract: ok ({len(app_ids)} app ids, {len(panels)} panels, every button resolves)") +PY