Finish the wonderland: System told truthfully, in eight tabs instead of ten
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -159,7 +159,9 @@ qml_package() {
|
||||
|
||||
# Provided by the base system or the shell itself; nothing installs these
|
||||
# separately, and listing them would be noise.
|
||||
QML_BASELINE='^(sh|bash|rm|test|pkill|systemd-inhibit|loginctl|timedatectl|gsettings|gapplication|systemctl|busctl)$'
|
||||
# `qs` is Quickshell itself -- if the shell is running QML, qs is by
|
||||
# definition present, so it needs no package list entry.
|
||||
QML_BASELINE='^(sh|bash|rm|test|pkill|systemd-inhibit|loginctl|timedatectl|gsettings|gapplication|systemctl|busctl|qs)$'
|
||||
|
||||
while IFS= read -r command_name; do
|
||||
[[ -n "$command_name" ]] || continue
|
||||
|
||||
@@ -38,7 +38,9 @@ SHELL_WORDS='^(if|then|else|elif|fi|for|while|until|do|done|case|esac|in|functio
|
||||
# fingerprint aliases in config/bash can rely on it without declaring it.
|
||||
BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|ls|rfkill|lsof|authselect|setsid|nohup|grub2-mkconfig)$'
|
||||
|
||||
SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$'
|
||||
# bootctl ships in systemd-udev, which every Fedora install carries -- it is
|
||||
# the udev half of systemd, not an optional tool.
|
||||
SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|bootctl|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$'
|
||||
|
||||
# Installed by install-packages itself rather than by a package list. Two
|
||||
# reasons, both deliberate: bun and claude have no RPM or flatpak at all, and
|
||||
|
||||
@@ -55,6 +55,21 @@ fail() {
|
||||
# Fedora", explaining that GNOME's file-history switches would not take effect
|
||||
# in a Hyprland session anyway -- is gone, because the switches it was
|
||||
# apologizing for are now buttons that work.
|
||||
# "color" joined with the same argument, one page over. System Health's Fedora
|
||||
# card offered Color profiles because Panama's Displays page did not have them;
|
||||
# it has had them per display for some time -- automatic, sRGB, wide gamut or
|
||||
# HDR, with bit depth and SDR brightness beside them -- so the row was pointing
|
||||
# at GNOME for something the desktop's own display page does better, since it
|
||||
# is the page that knows which output you mean.
|
||||
#
|
||||
# "system region" joined when Region & Language stopped being a page that
|
||||
# offered a language picker and a link, and its contents became the Language &
|
||||
# formats card on Date, Time & Region: a language picker, four per-category
|
||||
# format dropdowns backed by the installed locales, and a live preview of what
|
||||
# each choice actually renders. GNOME's panel does the same job with the same
|
||||
# locales, so a row pointing at it is now a door out of a page that does the
|
||||
# thing. The retired page carried two: that one, and an "Open appearance"
|
||||
# handoff left over from when the fonts lived here.
|
||||
declare -A OWNED=(
|
||||
[network]=connectivity
|
||||
[wifi]=connectivity
|
||||
@@ -64,6 +79,8 @@ declare -A OWNED=(
|
||||
[users]=users
|
||||
[system\ users]=users
|
||||
[privacy]=privacy
|
||||
[system\ region]=datetime
|
||||
[color]=displays
|
||||
)
|
||||
|
||||
# Handoffs that are correct despite naming an owned panel, with the reason.
|
||||
@@ -127,6 +144,20 @@ if grep -q 'openGnomePanel' "$settings_dir/PrivacyPage.qml"; then
|
||||
violations=$((violations + 1))
|
||||
fi
|
||||
|
||||
# The same, for the page that no longer exists. RegionPage's two handoffs went
|
||||
# with the file, but the card they sat in moved to DateTimePage, and a card can
|
||||
# be moved with its rows intact -- so this is asserted against the page that
|
||||
# received the content rather than against the one that was deleted.
|
||||
if [[ -e "$settings_dir/RegionPage.qml" ]]; then
|
||||
printf 'gnome handoff contract: RegionPage.qml still exists; its route retired to datetime\n' >&2
|
||||
violations=$((violations + 1))
|
||||
fi
|
||||
if grep -q 'openGnomePanel' "$settings_dir/DateTimePage.qml"; then
|
||||
printf 'gnome handoff contract: DateTimePage opens a GNOME panel for something it now does itself:\n' >&2
|
||||
grep -n 'openGnomePanel' "$settings_dir/DateTimePage.qml" >&2
|
||||
violations=$((violations + 1))
|
||||
fi
|
||||
|
||||
if (( violations > 0 )); then
|
||||
printf 'Each of these sends someone to GNOME for a page this app already has.\n' >&2
|
||||
exit 1
|
||||
|
||||
@@ -23,6 +23,16 @@ confirm_snapshot="$(jq -c '
|
||||
action: {kind: "repair", label: "Restart Panama", confirm: true}
|
||||
}]
|
||||
' <<<"$warning_snapshot")"
|
||||
updates_snapshot="$(jq -c '
|
||||
.checks[0].action = {kind: "open", label: "Open Software Update", confirm: false, target: "updates"}
|
||||
' <<<"$warning_snapshot")"
|
||||
# "storage" is a real Settings page that `settingsTargets` deliberately does
|
||||
# not list. The allow-list has to be an allow-list: if any page id were taken
|
||||
# on trust, extending the doctor's vocabulary would stop being a change that
|
||||
# has to be made on both sides, and this whole coupling would be decorative.
|
||||
unlisted_snapshot="$(jq -c '
|
||||
.checks[0].action = {kind: "open", label: "Open Storage", confirm: false, target: "storage"}
|
||||
' <<<"$warning_snapshot")"
|
||||
projection_snapshot="$(jq -c '
|
||||
.fixtureSecret = "fixture-secret"
|
||||
| .summary.fixtureSecret = "fixture-secret"
|
||||
@@ -30,6 +40,11 @@ projection_snapshot="$(jq -c '
|
||||
| .context.versions[0].fixtureSecret = "fixture-secret"
|
||||
| .checks[0].fixtureSecret = "fixture-secret"
|
||||
' <<<"$warning_snapshot")"
|
||||
# One probe, in the same envelope as a full report. Re-checking a single row
|
||||
# after fixing something by hand is the reason it exists: rescanning all thirty
|
||||
# takes long enough that people stop doing it, and a row that never updates is
|
||||
# a row that stops being believed.
|
||||
single_check='{"schemaVersion":1,"generatedAt":"2026-08-24T00:00:00Z","summary":{"status":"healthy","healthy":1,"warnings":0,"errors":0,"unconfigured":0},"context":{"session":"hyprland","versions":[{"id":"quickshell","version":"0.3.0"}]},"checks":[{"id":"panama.caffeine","group":"panama-tools","title":"Caffeine","status":"ok","detail":"No duplicate sleep inhibitors."}]}'
|
||||
adversarial_snapshot="$(jq -c '
|
||||
.fixtureSecret = "fixture-secret"
|
||||
| .summary.fixtureSecret = "fixture-secret"
|
||||
@@ -85,6 +100,57 @@ if keys != ["summary", "busy", "generation", "acceptedGeneration", "checks"]:
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
# ── Both sides of a Settings target ─────────────────────────────────────────
|
||||
#
|
||||
# An `open` action carries the id of a Settings page, and the two halves of
|
||||
# that agreement live in different languages in different directories: the
|
||||
# doctor writes `target="updates"`, and Health decides whether it will accept
|
||||
# one by looking the value up in `settingsTargets`.
|
||||
#
|
||||
# Getting one without the other is not a dead button. `validAction` returning
|
||||
# false makes `validCheck` return false, which makes `consumeSnapshot` reject
|
||||
# the WHOLE report -- so a single unlisted target takes every other check down
|
||||
# with it and System Health goes blank, for a reason nothing on screen names.
|
||||
# That is what "Open Software Update" would have done had it been given the
|
||||
# target it was missing without `settingsTargets` being extended to match.
|
||||
#
|
||||
# Derived from both files rather than restated, so the next target added on
|
||||
# either side has to be added on the other.
|
||||
python3 - "$repo_dir/config/dot/quickshell/scripts/panama-doctor" "$service" <<'PY' \
|
||||
|| fail 'the doctor emits a Settings or instructions target that Health would reject, which rejects the entire snapshot'
|
||||
import re
|
||||
import sys
|
||||
|
||||
doctor = open(sys.argv[1], encoding="utf-8").read()
|
||||
health = open(sys.argv[2], encoding="utf-8").read()
|
||||
|
||||
|
||||
def accepted(name: str) -> set[str]:
|
||||
block = re.search(rf'property var {name}:\s*\[(.*?)\]', health, re.S)
|
||||
if not block:
|
||||
raise SystemExit(f"Health.qml no longer declares {name}")
|
||||
return set(re.findall(r'"([a-z-]+)"', block.group(1)))
|
||||
|
||||
|
||||
settings_targets = accepted("settingsTargets")
|
||||
instruction_targets = accepted("instructionTargets")
|
||||
|
||||
emitted = re.findall(r'Action\(\s*"(open|instructions)"\s*,\s*"[^"]*"\s*,\s*target="([a-z-]+)"', doctor)
|
||||
if not emitted:
|
||||
raise SystemExit("no targeted actions were read from the doctor, so this proves nothing")
|
||||
|
||||
for kind, target in emitted:
|
||||
allowed = settings_targets if kind == "open" else instruction_targets
|
||||
if target not in allowed:
|
||||
raise SystemExit(
|
||||
f'the doctor emits a {kind} action targeting "{target}", which Health does not accept'
|
||||
)
|
||||
|
||||
if "updates" not in settings_targets:
|
||||
raise SystemExit('Health does not accept the "updates" target, so the Software Update '
|
||||
'actions have nowhere to go')
|
||||
PY
|
||||
|
||||
fixture_dir="$(mktemp -d /tmp/panama-health.XXXXXX)"
|
||||
config_path="$fixture_dir/quickshell"
|
||||
cp -a "$repo_dir/config/dot/quickshell" "$config_path"
|
||||
@@ -130,6 +196,10 @@ printf '%s\n' \
|
||||
" printf '%s\\n' '$warning_snapshot'" \
|
||||
' exit 0' \
|
||||
'fi' \
|
||||
'if [[ "$1" == "check" ]]; then' \
|
||||
' printf "%s\n" "$PANAMA_HEALTH_SINGLE_CHECK"' \
|
||||
' exit 0' \
|
||||
'fi' \
|
||||
'if [[ "$1" == "--repair" ]]; then' \
|
||||
' repair_start_time="$(awk '\''{ print $22 }'\'' "/proc/$$/stat")"' \
|
||||
' printf "%s|%s\n" "$$" "$repair_start_time" >"$PANAMA_HEALTH_REPAIR_STARTED"' \
|
||||
@@ -161,7 +231,7 @@ chmod +x "$copy_bin/wl-copy" "$copy_bin/notify-send"
|
||||
run() {
|
||||
PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \
|
||||
PANAMA_HEALTH_REPAIR_MODE_FILE="$repair_mode_file" PANAMA_HEALTH_REPAIR_LOG="$repair_log" \
|
||||
PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" \
|
||||
PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" PANAMA_HEALTH_SINGLE_CHECK="$single_check" \
|
||||
PANAMA_HEALTH_REPAIR_STARTED="$repair_started_file" PANAMA_HEALTH_REPAIR_RELEASE="$repair_release_file" \
|
||||
qs -p "$harness" "$@"
|
||||
}
|
||||
@@ -218,7 +288,7 @@ trap cleanup EXIT
|
||||
|
||||
PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \
|
||||
PANAMA_HEALTH_REPAIR_MODE_FILE="$repair_mode_file" PANAMA_HEALTH_REPAIR_LOG="$repair_log" \
|
||||
PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" \
|
||||
PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" PANAMA_HEALTH_SINGLE_CHECK="$single_check" \
|
||||
PANAMA_HEALTH_REPAIR_STARTED="$repair_started_file" PANAMA_HEALTH_REPAIR_RELEASE="$repair_release_file" \
|
||||
qs -p "$harness" --daemonize >/dev/null
|
||||
for _ in $(seq 1 40); do
|
||||
@@ -237,6 +307,38 @@ state="$(run ipc call health-test status)"
|
||||
jq -e '.status == "warning" and .acceptedGeneration == 0 and .checks == ["integration.calendar", "panama.caffeine"] and .diagnosticUnavailable == false' \
|
||||
>/dev/null <<<"$state" || fail "valid warning snapshot was not accepted intact: $state"
|
||||
|
||||
[[ "$(run ipc call health-test accept "$updates_snapshot" 0)" == "true" ]] \
|
||||
|| fail 'a check pointing at Software Update was rejected, so the whole report would go blank rather than one button being dead'
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.status == "warning" and .diagnosticUnavailable == false
|
||||
and .checks == ["integration.calendar", "panama.caffeine"]' \
|
||||
>/dev/null <<<"$state" || fail "the Software Update target did not survive acceptance intact: $state"
|
||||
|
||||
[[ "$(run ipc call health-test accept "$unlisted_snapshot" 0)" == "false" ]] \
|
||||
|| fail 'a Settings target the service does not list was accepted, so the allow-list is not one'
|
||||
|
||||
# ── The command a repair will run, carried through ──────────────────────────
|
||||
#
|
||||
# Every field of a check is projected onto a known shape on the way in, which
|
||||
# is what stops an unknown key from reaching the report -- and which means a
|
||||
# NEW known key has to be added to the projection or it is silently dropped.
|
||||
# `repairCommand` is the one where that failure is invisible: the repair still
|
||||
# works, the row still says "Restart Vicinae", and the only thing missing is
|
||||
# the sentence telling somebody what is about to run as them.
|
||||
repair_command_snapshot="$(jq -c '
|
||||
.checks[1].repairCommand = "systemd-inhibit --list"
|
||||
' <<<"$warning_snapshot")"
|
||||
[[ "$(run ipc call health-test accept "$repair_command_snapshot" 0)" == "true" ]] \
|
||||
|| fail 'a check carrying its repair command was rejected'
|
||||
jq -e '[.checks[] | select(.id == "panama.caffeine") | .repairCommand] == ["systemd-inhibit --list"]' \
|
||||
>/dev/null <<<"$(run ipc call health-test report)" \
|
||||
|| fail 'the repair command was projected away, so the row cannot say what it is about to run'
|
||||
|
||||
# A check with no repair, or a doctor that has not learned to send one, is
|
||||
# still a check. Dropping the field is fine; refusing the report is not.
|
||||
[[ "$(run ipc call health-test accept "$warning_snapshot" 0)" == "true" ]] \
|
||||
|| fail 'a check without a repair command was rejected once the field existed'
|
||||
|
||||
[[ "$(run ipc call health-test accept "$projection_snapshot" 0)" == "true" ]] \
|
||||
|| fail 'snapshot with unknown non-action fields was rejected instead of safely projected'
|
||||
stored_report="$(run ipc call health-test report)"
|
||||
@@ -398,6 +500,63 @@ state="$(run ipc call health-test status)"
|
||||
jq -e '.repairingId == "" and .generation == ($before + 1)' --argjson before "$confirm_generation" \
|
||||
>/dev/null <<<"$state" || fail "rejected repair altered process state: $state"
|
||||
|
||||
# ── One row, re-checked ─────────────────────────────────────────────────────
|
||||
#
|
||||
# The whole scan is thirty probes with network and D-Bus work behind several of
|
||||
# them. Somebody who has just restarted a service by hand wants to know about
|
||||
# that service, and making them wait nine seconds for the other twenty-nine is
|
||||
# how a Re-check button stops being pressed and a stale row stops being
|
||||
# believed. So `refreshCheck` runs one probe -- and has to leave the rest of
|
||||
# the accepted report exactly as it was, since a single-check response says
|
||||
# nothing about any other row.
|
||||
: >"$repair_log"
|
||||
[[ "$(run ipc call health-test recheck panama.caffeine)" == "true" ]] \
|
||||
|| fail 'a single-check refresh was refused for a check that is in the report'
|
||||
for _ in $(seq 1 60); do
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '(.checkStates[] | select(.id == "panama.caffeine") | .status) == "ok"' \
|
||||
>/dev/null <<<"$state" && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.checks == ["integration.calendar", "panama.caffeine"]
|
||||
and (.checkStates[] | select(.id == "panama.caffeine") | .status) == "ok"
|
||||
and (.checkStates[] | select(.id == "integration.calendar") | .status) == "warning"
|
||||
and .summary == {status: "warning", healthy: 1, warnings: 1, errors: 0, unconfigured: 0}' \
|
||||
>/dev/null <<<"$state" \
|
||||
|| fail "a single-check refresh did not update exactly the one row it probed: $state"
|
||||
grep -Fxq 'check panama.caffeine' "$repair_log" \
|
||||
|| fail "refreshCheck did not ask the doctor for one check: $(<"$repair_log")"
|
||||
! grep -Fxq -- '--json' "$repair_log" \
|
||||
|| fail 'a single-check refresh ran the whole thirty-probe scan anyway'
|
||||
|
||||
: >"$repair_log"
|
||||
[[ "$(run ipc call health-test recheck unknown.check)" == "false" ]] \
|
||||
|| fail 'a check id that is not in the report started a probe'
|
||||
[[ ! -s "$repair_log" ]] || fail 'a refused single-check refresh started a process'
|
||||
|
||||
# ── The report, saved rather than copied ────────────────────────────────────
|
||||
#
|
||||
# Copy Report puts the diagnostics on the clipboard, which is the right answer
|
||||
# when the next step is pasting it into a message and the wrong one when the
|
||||
# next step is attaching it, or reading it in an editor, or sending it from a
|
||||
# session that is the thing being diagnosed. Same redacted projection, written
|
||||
# to a file.
|
||||
report_file="$fixture_dir/health-report.txt"
|
||||
[[ "$(run ipc call health-test save "$report_file")" == "true" ]] \
|
||||
|| fail 'saving the health report was refused'
|
||||
for _ in $(seq 1 40); do
|
||||
[[ -s "$report_file" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[[ -s "$report_file" ]] || fail 'the saved health report is absent or empty'
|
||||
grep -Fq 'panama.caffeine' "$report_file" \
|
||||
|| fail 'the saved report does not contain the checks it is a report of'
|
||||
! grep -Fq 'fixture-secret' "$report_file" \
|
||||
|| fail 'the saved report is not the redacted projection that copyReport writes'
|
||||
rg -Fq 'panama-health-report.txt' "$service" \
|
||||
|| fail 'saveReport has no default destination, so the row has nowhere to write without a file dialog'
|
||||
|
||||
|
||||
python3 - "$service" <<'PY' || fail 'external repair failure notification is not bounded'
|
||||
import sys
|
||||
|
||||
|
||||
@@ -78,10 +78,46 @@ rg -Fq 'SystemSettings.openGnomePanel("system", "users")' "$settings_dir/HealthP
|
||||
&& fail 'System Health still hands Users to GNOME, but Panama owns that page'
|
||||
rg -Fq 'SystemSettings.openGnomePanel("sharing")' "$settings_dir/HealthPage.qml" \
|
||||
&& fail 'System Health still hands Sharing to GNOME, but Panama owns that page'
|
||||
# Colour profiles left the card for the same reason Users and Sharing did:
|
||||
# Displays offers a colour profile, a bit depth, an SDR brightness and an SDR
|
||||
# saturation per output, which is more than GNOME's panel can say in a session
|
||||
# it does not manage. Digital wellbeing stays, because nothing here does it.
|
||||
rg -Fq 'SystemSettings.openGnomePanel("color")' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'Fedora ownership boundary lost the Color profiles handoff'
|
||||
&& fail 'System Health still hands Colour profiles to GNOME, but Displays owns them per output'
|
||||
rg -Fq 'SystemSettings.openGnomePanel("wellbeing")' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'Fedora ownership boundary lost the Digital wellbeing handoff'
|
||||
|
||||
# ── A repair says what it will run, before it runs it ───────────────────────
|
||||
#
|
||||
# "Restart Vicinae" and "Release duplicate inhibitors" are what the buttons
|
||||
# say. What they do is run a command as the person pressing them, and the row
|
||||
# never said which. Somebody who wants to know what a button is about to do to
|
||||
# their machine should not have to read panama-doctor to find out -- and after
|
||||
# the fact is not the same answer, because by then the decision is made.
|
||||
rg -Fq 'repairCommand' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'a repair row never shows the command it is about to run'
|
||||
python3 - "$settings_dir/HealthPage.qml" <<'PY' || fail 'the repair command is only shown once the repair has run, which is after the moment it was worth knowing'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
for line in text.splitlines():
|
||||
if "repairCommand" not in line:
|
||||
continue
|
||||
# Gating the command on a repair result, a repairing id, or a "working"
|
||||
# state makes it after-the-fact reassurance rather than a decision aid.
|
||||
if re.search(r'\b(lastRepair|repairingId|repairing|working|repairFailed)\b', line):
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
|
||||
# ── One row, re-checked; and the report saved rather than copied ────────────
|
||||
rg -Fq 'Health.refreshCheck(' "$settings_dir/HealthCheckRow.qml" \
|
||||
|| rg -Fq 'Health.refreshCheck(' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'no row offers a re-check, so a row fixed by hand stays wrong until the whole scan runs again'
|
||||
rg -Fq 'Health.saveReport(' "$settings_dir/HealthPage.qml" \
|
||||
|| rg -Fq 'Health.saveReport(' "$settings_dir/HealthSummary.qml" \
|
||||
|| fail 'the report can only be copied, never saved'
|
||||
# Exact authored handoffs are asserted above. Also prove every panel named by
|
||||
# this boundary is accepted by SystemSettings, so a typo cannot ship a dead
|
||||
# button even if its copy still looks correct.
|
||||
@@ -122,7 +158,9 @@ labels = (
|
||||
'if (status === "error") return "Action required";',
|
||||
'return "Not set up";',
|
||||
)
|
||||
assert all(label in page for label in labels)
|
||||
# The four status words belong to the row that shows them. They used to be
|
||||
# written on the page, one indirection away from the Text that rendered them.
|
||||
assert all(label in row for label in labels)
|
||||
assert 'group: "desktop-foundation"' in page
|
||||
assert 'group: "input-media"' in page
|
||||
assert 'group: "integrations"' in page
|
||||
@@ -140,7 +178,7 @@ assert 'pendingConfirmation' in page
|
||||
assert 'ddc-permissions' in page
|
||||
PY
|
||||
|
||||
fixture='{"schemaVersion":1,"generatedAt":"2026-08-18T12:00:00Z","summary":{"status":"error","healthy":2,"warnings":2,"errors":1,"unconfigured":1},"context":{"session":"hyprland","versions":[{"id":"quickshell","version":"0.3.0"}]},"checks":[{"id":"desktop.vicinae","group":"desktop-foundation","title":"Vicinae","status":"warning","detail":"The launcher service is stopped.","action":{"kind":"repair","label":"Restart Vicinae","confirm":false}},{"id":"desktop.quickshell","group":"desktop-foundation","title":"Quickshell","status":"error","detail":"Panama shell needs to restart.","action":{"kind":"repair","label":"Restart Panama","confirm":true}},{"id":"input.pipewire","group":"input-media","title":"PipeWire","status":"ok","detail":"Audio graph is responding."},{"id":"integration.bluebubbles","group":"integrations","title":"BlueBubbles","status":"unconfigured","detail":"Messaging integration has not been enabled."},{"id":"integration.calendar","group":"integrations","title":"Calendar","status":"warning","detail":"Calendar probe timed out.","action":{"kind":"open","label":"Open Date & Time","confirm":false,"target":"datetime"}},{"id":"panama.caffeine","group":"panama-tools","title":"Caffeine","status":"ok","detail":"No duplicate sleep inhibitors."}]}'
|
||||
fixture='{"schemaVersion":1,"generatedAt":"2026-08-18T12:00:00Z","summary":{"status":"error","healthy":2,"warnings":3,"errors":1,"unconfigured":1},"context":{"session":"hyprland","versions":[{"id":"quickshell","version":"0.3.0"}]},"checks":[{"id":"desktop.vicinae","group":"desktop-foundation","title":"Vicinae","status":"warning","detail":"The launcher service is stopped.","action":{"kind":"repair","label":"Restart Vicinae","confirm":false},"repairCommand":"systemctl --user restart vicinae.service"},{"id":"desktop.quickshell","group":"desktop-foundation","title":"Quickshell","status":"error","detail":"Panama shell needs to restart.","action":{"kind":"repair","label":"Restart Panama","confirm":true},"repairCommand":"panama-action restart-shell"},{"id":"input.pipewire","group":"input-media","title":"PipeWire","status":"ok","detail":"Audio graph is responding."},{"id":"integration.bluebubbles","group":"integrations","title":"BlueBubbles","status":"unconfigured","detail":"Messaging integration has not been enabled."},{"id":"integration.calendar","group":"integrations","title":"Calendar","status":"warning","detail":"Calendar probe timed out.","action":{"kind":"open","label":"Open Date & Time","confirm":false,"target":"datetime"}},{"id":"panama.caffeine","group":"panama-tools","title":"Caffeine","status":"ok","detail":"No duplicate sleep inhibitors."},{"id":"panama.updates","group":"panama-tools","title":"Software updates","status":"warning","detail":"3 pending updates carry a security advisory.","action":{"kind":"open","label":"Open Software Update","confirm":false,"target":"updates"}}]}'
|
||||
|
||||
state_home="$(mktemp -d /tmp/panama-health-ui.XXXXXX)"
|
||||
config_path="$state_home/quickshell"
|
||||
@@ -251,6 +289,39 @@ ShellRoot {
|
||||
});
|
||||
}
|
||||
|
||||
// The Software Update action, end to end: the doctor authors the
|
||||
// target, Health accepts it, and the row has to actually go there.
|
||||
function offerUpdatesAction(): bool {
|
||||
ShellState.settingsPage = "services";
|
||||
return Health.consumeSnapshot(JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
generatedAt: "2026-08-24T12:00:00Z",
|
||||
summary: { status: "warning", healthy: 0, warnings: 1, errors: 0, unconfigured: 0 },
|
||||
context: { session: "hyprland", versions: [] },
|
||||
checks: [{
|
||||
id: "panama.updates",
|
||||
group: "panama-tools",
|
||||
title: "Software updates",
|
||||
status: "warning",
|
||||
detail: "3 pending updates carry a security advisory.",
|
||||
action: {
|
||||
kind: "open",
|
||||
label: "Open Software Update",
|
||||
confirm: false,
|
||||
target: "updates"
|
||||
}
|
||||
}]
|
||||
}), Health.acceptedGeneration + 1);
|
||||
}
|
||||
|
||||
function followUpdatesAction(): string {
|
||||
const requested = settingsShell.requestHealthAction("panama.updates");
|
||||
return JSON.stringify({
|
||||
requested: requested,
|
||||
page: ShellState.settingsPage
|
||||
});
|
||||
}
|
||||
|
||||
function activateIndicator(): string {
|
||||
ShellState.settingsPage = "home";
|
||||
ShellState.settingsOpen = false;
|
||||
@@ -311,19 +382,19 @@ jq -e '
|
||||
{objectName:"health-check-row:issue:desktop.vicinae", id:"desktop.vicinae", section:"issue", statusText:"Needs attention"},
|
||||
{objectName:"health-check-row:issue:desktop.quickshell", id:"desktop.quickshell", section:"issue", statusText:"Action required"},
|
||||
{objectName:"health-check-row:issue:integration.calendar", id:"integration.calendar", section:"issue", statusText:"Needs attention"},
|
||||
{objectName:"health-check-row:issue:panama.updates", id:"panama.updates", section:"issue", statusText:"Needs attention"},
|
||||
{objectName:"health-check-row:quiet:input.pipewire", id:"input.pipewire", section:"quiet", statusText:"Healthy"},
|
||||
{objectName:"health-check-row:quiet:integration.bluebubbles", id:"integration.bluebubbles", section:"quiet", statusText:"Not set up"},
|
||||
{objectName:"health-check-row:quiet:panama.caffeine", id:"panama.caffeine", section:"quiet", statusText:"Healthy"}
|
||||
]
|
||||
and (.renderedRows | map(.id) | length) == 6
|
||||
and (.renderedRows | map(.id) | unique | length) == 6
|
||||
and (.renderedRows | map(.id) | length) == 7
|
||||
and (.renderedRows | map(.id) | unique | length) == 7
|
||||
and .emptyQuietGroups == ["desktop-foundation"]
|
||||
and .fedoraHandoffs == [
|
||||
{id:"color", label:"Color profiles", action:"Open color"},
|
||||
{id:"wellbeing", label:"Digital wellbeing", action:"Open wellbeing"}
|
||||
]
|
||||
and .summaryHeight == 126
|
||||
and (.rowHeights | length) == 6
|
||||
and (.rowHeights | length) == 7
|
||||
and (.rowHeights | all(. >= 62))
|
||||
and .checking == true
|
||||
and .checkingText == "Checking…"
|
||||
@@ -363,7 +434,7 @@ jq -e '.renderedRows[] | select(.id == "desktop.vicinae") | .statusText == "Repa
|
||||
>/dev/null <<<"$failed_repair_state" || fail "repair failure was not shown inline: $failed_repair_state"
|
||||
[[ "$(jq -c .rowHeights <<<"$failed_repair_state")" == "$settled_heights" ]] \
|
||||
|| fail 'repair failure changed row geometry'
|
||||
[[ "$(jq -r '.renderedRows | map(.id) | unique | length' <<<"$failed_repair_state")" == 6 ]] \
|
||||
[[ "$(jq -r '.renderedRows | map(.id) | unique | length' <<<"$failed_repair_state")" == 7 ]] \
|
||||
|| fail 'repair state duplicated a health action row'
|
||||
for _ in $(seq 1 40); do
|
||||
failed_repair_state="$(run ipc call health-ui-test state)"
|
||||
@@ -432,6 +503,32 @@ jq -e '
|
||||
and .refreshRequested == true
|
||||
' >/dev/null <<<"$activation_state" || fail "indicator activation did not open and refresh System Health: $activation_state"
|
||||
|
||||
# ── The Software Update action goes to Software Update ──────────────────────
|
||||
#
|
||||
# The two health checks that offered "Open Software Update" carried no target
|
||||
# at all, so the button rendered, focused, and did nothing -- the failure this
|
||||
# whole target mechanism exists to make impossible, sitting inside it. Fixing
|
||||
# it needed a change on both sides at once (panama-doctor authors the target,
|
||||
# Health.settingsTargets accepts it), and each half is pinned where it lives.
|
||||
# This is the third piece: the row acts on it.
|
||||
[[ "$(run ipc call health-ui-test offerUpdatesAction)" == "true" ]] \
|
||||
|| fail 'a check offering the Software Update action was rejected by the service'
|
||||
# Both conditions: the row rendered, and the scan the page starts when it loads
|
||||
# has settled. A row action is disabled while a check is running, which is
|
||||
# correct and would otherwise read here as a dead button.
|
||||
for _ in $(seq 1 60); do
|
||||
updates_state="$(run ipc call health-ui-test state)"
|
||||
jq -e '.checking == false and (.renderedRows | any(.id == "panama.updates"))' \
|
||||
>/dev/null <<<"$updates_state" && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.checking == false and (.renderedRows | any(.id == "panama.updates"))' \
|
||||
>/dev/null <<<"$updates_state" \
|
||||
|| fail "the Software Update check did not render on a settled page: $updates_state"
|
||||
follow_state="$(run ipc call health-ui-test followUpdatesAction)"
|
||||
jq -e '.requested == true and .page == "updates"' >/dev/null <<<"$follow_state" \
|
||||
|| fail "the Software Update action did not open Software Update: $follow_state"
|
||||
|
||||
if rg -i 'QQml|ReferenceError|TypeError|binding loop|failed to load component' "$shell_log"; then
|
||||
fail 'isolated fixture emitted QML errors or warnings'
|
||||
fi
|
||||
|
||||
@@ -24,6 +24,9 @@ set -uo pipefail
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
manual_dir="$repo_dir/config/dot/quickshell/manual"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/ManualPage.qml"
|
||||
# The chapter list and the titles live in one place that both the reader and
|
||||
# About's Manual card instantiate, so neither can drift from the other.
|
||||
contents="$repo_dir/config/dot/quickshell/modules/settings/ManualChapters.qml"
|
||||
routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml"
|
||||
shell_ui="$repo_dir/config/dot/quickshell/modules/settings/SettingsShell.qml"
|
||||
qmldir="$repo_dir/config/dot/quickshell/modules/settings/qmldir"
|
||||
@@ -40,16 +43,18 @@ note() { findings+=("$1"); }
|
||||
mapfile -t on_disk < <(find "$manual_dir" -maxdepth 1 -name '*.md' -printf '%f\n' | sort)
|
||||
(( ${#on_disk[@]} > 0 )) || note 'the manual has no chapters'
|
||||
|
||||
mapfile -t listed < <(grep -oE 'file: "[^"]+\.md"' "$page" | sed 's/file: "//; s/"//' | sort)
|
||||
(( ${#listed[@]} > 0 )) || note 'the manual page lists no chapters'
|
||||
[[ -r "$contents" ]] \
|
||||
|| note 'ManualChapters.qml is missing, so neither the reader nor About knows what the chapters are'
|
||||
mapfile -t listed < <(grep -oE 'file: "[^"]+\.md"' "$contents" | sed 's/file: "//; s/"//' | sort)
|
||||
(( ${#listed[@]} > 0 )) || note 'the chapter list names no chapters'
|
||||
|
||||
for file in "${on_disk[@]}"; do
|
||||
printf '%s\n' "${listed[@]}" | grep -qx "$file" \
|
||||
|| note "$file exists but the manual page never shows it"
|
||||
|| note "$file exists but the chapter list never names it"
|
||||
done
|
||||
for file in "${listed[@]}"; do
|
||||
[[ -r "$manual_dir/$file" ]] \
|
||||
|| note "the manual page lists $file, which does not exist, so that chapter renders an error"
|
||||
|| note "the chapter list names $file, which does not exist, so that chapter renders an error"
|
||||
done
|
||||
|
||||
# A chapter that is only a heading is a chapter somebody forgot to write.
|
||||
@@ -60,6 +65,43 @@ for file in "${on_disk[@]}"; do
|
||||
|| note "$file does not begin with a heading, so it has no title of its own"
|
||||
done
|
||||
|
||||
# ── 1b. The tab titles come from the files ───────────────────────────────────
|
||||
#
|
||||
# The page carried a comment saying the title shown is the first heading of
|
||||
# each chapter, "so a chapter cannot be renamed in one place and not the
|
||||
# other". It was not true: the titles were a second copy, written beside the
|
||||
# filenames, and the two agreed only because nobody had renamed anything yet.
|
||||
# A comment that describes a property the code does not have is worse than no
|
||||
# comment, because it is the reason the next person does not check.
|
||||
#
|
||||
# So: no authored label beside a chapter file, and something that actually
|
||||
# reads a leading heading out of one.
|
||||
python3 - "$contents" <<'PY' || note 'the chapter list still carries hand-written titles beside the filenames, so a renamed heading and a tab label can disagree'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
block = re.search(r'chapters:\s*\[(.*?)\n \]', text, re.S)
|
||||
if not block:
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(1 if re.search(r'label:\s*"', block.group(1)) else 0)
|
||||
PY
|
||||
grep -qE '\^#' "$contents" \
|
||||
|| note 'nothing reads a leading heading, so the chapter titles cannot be coming from the files'
|
||||
grep -q 'Quickshell.shellDir + "/manual/"' "$contents" \
|
||||
|| note 'the chapter titles are not read through the shell directory, so they would break on a clone elsewhere'
|
||||
grep -q 'ManualChapters {' "$page" \
|
||||
|| note 'the manual reader keeps its own idea of what the chapters are'
|
||||
|
||||
# And the titles the files offer have to be usable as tab labels: a first line
|
||||
# that is a paragraph would render a tab strip nobody can read.
|
||||
for file in "${on_disk[@]}"; do
|
||||
heading="$(head -1 "$manual_dir/$file" | sed 's/^#\+[[:space:]]*//')"
|
||||
[[ -n "$heading" ]] || note "$file has an empty first heading, so its tab would have no name"
|
||||
(( ${#heading} <= 40 )) \
|
||||
|| note "$file's first heading is ${#heading} characters; it is the tab label, so it has to be a title"
|
||||
done
|
||||
|
||||
# ── 2 & 3. How it renders ────────────────────────────────────────────────────
|
||||
|
||||
grep -q 'textFormat: Text.MarkdownText' "$page" \
|
||||
@@ -68,6 +110,75 @@ grep -q 'root.chapters\[root.current\]' "$page" \
|
||||
|| note 'the page does not render one chapter at a time; a single long Text goes blank rather than erroring'
|
||||
grep -q 'onLinkActivated' "$page" \
|
||||
|| note 'links in the manual do nothing when clicked'
|
||||
|
||||
# ── 3b. A link to a settings page opens the settings page ────────────────────
|
||||
#
|
||||
# The whole reason the manual is rendered inside Settings rather than in a
|
||||
# browser is that a chapter can point at a page and have that mean something.
|
||||
# Every link went to Qt.openUrlExternally, so "open Displays" handed a
|
||||
# panama:// URL to xdg-open, which has no handler for it: the click did
|
||||
# nothing, silently, which is the worst of the three possible outcomes.
|
||||
grep -q 'panama://settings/' "$page" \
|
||||
|| note 'the manual has no in-app link scheme, so a chapter cannot point at a settings page'
|
||||
python3 - "$page" <<'PY' || note 'the manual link handler does not route in-app links through ShellState.openSettings while still sending everything else out of the desktop'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
|
||||
|
||||
def block_at(index: int) -> str:
|
||||
"""The braced body starting at the first { on or after index."""
|
||||
start = text.index("{", index)
|
||||
depth = 0
|
||||
for position in range(start, len(text)):
|
||||
if text[position] == "{":
|
||||
depth += 1
|
||||
elif text[position] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return text[start:position + 1]
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
# Either shape is fine: the branch written inline, or delegated to a named
|
||||
# function on the page. What matters is what is on the path, so the path is
|
||||
# followed rather than one line being read and guessed at.
|
||||
handler = re.search(r'onLinkActivated:\s*link\s*=>\s*(?P<tail>.)', text)
|
||||
if not handler:
|
||||
raise SystemExit(1)
|
||||
if handler.group("tail") == "{":
|
||||
body = block_at(handler.start("tail"))
|
||||
else:
|
||||
called = re.match(r'(?:root\.)?([A-Za-z][A-Za-z0-9]*)\(', text[handler.start("tail"):])
|
||||
if not called:
|
||||
raise SystemExit(1)
|
||||
declaration = re.search(rf'function {called.group(1)}\s*\(', text)
|
||||
if not declaration:
|
||||
raise SystemExit(1)
|
||||
body = block_at(declaration.end())
|
||||
|
||||
raise SystemExit(0 if "ShellState.openSettings" in body
|
||||
and "Qt.openUrlExternally" in body
|
||||
and ("panama://settings/" in body or "linkScheme" in body) else 1)
|
||||
PY
|
||||
|
||||
# Every in-app link a chapter actually writes must name a page that exists.
|
||||
# A typo here is invisible: the scheme matches, the handler fires, and
|
||||
# openSettings resolves an unknown id to Home -- so the link works, just not
|
||||
# the way the sentence around it promised.
|
||||
leaves="$( {
|
||||
grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs: \[\] \}' "$routes"
|
||||
grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes"
|
||||
sed -n '/property var hiddenLeaves:/,/\]/p' "$routes" | grep -oE 'page: "[a-z-]+"'
|
||||
} | sed -E 's/.*page: "([a-z-]+)".*/\1/' | sort -u)"
|
||||
[[ -n "$leaves" ]] || note 'no leaves could be read from SettingsRoutes, so in-app manual links prove nothing'
|
||||
while read -r target; do
|
||||
[[ -n "$target" ]] || continue
|
||||
grep -qx "$target" <<<"$leaves" \
|
||||
|| note "a chapter links to panama://settings/$target, which is not a page anyone can land on"
|
||||
done < <(grep -rhoE 'panama://settings/[a-z-]+' "$manual_dir" \
|
||||
| sed -E 's|panama://settings/||' | sort -u)
|
||||
grep -q 'onLoadFailed' "$page" \
|
||||
|| note 'a chapter that cannot be read fails silently instead of saying so'
|
||||
|
||||
@@ -80,18 +191,31 @@ grep -q '\.\./\.\./\.\.' "$page" \
|
||||
&& note 'the manual path walks upward out of the shell directory, which is only correct by accident'
|
||||
|
||||
# ── 4. Registered everywhere a settings page has to be ───────────────────────
|
||||
# The manual is a tab of the System category rather than a sidebar row of its
|
||||
# own -- it is reference material, not a control surface, and it belongs beside
|
||||
# About for the same reason. SettingsRoutes is what makes it reachable at all:
|
||||
# a leaf missing from the taxonomy cannot be opened, searched, or linked to.
|
||||
# The manual is a routable leaf with no tab of its own. It was a tab of System
|
||||
# until the strip reached ten and had to come back down to eight, and it is the
|
||||
# right one to lose: it is reference material rather than a control surface, so
|
||||
# it is opened deliberately -- from About's Manual card, from a deep link, from
|
||||
# a search result -- rather than found by scanning a row of tabs.
|
||||
#
|
||||
# That makes the taxonomy entry the only thing holding it up. A leaf missing
|
||||
# from SettingsRoutes cannot be opened, searched, or linked to, and because it
|
||||
# draws no tab anywhere, nothing on screen would show it had gone.
|
||||
|
||||
grep -q '{ page: "manual", label: ' "$routes" || note 'the manual is not a leaf in SettingsRoutes, so nothing can navigate to it'
|
||||
python3 - "$routes" <<'PY' || note 'the manual is no longer a tab of the System category, so it has drifted out of the group it belongs to'
|
||||
import re, sys
|
||||
python3 - "$routes" <<'PY' || note 'the manual is not a hidden leaf in SettingsRoutes, so nothing can navigate to it'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
block = re.search(r'\{ page: "system",.*?tabs: \[(.*?)\] \}', text, re.S)
|
||||
raise SystemExit(0 if block and '{ page: "manual"' in block.group(1) else 1)
|
||||
block = re.search(r'hiddenLeaves:\s*\[(.*?)\]', text, re.S)
|
||||
raise SystemExit(0 if block and re.search(r'page: "manual"', block.group(1)) else 1)
|
||||
PY
|
||||
python3 - "$routes" <<'PY' || note 'the manual is a System tab again, taking back a slot the eight-tab strip does not have'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
block = re.search(r'\{ page: "system",.*?tabs: \[(.*?)\n \] \}', text, re.S)
|
||||
raise SystemExit(1 if block and '{ page: "manual"' in block.group(1) else 0)
|
||||
PY
|
||||
grep -q 'case "manual": return manualPage;' "$shell_ui" || note 'SettingsShell does not route to the manual'
|
||||
grep -q 'Component { id: manualPage; ManualPage {} }' "$shell_ui" || note 'SettingsShell never declares the manual component'
|
||||
@@ -102,6 +226,21 @@ grep -q '^ManualPage 1.0 ManualPage.qml$' "$qmldir" || note 'ManualPage is not r
|
||||
grep -q 'SettingsRoutes.resolve(' "$state" || note 'ShellState does not resolve pages through SettingsRoutes, so openSettings("manual") has no defined destination'
|
||||
grep -q 'page: "manual"' "$search" || note 'the manual is not searchable from the settings search box'
|
||||
|
||||
# With no tab of its own, About's Manual card is the only place the manual is
|
||||
# offered rather than looked up. If that card stops opening it, the page is
|
||||
# still reachable in principle and undiscoverable in practice.
|
||||
about="$repo_dir/config/dot/quickshell/modules/settings/AboutPage.qml"
|
||||
if [[ -r "$about" ]]; then
|
||||
grep -q 'openSettings("manual"' "$about" \
|
||||
|| note 'About does not open the manual, which is now the only place it is offered rather than searched for'
|
||||
grep -q 'ManualChapters {' "$about" \
|
||||
|| note 'About keeps its own idea of what the chapters are, so its card and the reader can disagree'
|
||||
grep -qE 'file: "[^"]+\.md"' "$about" \
|
||||
&& note 'About names chapter files itself instead of reading the shared list'
|
||||
else
|
||||
note 'AboutPage.qml is missing, so the manual has nowhere to be opened from'
|
||||
fi
|
||||
|
||||
if (( ${#findings[@]} > 0 )); then
|
||||
printf 'manual contract: %d finding(s)\n' "${#findings[@]}" >&2
|
||||
printf ' - %s\n' "${findings[@]}" >&2
|
||||
|
||||
@@ -53,10 +53,16 @@ while read -r page; do
|
||||
[[ -n "$page" ]] || continue
|
||||
[[ "$page" == "home" ]] && continue
|
||||
expected[settings-$page]="settings-page $page"
|
||||
#
|
||||
# Hidden leaves count too. The manual has no tab of its own -- it is reference
|
||||
# material rather than a control surface -- but "manual" is exactly the sort of
|
||||
# thing somebody types into a launcher, and a leaf with no tab is the one leaf
|
||||
# with nowhere else to be found by scanning.
|
||||
done < <( {
|
||||
grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs: \[\] \}' "$routes"
|
||||
grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes"
|
||||
} | sed -E 's/\{ page: "([a-z-]+)".*/\1/' | sort -u)
|
||||
sed -n '/property var hiddenLeaves:/,/\]/p' "$routes" | grep -oE 'page: "[a-z-]+"'
|
||||
} | sed -E 's/.*page: "([a-z-]+)".*/\1/' | sort -u)
|
||||
|
||||
(( ${#expected[@]} > 18 )) || fail 'no generated per-page commands were found; run scripts/panama-settings-commands'
|
||||
|
||||
|
||||
@@ -33,8 +33,13 @@ state_home="$home/.local/state"
|
||||
runtime_dir="$fixture/runtime"
|
||||
bin_dir="$fixture/bin"
|
||||
data_home="$home/.local/share"
|
||||
# The updates check reads the Updates page's cache out of XDG_CACHE_HOME rather
|
||||
# than through DoctorConfig, so without this the fixture would read the real
|
||||
# machine's update state and report whatever happened to be pending today.
|
||||
cache_home="$home/.cache"
|
||||
|
||||
mkdir -p "$config_home" "$state_home" "$runtime_dir" "$bin_dir" "$data_home/vicinae/scripts"
|
||||
mkdir -p "$config_home" "$state_home" "$runtime_dir" "$bin_dir" "$data_home/vicinae/scripts" \
|
||||
"$cache_home/panama"
|
||||
|
||||
# A mount table with the document portal present, which is the healthy state the
|
||||
# rest of this file assumes. Written rather than read from /proc so the contract
|
||||
@@ -114,6 +119,7 @@ run_doctor() {
|
||||
HOME="$home" \
|
||||
PATH="$bin_dir" \
|
||||
XDG_CURRENT_DESKTOP=Hyprland \
|
||||
XDG_CACHE_HOME="$cache_home" \
|
||||
PANAMA_HOME_ASSISTANT_URL='https://fixture.invalid' \
|
||||
PANAMA_HOME_ASSISTANT_TOKEN='fixture-secret-token' \
|
||||
PANAMA_DOCTOR_ROOT="$repo_dir" \
|
||||
@@ -803,4 +809,177 @@ for rejected_id in unknown.check integration.home-assistant input.brightness \
|
||||
[[ "$(fixture_state)" == "$before_state" ]] || fail "$rejected_id mutated the filesystem"
|
||||
done
|
||||
|
||||
# ── Every check in the order has a title ────────────────────────────────────
|
||||
#
|
||||
# `unavailable_check` is the doctor's own containment: a probe that raises
|
||||
# becomes a warning row rather than a crashed report. It reads the title out of
|
||||
# CHECK_TITLES by key, and `panama.updates` was never added there -- so the one
|
||||
# path that exists to keep a failing probe from taking the report down was
|
||||
# itself a KeyError, raised inside the `except` handler that was catching the
|
||||
# original failure, from where it escaped `collect_checks`, hit `snapshot`'s
|
||||
# outer guard, and raised again building the all-unavailable fallback.
|
||||
#
|
||||
# Nobody saw it, because it needs the updates probe to fail, and the updates
|
||||
# probe reads a cache file and almost never does. Both halves are asserted:
|
||||
# the invariant, which is cheap and total, and the path, which is the one that
|
||||
# actually ran.
|
||||
/usr/bin/python3 - "$doctor" <<'PY' || fail 'a check in CHECK_ORDER has no title, so a failing probe raises KeyError from inside the handler that exists to contain it'
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import sys
|
||||
|
||||
loader = importlib.machinery.SourceFileLoader("panama_doctor_titles", sys.argv[1])
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
loader.exec_module(module)
|
||||
|
||||
missing = [check for check in module.CHECK_ORDER if check not in module.CHECK_TITLES]
|
||||
if missing:
|
||||
raise SystemExit(f"no title for {missing}")
|
||||
for check in module.CHECK_ORDER:
|
||||
module.unavailable_check(check)
|
||||
PY
|
||||
|
||||
/usr/bin/python3 - "$doctor" "$fixture/unavailable-home" <<'PY' || fail 'a probe that raises does not become a warning row; it takes the whole report with it'
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
doctor_path, home_text = sys.argv[1:]
|
||||
loader = importlib.machinery.SourceFileLoader("panama_doctor_unavailable", doctor_path)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
loader.exec_module(module)
|
||||
|
||||
home = Path(home_text)
|
||||
home.mkdir(parents=True)
|
||||
config = module.DoctorConfig(home, home, home / "config", home / "state", home / "runtime", "", 0.05)
|
||||
|
||||
|
||||
def raising(_config):
|
||||
raise TimeoutError("fixture probe timeout")
|
||||
|
||||
|
||||
module.check_updates = raising
|
||||
report = module.snapshot(config)
|
||||
ids = [check["id"] for check in report["checks"]]
|
||||
if ids != list(module.CHECK_ORDER):
|
||||
raise SystemExit("a raising probe changed the shape of the report")
|
||||
row = next(check for check in report["checks"] if check["id"] == "panama.updates")
|
||||
if row["status"] != "warning" or not row["title"]:
|
||||
raise SystemExit(f"the contained row is not a titled warning: {row}")
|
||||
PY
|
||||
|
||||
# ── The updates check knows where Software Update is ────────────────────────
|
||||
#
|
||||
# Two of its four states offered "Open Software Update" with no target, so the
|
||||
# button rendered and did nothing. Adding the target is only half the change --
|
||||
# Health.settingsTargets has to accept it, or the whole report is rejected --
|
||||
# and health-service-contract pins the other half against this same file.
|
||||
/usr/bin/python3 - "$cache_home/panama/updates.json" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
json.dump({
|
||||
"checkedAt": int(time.time()),
|
||||
"dnf": {"count": 5, "securityCount": 3},
|
||||
"flatpak": {"count": 0},
|
||||
"firmware": {"count": 0},
|
||||
}, open(sys.argv[1], "w"))
|
||||
PY
|
||||
pending_updates="$(run_doctor --json)"
|
||||
check_status "$pending_updates" panama.updates warning
|
||||
jq -e '.checks[] | select(.id == "panama.updates")
|
||||
| .action == {kind:"open", label:"Open Software Update", confirm:false, target:"updates"}' \
|
||||
>/dev/null <<<"$pending_updates" || fail 'the updates action carries no Settings target, so the button does nothing'
|
||||
assert_schema_and_redaction "$pending_updates"
|
||||
|
||||
# ── A repair row says what it will run ──────────────────────────────────────
|
||||
#
|
||||
# The command is the literal from REPAIR_COMMANDS and nothing else: derived
|
||||
# from the table rather than restated here, so a repair whose argv changes
|
||||
# cannot leave the row describing the old one, and probe output can never reach
|
||||
# this field.
|
||||
repairable="$(PANAMA_DOCTOR_MOUNTINFO="$fixture/mountinfo-unmounted" run_doctor --json)"
|
||||
check_status "$repairable" desktop.document-portal warning
|
||||
printf '%s' "$repairable" >"$fixture/repairable-report.json"
|
||||
/usr/bin/python3 - "$doctor" "$fixture/repairable-report.json" <<'PY' || fail 'a repairable check does not carry the exact authored repair command'
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
|
||||
loader = importlib.machinery.SourceFileLoader("panama_doctor_repair_command", sys.argv[1])
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
loader.exec_module(module)
|
||||
|
||||
report = json.load(open(sys.argv[2], encoding="utf-8"))
|
||||
expected = {check: " ".join(command) for check, command in module.REPAIR_COMMANDS.items()}
|
||||
|
||||
seen = 0
|
||||
for check in report["checks"]:
|
||||
rendered = check.get("repairCommand")
|
||||
if check["id"] in expected and check.get("action", {}).get("kind") == "repair":
|
||||
if rendered != expected[check["id"]]:
|
||||
raise SystemExit(f'{check["id"]} shows {rendered!r}, expected {expected[check["id"]]!r}')
|
||||
seen += 1
|
||||
elif rendered is not None and rendered != expected.get(check["id"]):
|
||||
raise SystemExit(f'{check["id"]} shows a command that is not its authored one: {rendered!r}')
|
||||
if not seen:
|
||||
raise SystemExit("no repairable check carried a command, so this proves nothing")
|
||||
PY
|
||||
|
||||
# ── One check, asked for on its own ─────────────────────────────────────────
|
||||
#
|
||||
# The whole scan is thirty probes. Re-checking the one row somebody just fixed
|
||||
# should not wait on the other twenty-nine, so there is a verb that runs one --
|
||||
# and it answers in the same envelope as a full report, because the consumer is
|
||||
# the same parser and a second response shape is a second thing to get wrong.
|
||||
single="$(run_doctor check desktop.hyprpaper)" || fail 'a single-check run failed'
|
||||
jq -e '.schemaVersion == 1
|
||||
and (.generatedAt | type == "string")
|
||||
and (.summary.status | IN("healthy", "warning", "error"))
|
||||
and (.context.session | IN("hyprland", "other"))
|
||||
and (.context.versions | type == "array")
|
||||
and (.checks | length) == 1
|
||||
and (.checks[0] | has("id") and has("group") and has("title") and has("status") and has("detail"))
|
||||
and .checks[0].id == "desktop.hyprpaper"' \
|
||||
>/dev/null <<<"$single" || fail "a single check is not the full report shape: $single"
|
||||
[[ "$(jq -r '.summary | [.healthy, .warnings, .errors, .unconfigured] | add' <<<"$single")" == "1" ]] \
|
||||
|| fail 'the single-check summary counts something other than the one check it ran'
|
||||
! grep -Fq 'fixture-secret-token' <<<"$single" || fail 'a single check exposed a fixture secret'
|
||||
|
||||
# An id nobody authored is refused rather than answered with an empty
|
||||
# snapshot, which would look valid and say nothing. The refusal is an exit
|
||||
# status and a message, not a report: a caller that asked for a check that does
|
||||
# not exist has a bug, and handing it a well-formed reply hides it.
|
||||
for rejected_id in unknown.check ../../escape 'desktop.vicinae;touch injected' ''; do
|
||||
before_state="$(fixture_state)"
|
||||
set +e
|
||||
single_rejected="$(run_doctor check "$rejected_id" 2>"$fixture/single-check-error")"
|
||||
single_status=$?
|
||||
set -e
|
||||
[[ "$single_status" != 0 ]] \
|
||||
|| fail "the single-check verb accepted \"$rejected_id\""
|
||||
[[ -z "$single_rejected" ]] \
|
||||
|| fail "the single-check verb printed a report for \"$rejected_id\": $single_rejected"
|
||||
[[ -s "$fixture/single-check-error" ]] \
|
||||
|| fail "the single-check verb refused \"$rejected_id\" without saying why"
|
||||
rm -f "$fixture/single-check-error"
|
||||
[[ "$(fixture_state)" == "$before_state" ]] \
|
||||
|| fail "the single-check verb mutated the filesystem for \"$rejected_id\""
|
||||
done
|
||||
|
||||
# And it is not a second way to run the whole scan, or to run a repair.
|
||||
run_doctor check desktop.hyprpaper --summary >/dev/null 2>&1 \
|
||||
&& fail 'the single-check verb accepted a second output mode'
|
||||
run_doctor check desktop.vicinae --repair desktop.vicinae >/dev/null 2>&1 \
|
||||
&& fail 'the single-check verb accepted a repair alongside it'
|
||||
|
||||
printf 'panama doctor contract: PASS\n'
|
||||
|
||||
@@ -198,6 +198,102 @@ jq -e 'has("pwned") | not' "$settings" >/dev/null || fail 'a file outside the ba
|
||||
# ── A snapshot that is not listed is refused ─────────────────────────────────
|
||||
run restore "settings-20000101-000000000.json" >/dev/null 2>&1 && fail 'a missing snapshot was reported restored'
|
||||
|
||||
# ── A snapshot can be given a name, and the name cannot be a path ────────────
|
||||
#
|
||||
# Fifteen rows reading "2026-08-24 11:03:07" are fifteen rows nobody can choose
|
||||
# between, which is the same as having no backups: the one you want is the one
|
||||
# you took before the thing you are undoing, and the timestamp does not say
|
||||
# what that was. So `create` takes a name.
|
||||
#
|
||||
# A name typed by a person then reaches a filename, which is the oldest way a
|
||||
# helper gets talked into writing outside its own directory. The charset is the
|
||||
# snapshot charset -- letters, digits, dot, dash, underscore -- and everything
|
||||
# else is either sanitized out or refused; either is fine, writing outside
|
||||
# BACKUP_DIR is not.
|
||||
|
||||
rm -f "$backups"/*.json
|
||||
printf '{"gapsOut":24}' >"$settings"
|
||||
printf '{"initialized":true,"favorites":[]}' >"$home"
|
||||
|
||||
run create >/dev/null || fail 'create without a name failed'
|
||||
[[ "$(run list | jq 'length')" == "1" ]] || fail 'an unnamed create did not produce one snapshot'
|
||||
|
||||
run create "Before the theme experiment" >/dev/null || fail 'create with a name failed'
|
||||
named="$(run list | jq -r '.[0]')"
|
||||
jq -e '.label == "Before the theme experiment"' <<<"$named" >/dev/null \
|
||||
|| fail "a named snapshot did not carry its name back: $named"
|
||||
named_file="$(jq -r .name <<<"$named")"
|
||||
[[ "$named_file" =~ ^[A-Za-z0-9_.-]+$ ]] \
|
||||
|| fail "a named snapshot produced the filename \"$named_file\", which is outside the snapshot charset"
|
||||
|
||||
# Every one of these either fails or produces a file inside the backup
|
||||
# directory. None of them may produce a file anywhere else, and none may
|
||||
# remove or overwrite something that is not a snapshot.
|
||||
outside="$work/state/panama/NOT-A-BACKUP"
|
||||
printf 'untouched\n' >"$outside"
|
||||
for hostile in \
|
||||
'../../../NOT-A-BACKUP' \
|
||||
'/etc/panama-owned' \
|
||||
'a/b' \
|
||||
'..' \
|
||||
'.' \
|
||||
$'tab\there' \
|
||||
'-rf'; do
|
||||
run create "$hostile" >/dev/null 2>&1 || true
|
||||
[[ "$(<"$outside")" == 'untouched' ]] \
|
||||
|| fail "the snapshot name \"$hostile\" wrote outside the backup directory"
|
||||
done
|
||||
while read -r listed; do
|
||||
[[ -n "$listed" ]] || continue
|
||||
[[ "$listed" =~ ^[A-Za-z0-9_.-]+$ ]] \
|
||||
|| fail "a hostile snapshot name produced the listed filename \"$listed\""
|
||||
done < <(run list | jq -r '.[].name')
|
||||
|
||||
# ── A snapshot can be deleted, and only a snapshot ───────────────────────────
|
||||
#
|
||||
# Same boundary as restore, in the other direction, and with a worse failure:
|
||||
# restore reading the wrong file overwrites settings, delete resolving the
|
||||
# wrong name destroys something that is not a backup at all.
|
||||
|
||||
before_delete="$(run list | jq 'length')"
|
||||
(( before_delete >= 2 )) || fail 'not enough snapshots to exercise delete'
|
||||
victim="$(run list | jq -r '.[0].name')"
|
||||
run delete "$victim" >/dev/null || fail 'deleting a listed snapshot failed'
|
||||
[[ ! -e "$backups/$victim" ]] || fail 'a deleted snapshot is still on disk'
|
||||
[[ "$(run list | jq 'length')" == "$((before_delete - 1))" ]] \
|
||||
|| fail 'delete removed a different number of snapshots than one'
|
||||
|
||||
printf '{"pwned":false}' >"$work/delete-target.json"
|
||||
for hostile in \
|
||||
'../../delete-target.json' \
|
||||
'/etc/passwd' \
|
||||
"$victim" \
|
||||
'settings-20000101-000000000.json' \
|
||||
'' ; do
|
||||
run delete "$hostile" >/dev/null 2>&1 \
|
||||
&& fail "delete accepted \"$hostile\", which is not a snapshot in the backup directory"
|
||||
done
|
||||
[[ -f "$work/delete-target.json" ]] || fail 'delete followed a traversing name out of the backup directory'
|
||||
|
||||
escape="settings-20000101-000000009.json"
|
||||
ln -s "$work/delete-target.json" "$backups/$escape"
|
||||
run delete "$escape" >/dev/null 2>&1 \
|
||||
&& fail 'delete accepted a symlink escaping the backup directory'
|
||||
[[ -f "$work/delete-target.json" ]] || fail 'delete removed the target of an escaping symlink'
|
||||
rm -f "$backups/$escape"
|
||||
|
||||
# ── The list says how much room a snapshot takes ─────────────────────────────
|
||||
#
|
||||
# Fifteen snapshots of a settings file are nothing; fifteen of a settings file
|
||||
# somebody grew are not, and the page offers a Delete button now, which is a
|
||||
# decision nobody can make without the size.
|
||||
run list | jq -e 'all(.[]; (.bytes | type == "number") and .bytes > 0)' >/dev/null \
|
||||
|| fail 'the snapshot list does not report each snapshot’s size'
|
||||
listed_bytes="$(run list | jq -r '.[0].bytes')"
|
||||
actual_bytes="$(stat -c %s "$backups/$(run list | jq -r '.[0].name')")"
|
||||
[[ "$listed_bytes" == "$actual_bytes" ]] \
|
||||
|| fail "the list reports $listed_bytes bytes for a snapshot that is $actual_bytes on disk"
|
||||
|
||||
# ── Snapshots are capped ─────────────────────────────────────────────────────
|
||||
for _ in $(seq 1 20); do
|
||||
printf '{"n":%s}' "$RANDOM" >"$settings"
|
||||
|
||||
@@ -173,6 +173,91 @@ status="$(qs_test ipc call settings-backup-behavior status)"
|
||||
jq -e '.calls == [] and (.lastError | contains("display change"))' <<<"$status" >/dev/null \
|
||||
|| fail "display-busy restore refusal was not clean: $status"
|
||||
|
||||
# ── The colour half of a display layout ─────────────────────────────────────
|
||||
#
|
||||
# Displays persists eleven fields per output. A restore read seven of them.
|
||||
#
|
||||
# The four it dropped -- VRR mode, colour profile, bit depth, SDR brightness
|
||||
# and saturation, plus the mirror source -- are the ones nobody notices going
|
||||
# missing, because the picture is still there and it is still the right size.
|
||||
# Worse, `layoutsEqual` compared the same seven, so a snapshot whose colour
|
||||
# settings differed from the live state compared EQUAL: the restore took the
|
||||
# early return, decided there was nothing to apply, and reported success while
|
||||
# leaving HDR off. A silent no-op that says it worked is the failure mode this
|
||||
# whole codebase keeps relearning, and this is it in the one place where the
|
||||
# evidence is a monitor looking slightly wrong.
|
||||
#
|
||||
# Both halves are asserted, because fixing either alone leaves the bug: reading
|
||||
# the fields without comparing them means the restore never runs; comparing
|
||||
# them without reading them means it runs and applies nothing.
|
||||
|
||||
full='{
|
||||
"DP-2": {"mode":"4500x3000@60","scale":1.5,"transform":0,"x":-2560,"y":0,"primary":false,
|
||||
"vrrMode":2,"colorProfile":"hdr","bitdepth":10,"sdrBrightness":1.2,
|
||||
"sdrSaturation":0.9,"mirrorOf":""},
|
||||
"HDMI-A-1": {"mode":"2560x1440@60","scale":1,"transform":0,"x":0,"y":0,"primary":true,
|
||||
"vrrMode":0,"colorProfile":"srgb","bitdepth":8,"sdrBrightness":1,
|
||||
"sdrSaturation":1,"mirrorOf":"DP-2"}
|
||||
}'
|
||||
plain='{
|
||||
"DP-2": {"mode":"4500x3000@60","scale":1.5,"transform":0,"x":-2560,"y":0,"primary":false},
|
||||
"HDMI-A-1": {"mode":"2560x1440@60","scale":1,"transform":0,"x":0,"y":0,"primary":true}
|
||||
}'
|
||||
|
||||
qs_test ipc call settings-backup-behavior reset >/dev/null
|
||||
|
||||
restored="$(qs_test ipc call settings-backup-behavior layoutFor "$(jq -c . <<<"$full")")"
|
||||
jq -e '
|
||||
(. != null) and (length == 2)
|
||||
and (.[] | select(.name == "DP-2")
|
||||
| .vrrMode == 2 and .colorProfile == "hdr" and .bitdepth == 10
|
||||
and .sdrBrightness == 1.2 and .sdrSaturation == 0.9 and .mirrorOf == "")
|
||||
' <<<"$restored" >/dev/null \
|
||||
|| fail "a stored layout's colour fields did not survive the trip back into a live layout: $restored"
|
||||
jq -e '.[] | select(.name == "HDMI-A-1") | .mirrorOf == "DP-2" and .bitdepth == 8' \
|
||||
<<<"$restored" >/dev/null \
|
||||
|| fail "the mirror source did not survive the trip back into a live layout: $restored"
|
||||
|
||||
# Back-compat, in the same shape Displays.isPersistedLayoutEntry uses: a record
|
||||
# written before these fields existed is still a record. Refusing it would turn
|
||||
# every snapshot anybody already has into one that cannot be restored.
|
||||
legacy="$(qs_test ipc call settings-backup-behavior layoutFor "$(jq -c . <<<"$plain")")"
|
||||
jq -e '(. != null) and (length == 2) and (.[] | select(.name == "DP-2") | .scale == 1.5)' \
|
||||
<<<"$legacy" >/dev/null \
|
||||
|| fail "a stored layout without the colour fields was refused, so older snapshots cannot be restored: $legacy"
|
||||
|
||||
# And the comparison. Two layouts identical but for a colour profile are not
|
||||
# equal -- this is the assertion the shipped behaviour FAILS, and the reason
|
||||
# the restore silently did nothing.
|
||||
# The two layouts travel as one object: the IPC client turns a top-level JSON
|
||||
# array into one argument per element, so passing two layouts as two arguments
|
||||
# arrives as four.
|
||||
compare() {
|
||||
qs_test ipc call settings-backup-behavior layoutsMatch \
|
||||
"$(jq -c -n --argjson left "$1" --argjson right "$2" '{left: $left, right: $right}')"
|
||||
}
|
||||
|
||||
drifted="$(jq -c '.[0].colorProfile = "srgb"' <<<"$restored")"
|
||||
[[ "$(compare "$restored" "$drifted")" == "false" ]] \
|
||||
|| fail 'layoutsEqual calls two layouts equal when their colour profiles differ, so a restore that should change the picture takes the early return and reports success'
|
||||
for field in vrrMode bitdepth sdrBrightness sdrSaturation mirrorOf; do
|
||||
case "$field" in
|
||||
mirrorOf) drifted="$(jq -c --arg f "$field" '.[0][$f] = "HDMI-A-1"' <<<"$restored")" ;;
|
||||
*) drifted="$(jq -c --arg f "$field" '.[0][$f] = 7' <<<"$restored")" ;;
|
||||
esac
|
||||
[[ "$(compare "$restored" "$drifted")" == "false" ]] \
|
||||
|| fail "layoutsEqual ignores $field, so a snapshot differing only in it restores nothing"
|
||||
done
|
||||
[[ "$(compare "$restored" "$restored")" == "true" ]] \
|
||||
|| fail 'layoutsEqual no longer calls a layout equal to itself, which would make every restore reapply the geometry it already has'
|
||||
|
||||
# The tolerance on the two float fields is a tolerance, not an exemption: a
|
||||
# value that came back one ulp different is the same value, a value somebody
|
||||
# changed is not.
|
||||
nudged="$(jq -c '.[0].sdrBrightness = 1.2000001' <<<"$restored")"
|
||||
[[ "$(compare "$restored" "$nudged")" == "true" ]] \
|
||||
|| fail 'a float that came back with a rounding difference is treated as a change, so every restore would reapply the layout it already has'
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'settings backup live contract: PASS\n'
|
||||
|
||||
@@ -52,11 +52,33 @@ tabless_ids="$(grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs
|
||||
tab_ids="$(grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes" \
|
||||
| sed -E 's/\{ page: "([a-z-]+)".*/\1/')"
|
||||
|
||||
# A third shape: a leaf that is reachable but is not a tab of anything. The
|
||||
# manual is the only one -- it is reference material opened from About's Manual
|
||||
# card and from deep links, not a control surface worth a permanent slot in the
|
||||
# System strip. It still has to resolve, still has to have a SettingsShell case,
|
||||
# and still has to be addressable by every caller holding its id, so it is a
|
||||
# leaf for every purpose below except appearing in a tab strip.
|
||||
hidden_block="$(sed -n '/property var hiddenLeaves:/,/\]/p' "$routes")"
|
||||
hidden_ids="$(grep -oE 'page: "[a-z-]+"' <<<"$hidden_block" | sed -E 's/page: "([a-z-]+)"/\1/')"
|
||||
|
||||
[[ -n "$category_ids" ]] || fail 'no categories found in SettingsRoutes -- this contract is not reading it correctly'
|
||||
[[ -n "$tab_ids" ]] || fail 'no tabs found in SettingsRoutes -- this contract is not reading it correctly'
|
||||
[[ -n "$hidden_ids" ]] \
|
||||
|| fail 'no hiddenLeaves found in SettingsRoutes -- the manual is a routable non-tab leaf and this contract is not reading the mechanism that makes it one'
|
||||
|
||||
# The leaves: every page a person can actually land on.
|
||||
leaves="$(printf '%s\n%s\n' "$tabless_ids" "$tab_ids" | sed '/^$/d')"
|
||||
leaves="$(printf '%s\n%s\n%s\n' "$tabless_ids" "$tab_ids" "$hidden_ids" | sed '/^$/d')"
|
||||
|
||||
# A hidden leaf that is also a tab, or also a tabless category, puts one page
|
||||
# in two places -- and unlike the tab/category overlap below, nothing visible
|
||||
# would show it, because the hidden half draws no row anywhere.
|
||||
while read -r page; do
|
||||
[[ -n "$page" ]] || continue
|
||||
grep -qx "$page" <<<"$tab_ids" \
|
||||
&& fail "\"$page\" is a hidden leaf and also a tab, so the same page id names two different places"
|
||||
grep -qx "$page" <<<"$tabless_ids" \
|
||||
&& fail "\"$page\" is a hidden leaf and also a category of its own"
|
||||
done <<<"$hidden_ids"
|
||||
|
||||
# ── The taxonomy addresses each leaf exactly once ─────────────────────────────
|
||||
# ShellState.settingsPage holds a leaf id, and the sidebar highlights the
|
||||
@@ -103,6 +125,50 @@ while read -r pair; do
|
||||
fi
|
||||
done <<<"$retired_pairs"
|
||||
|
||||
# ── The System strip is eight tabs, and the two it lost are still reachable ──
|
||||
#
|
||||
# System had grown to ten tabs, which is more than a strip can show without
|
||||
# becoming a second sidebar. Two left: Region & Language merged into Date &
|
||||
# Time, because a date format and the clock that shows it are one subject, and
|
||||
# the Manual became a hidden leaf.
|
||||
#
|
||||
# The count is pinned rather than derived because the number is the point: this
|
||||
# is the horizontal space one row of tabs has. Anything that needs an eleventh
|
||||
# subject needs a decision, not another entry.
|
||||
python3 - "$routes" <<'PY' || fail 'the System category is not the approved eight-tab strip'
|
||||
import re
|
||||
import sys
|
||||
|
||||
expected = [
|
||||
"about", "updates", "services", "storage",
|
||||
"snapshots", "containers", "datetime", "sync",
|
||||
]
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
block = re.search(r'\{ page: "system",.*?tabs: \[(.*?)\n \] \}', text, re.S)
|
||||
if not block:
|
||||
raise SystemExit("the System category could not be read")
|
||||
found = re.findall(r'\{ page: "([a-z-]+)", label: "[^"]*" \}', block.group(1))
|
||||
if found != expected:
|
||||
raise SystemExit(f"System tabs are {found}, expected {expected}")
|
||||
PY
|
||||
|
||||
# `region` retiring is only safe because every caller still holding it lands on
|
||||
# the tab that absorbed it. The generic retired-map check above proves the
|
||||
# target is a leaf; this proves it is the RIGHT leaf, which is the half a
|
||||
# rename cannot get wrong quietly.
|
||||
grep -qE '"region"[[:space:]]*:[[:space:]]*"datetime"' <<<"$retired_block" \
|
||||
|| fail 'the retired "region" id does not resolve to "datetime", so every Vicinae command, deep link, and search result holding it lands on Home'
|
||||
[[ ! -e "$settings_dir/RegionPage.qml" ]] \
|
||||
|| fail 'RegionPage.qml still exists, so the retired route has a live page behind it after all'
|
||||
|
||||
# The manual is a leaf but not a tab. Said both ways: a strip entry would put
|
||||
# reference material back in the System strip that the merge just freed, and
|
||||
# losing the leaf would break About's Manual card and every deep link.
|
||||
grep -qx 'manual' <<<"$hidden_ids" \
|
||||
|| fail 'the manual is not a hidden leaf, so opening it from About or a deep link has no destination'
|
||||
grep -qx 'manual' <<<"$tab_ids" \
|
||||
&& fail 'the manual is a tab again, which is the System strip slot the consolidation just freed'
|
||||
|
||||
# ── Every leaf resolves everywhere ───────────────────────────────────────────
|
||||
while read -r page; do
|
||||
[[ -n "$page" ]] || continue
|
||||
@@ -153,5 +219,68 @@ while read -r page_file; do
|
||||
|| 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 (%d categories, %d leaves, %d retired ids)\n' \
|
||||
"$(grep -c . <<<"$category_ids")" "$(grep -c . <<<"$leaves")" "$retired_count"
|
||||
# ── The search index lands on leaves, not on ids that used to be leaves ──────
|
||||
#
|
||||
# SettingsSearch is the fifth place a page id is written down, and the only one
|
||||
# where being wrong is silent: `resolve()` turns anything it does not recognise
|
||||
# into Home, so a result whose page id was retired still opens a window, still
|
||||
# looks like it worked, and lands somewhere else. That is exactly what the two
|
||||
# Region & Language entries would have done -- and they are the entries most
|
||||
# likely to be searched for by somebody who could not find the setting.
|
||||
#
|
||||
# Checked against the leaves this file already derived, so a page consolidated
|
||||
# next time cannot leave a search result pointing at its old name.
|
||||
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
|
||||
[[ -r "$search" ]] || fail "cannot read $search"
|
||||
while read -r page; do
|
||||
[[ -n "$page" ]] || continue
|
||||
grep -qx "$page" <<<"$leaves" && continue
|
||||
if grep -qE "\"[a-z-]+\"[[:space:]]*:[[:space:]]*\"$page\"" <<<"$retired_block"; then
|
||||
fail "the search index routes to \"$page\", which is a retired id -- resolve() answers with its target, so the result lands somewhere the row never named"
|
||||
fi
|
||||
fail "the search index routes to \"$page\", which is not a leaf, so that result silently opens Home"
|
||||
done < <({
|
||||
grep -oE 'page: "[a-z-]+"' "$search"
|
||||
sed -n '/property var groupPages:/,/})/p' "$search" | grep -oE ': "[a-z-]+"'
|
||||
} | sed -E 's/.*"([a-z-]+)".*/\1/' | sort -u)
|
||||
|
||||
# The subjects the consolidation moved, each findable by its own name and each
|
||||
# landing on the tab that now owns it. Region & Language merged into Date, Time
|
||||
# & Region, so the words people arrive with for a format have to reach it; and
|
||||
# About and Sync & Backup grew rows nobody could search for at all.
|
||||
while IFS='|' read -r label page; do
|
||||
[[ -n "$label" ]] || continue
|
||||
python3 - "$search" "$label" "$page" <<'PY' \
|
||||
|| fail "the search index does not offer \"$label\" on the $page page"
|
||||
import re
|
||||
import sys
|
||||
|
||||
text, label, page = open(sys.argv[1], encoding="utf-8").read(), sys.argv[2], sys.argv[3]
|
||||
pattern = rf'\{{ label: "{re.escape(label)}",[^\n]*page: "([a-z-]+)" \}}'
|
||||
match = re.search(pattern, text)
|
||||
if not match:
|
||||
raise SystemExit(f'no entry labelled "{label}"')
|
||||
if match.group(1) != page:
|
||||
raise SystemExit(f'"{label}" routes to {match.group(1)}, expected {page}')
|
||||
PY
|
||||
done <<'SEARCHABLE'
|
||||
Hostname|about
|
||||
Kernel version|about
|
||||
Device model|about
|
||||
Installed memory|about
|
||||
Uptime|about
|
||||
Serial number|about
|
||||
Export settings|sync
|
||||
Import settings|sync
|
||||
Language|datetime
|
||||
Regional formats|datetime
|
||||
Currency|datetime
|
||||
Measurement units|datetime
|
||||
Paper size|datetime
|
||||
First day of the week|datetime
|
||||
Manual|manual
|
||||
SEARCHABLE
|
||||
|
||||
printf 'settings nav contract: PASS (%d categories, %d leaves of which %d hidden, %d retired ids)\n' \
|
||||
"$(grep -c . <<<"$category_ids")" "$(grep -c . <<<"$leaves")" \
|
||||
"$(grep -c . <<<"$hidden_ids")" "$retired_count"
|
||||
|
||||
@@ -13,7 +13,13 @@ fail() {
|
||||
# rebuild touched all four Connections-category pages at once: three of them had
|
||||
# never been checked for the page scaffold at all, and a rebuild is exactly when
|
||||
# a hand-rolled Flickable comes back.
|
||||
pages=(Home MyHome Phone Displays Connectivity Firewall Printers Sharing Bar Dock ControlCenter Tiling Workspaces Sync Sound Shortcuts Mouse Dictation Notifications Focus ScreenIntelligence Health About)
|
||||
# Updates, DateTime and Containers joined when the System rebuild touched all
|
||||
# of them at once. Containers is the one this check was waiting for: its root
|
||||
# was a bare Item, so it had never had the page scaffold at all, and every
|
||||
# convention the scaffold carries -- the scroll behaviour, the header, the
|
||||
# padding -- was hand-rolled there and quietly different from the other
|
||||
# twenty-five pages.
|
||||
pages=(Home MyHome Phone Displays Connectivity Firewall Printers Sharing Bar Dock ControlCenter Tiling Workspaces Sync Sound Shortcuts Mouse Dictation Notifications Focus ScreenIntelligence Health About Updates DateTime Containers Manual)
|
||||
for page in "${pages[@]}"; do
|
||||
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
|
||||
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
|
||||
@@ -359,7 +365,13 @@ shell_pid="$harness_pid"
|
||||
# four different categories, and the page the tab strip was introduced for.
|
||||
# Routing to a tab must land on that tab, not on whatever its category opens
|
||||
# first, which is the failure the SettingsRoutes resolution could introduce.
|
||||
pages=(home appearance displays connectivity my-home phone bar dock control-center tiling workspaces sync sound dictation notifications focus screen-intelligence shortcuts mouse services manual about)
|
||||
#
|
||||
# The four System leaves at the end are the ones the consolidation moved.
|
||||
# `updates` and `datetime` were rebuilt, `containers` changed its root type,
|
||||
# and `manual` stopped being a tab and became a hidden leaf -- which is exactly
|
||||
# the kind of change that keeps a page loading fine while making it
|
||||
# unreachable, so each one is opened for real here.
|
||||
pages=(home appearance displays connectivity my-home phone bar dock control-center tiling workspaces sync sound dictation notifications focus screen-intelligence shortcuts mouse services manual about updates datetime containers)
|
||||
for page in "${pages[@]}"; do
|
||||
qs_for_test ipc call settings page "$page" >/dev/null
|
||||
for _ in $(seq 1 20); do
|
||||
@@ -386,6 +398,17 @@ done
|
||||
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "bar" ]] \
|
||||
|| fail 'the retired "desktop" id no longer resolves to the Bar tab'
|
||||
|
||||
# Region & Language merged into Date, Time & Region. Its id is held by two
|
||||
# Vicinae commands, the deep links in the manual, and anybody who ever typed
|
||||
# it, so it has to land on the tab that absorbed it rather than on Home.
|
||||
qs_for_test ipc call settings page region >/dev/null
|
||||
for _ in $(seq 1 20); do
|
||||
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "datetime" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "datetime" ]] \
|
||||
|| fail 'the retired "region" id does not resolve to the Date, Time & Region tab'
|
||||
|
||||
/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 \
|
||||
|
||||
@@ -20,6 +20,14 @@
|
||||
# including every numeric enum -- were accepted unchecked, and numeric
|
||||
# enums were then refused outright once that was noticed.
|
||||
# 5. Import is a merge. Settings the file does not mention are left alone.
|
||||
# 6. The preview says what would change, in a shape a diff list can render.
|
||||
# "12 settings would change" is a number, not an answer; the page now
|
||||
# shows the rows, so `changes` carries {key, from, to} with both sides
|
||||
# already turned into text. Doing that stringification in the helper
|
||||
# rather than in QML is what makes the cap enforceable: a value long
|
||||
# enough to be something other than a setting is truncated once, here,
|
||||
# instead of being handed whole to a Text element and to anybody reading
|
||||
# over a shoulder.
|
||||
#
|
||||
# Runs entirely against a temporary config home. The real settings store is read
|
||||
# for the export and never written.
|
||||
@@ -128,6 +136,60 @@ for key in ("gapsIn", "colorScheme", "vrrPolicy", "blurEnabled", "displays", "so
|
||||
raise SystemExit(f'{key} was refused and queued for application anyway')
|
||||
PY
|
||||
|
||||
# ── 6. The preview renders as a diff, and cannot render a secret whole ──────
|
||||
|
||||
python3 - "$bundle" "$work/oversized.json" <<'PY'
|
||||
import json, sys
|
||||
bundle = json.load(open(sys.argv[1]))
|
||||
# A real, free-text, non-path string setting, so this exercises a value that
|
||||
# genuinely travels rather than one the validator would refuse for its own
|
||||
# reasons. 4000 characters is not a location; it is somebody's paste buffer.
|
||||
bundle["settings"]["weatherLocation"] = "Bearer sk-fixture-secret-" + ("x" * 4000)
|
||||
json.dump(bundle, open(sys.argv[2], "w"))
|
||||
PY
|
||||
|
||||
"$helper" preview "$work/oversized.json" >"$work/oversized-preview.json" \
|
||||
|| fail 'preview failed on a bundle carrying an oversized value'
|
||||
python3 - "$work/preview.json" "$work/oversized-preview.json" <<'PY' || fail 'the preview does not describe changes in a shape a diff list can render safely'
|
||||
import json
|
||||
import sys
|
||||
|
||||
CAP = 200
|
||||
|
||||
for path in sys.argv[1:]:
|
||||
plan = json.load(open(path))
|
||||
|
||||
if "changes" not in plan:
|
||||
raise SystemExit('the preview does not say what would change')
|
||||
if "changeCount" not in plan:
|
||||
raise SystemExit('the preview lists changes without saying how many there are, '
|
||||
'so a capped list reads as the whole truth')
|
||||
|
||||
count = plan["changeCount"]
|
||||
if not isinstance(count, int) or count < 0:
|
||||
raise SystemExit('changeCount is not a count')
|
||||
if count != len(plan["apply"]):
|
||||
raise SystemExit(f'changeCount says {count} but {len(plan["apply"])} would be applied')
|
||||
if len(plan["changes"]) > count:
|
||||
raise SystemExit('the rendered list is longer than the number of changes')
|
||||
|
||||
for entry in plan["changes"]:
|
||||
if set(entry) != {"key", "from", "to"}:
|
||||
raise SystemExit(f'a change carries {sorted(entry)}, expected key/from/to')
|
||||
for side in ("key", "from", "to"):
|
||||
if not isinstance(entry[side], str):
|
||||
raise SystemExit(f'{entry["key"]}.{side} is {type(entry[side]).__name__}, '
|
||||
'not text a row can render')
|
||||
if len(entry[side]) > CAP:
|
||||
raise SystemExit(f'{entry["key"]}.{side} is {len(entry[side])} characters; '
|
||||
'an uncapped value reaches the screen whole')
|
||||
|
||||
oversized = json.load(open(sys.argv[2]))
|
||||
rendered = json.dumps(oversized["changes"])
|
||||
if "x" * (CAP + 1) in rendered:
|
||||
raise SystemExit('an oversized value was reproduced in full in the change list')
|
||||
PY
|
||||
|
||||
# A clean bundle must arrive intact. Refusing valid settings is the failure this
|
||||
# contract exists to catch as much as accepting invalid ones -- fixing the enum
|
||||
# check the first time turned every numeric enum into a rejection.
|
||||
|
||||
@@ -138,6 +138,151 @@ if not any(e['source'] == 'flatpak' for e in entries) and not any(e['source'] ==
|
||||
raise SystemExit('neither source produced anything, so nothing was parsed')
|
||||
" || fail 'the update history is not usable'
|
||||
|
||||
# ── 5. A download size is the whole download, or it is not offered ──────────
|
||||
#
|
||||
# dnf5 prices packages from repository metadata, flatpak renders "36.2 MB" and
|
||||
# has no machine-readable column at all, and either can come back short. A
|
||||
# figure that covers some of what is pending, shown as the download, understates
|
||||
# it -- and understating it is the direction that costs somebody money on a
|
||||
# metered connection. So the total is present only when every item was priced.
|
||||
printf '%s' "$state" | python3 -c "
|
||||
import json, sys
|
||||
|
||||
state = json.load(sys.stdin)
|
||||
for name, key in ((\"dnf\", \"packages\"), (\"flatpak\", \"applications\")):
|
||||
source = state.get(name, {})
|
||||
items = source.get(key, [])
|
||||
if \"downloadBytes\" not in source:
|
||||
continue
|
||||
if not items:
|
||||
raise SystemExit(name + \" priced an empty list\")
|
||||
unpriced = [item for item in items if \"bytes\" not in item]
|
||||
if unpriced:
|
||||
raise SystemExit(name + \" reports a total while items have no size\")
|
||||
total = sum(int(item[\"bytes\"]) for item in items)
|
||||
if int(source[\"downloadBytes\"]) != total:
|
||||
raise SystemExit(name + \" totals \" + str(source[\"downloadBytes\"]) + \" for items summing to \" + str(total))
|
||||
" || fail 'a reported download size does not add up to the items it is a size for'
|
||||
|
||||
# ── 6. A changelog is a read ────────────────────────────────────────────────
|
||||
#
|
||||
# This verb takes a package name from a settings page and hands it to dnf, which
|
||||
# is the one place in this helper where the page names the subject. Two things
|
||||
# have to hold and neither is visible from reading the happy path: the name is
|
||||
# constrained before it reaches argv, and nothing on this path installs, removes
|
||||
# or upgrades anything.
|
||||
#
|
||||
# Proved by construction rather than by inspection: dnf5, flatpak and pkexec are
|
||||
# replaced with stubs that record their argv and produce nothing, so whatever
|
||||
# the helper decides to run is written down and checked afterwards. A stub that
|
||||
# answers nothing also exercises the honest-absence path, which is the common
|
||||
# case on a machine with third-party repositories.
|
||||
|
||||
changelog_work="$(mktemp -d /tmp/panama-updates-changelog.XXXXXX)"
|
||||
trap 'rm -rf "$changelog_work"' EXIT
|
||||
changelog_bin="$changelog_work/bin"
|
||||
changelog_log="$changelog_work/argv.log"
|
||||
mkdir -p "$changelog_bin" "$changelog_work/cache/panama"
|
||||
for tool in dnf5 flatpak fwupdmgr pkexec systemctl; do
|
||||
cat >"$changelog_bin/$tool" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\t%s\n' "\${0##*/}" "\$*" >>"$changelog_log"
|
||||
exit 1
|
||||
EOF
|
||||
chmod +x "$changelog_bin/$tool"
|
||||
done
|
||||
|
||||
run_changelog() {
|
||||
PATH="$changelog_bin:$PATH" XDG_CACHE_HOME="$changelog_work/cache" "$helper" "$@"
|
||||
}
|
||||
|
||||
: >"$changelog_log"
|
||||
answer="$(run_changelog changelog dnf zsh)" || fail 'the changelog verb failed'
|
||||
jq -e '.source == "dnf" and .name == "zsh" and .error == ""
|
||||
and (.kind | IN("advisory", "changelog", "none"))
|
||||
and (.text | type == "string")' <<<"$answer" >/dev/null \
|
||||
|| fail "a changelog answered in an unusable shape: $answer"
|
||||
[[ "$(jq -r .kind <<<"$answer")" == "none" ]] \
|
||||
|| fail "a source that produced nothing was not reported as having no changelog: $answer"
|
||||
[[ -n "$(jq -r .text <<<"$answer")" ]] \
|
||||
|| fail 'a package with no changelog says nothing at all, which reads as a failure to load'
|
||||
|
||||
run_changelog changelog flatpak org.example.Fixture >/dev/null \
|
||||
|| fail 'the flatpak changelog verb failed'
|
||||
run_changelog changelog firmware FixtureDevice >/dev/null \
|
||||
|| fail 'the firmware changelog verb failed'
|
||||
|
||||
python3 - "$changelog_log" <<'PY' || fail 'reading a changelog runs something that changes this machine'
|
||||
import sys
|
||||
|
||||
mutating = {
|
||||
"install", "remove", "erase", "upgrade", "update", "reinstall", "downgrade",
|
||||
"autoremove", "distro-sync", "swap", "-y", "--assumeyes", "--noninteractive",
|
||||
}
|
||||
lines = [line.rstrip("\n") for line in open(sys.argv[1], encoding="utf-8") if line.strip()]
|
||||
if not lines:
|
||||
raise SystemExit("no commands were recorded, so this proves nothing")
|
||||
for line in lines:
|
||||
executable, _, arguments = line.partition("\t")
|
||||
if executable == "pkexec":
|
||||
raise SystemExit("a changelog asked for privilege")
|
||||
for token in arguments.split():
|
||||
if token in mutating:
|
||||
raise SystemExit(f"{executable} was run with {token!r} while reading a changelog")
|
||||
PY
|
||||
|
||||
# A name the machine would not have produced never reaches argv.
|
||||
: >"$changelog_log"
|
||||
for hostile in '../../etc/passwd' '/etc/passwd' 'zsh; rm -rf /' '' '-rf'; do
|
||||
refusal="$(run_changelog changelog dnf "$hostile" 2>/dev/null)" \
|
||||
|| fail "the changelog verb crashed on \"$hostile\" instead of refusing it"
|
||||
[[ -n "$(jq -r '.error // ""' <<<"$refusal")" ]] \
|
||||
|| fail "the changelog verb accepted the name \"$hostile\""
|
||||
done
|
||||
[[ ! -s "$changelog_log" ]] || fail "a refused changelog name still started a process: $(<"$changelog_log")"
|
||||
[[ -n "$(run_changelog changelog nonsense zsh | jq -r '.error // ""')" ]] \
|
||||
|| fail 'an unknown changelog source was accepted'
|
||||
|
||||
# ── 7. One application, updated by name ─────────────────────────────────────
|
||||
#
|
||||
# `apply flatpak` updated everything, so the only way to take one application's
|
||||
# update was to take all of them -- including the 900 MB one nobody asked about.
|
||||
# The per-application verb appends exactly one ID to the same command, and the
|
||||
# ID is checked against the last scan rather than trusted: the page is not the
|
||||
# authority on what is pending, and this is the only verb that takes a name
|
||||
# from it.
|
||||
printf '%s' '{"flatpak":{"available":true,"count":1,"applications":[{"id":"org.example.Fixture","version":"1.0","origin":"flathub"}]}}' \
|
||||
>"$changelog_work/cache/panama/updates.json"
|
||||
: >"$changelog_log"
|
||||
run_changelog apply flatpak org.example.Fixture >/dev/null \
|
||||
|| fail 'a per-application update failed to answer'
|
||||
# Only the flatpak lines: the refusal path re-reads the snapshot afterwards,
|
||||
# which asks systemctl about the two unattended-update timers.
|
||||
[[ "$(grep -c $'^flatpak\t' "$changelog_log")" == "1" ]] \
|
||||
|| fail "a per-application update ran flatpak more than once: $(<"$changelog_log")"
|
||||
grep -Fxq "$(printf 'flatpak\tupdate -y --noninteractive org.example.Fixture')" "$changelog_log" \
|
||||
|| fail "the per-application argv was not exact: $(<"$changelog_log")"
|
||||
|
||||
: >"$changelog_log"
|
||||
refusal="$(run_changelog apply flatpak org.example.NotPending)"
|
||||
[[ -n "$(jq -r '.error // ""' <<<"$refusal")" ]] \
|
||||
|| fail 'an application with no pending update was accepted'
|
||||
! grep -q $'^flatpak\t' "$changelog_log" \
|
||||
|| fail "a refused per-application update still ran flatpak: $(<"$changelog_log")"
|
||||
[[ -n "$(run_changelog apply dnf somepackage | jq -r '.error // ""')" ]] \
|
||||
|| fail 'a per-item target was accepted for a source that cannot take one'
|
||||
|
||||
# ── 8. The service offers all three, and the stale comment is gone ──────────
|
||||
for api in 'function changelogFor(source: string, name: string): var' \
|
||||
'function applyFlatpakApp(id: string): void' \
|
||||
'readonly property string downloadSize'; do
|
||||
grep -Fq "$api" "$service" || fail "the Updates service does not expose: $api"
|
||||
done
|
||||
grep -Fq 'dnf-automatic is not installed' "$helper" \
|
||||
&& fail 'panama-updates still says dnf-automatic is not installed, which stopped being true when set-auto-dnf landed'
|
||||
grep -q 'def set_auto_dnf' "$helper" \
|
||||
|| fail 'automatic package updates are reported but cannot be turned on'
|
||||
|
||||
printf 'updates contract: PASS (%s dnf, %s flatpak, %s firmware; reboot needed: %s)\n' \
|
||||
"$(jq -r '.dnf.count // 0' <<<"$state")" \
|
||||
"$(jq -r '.flatpak.count // 0' <<<"$state")" \
|
||||
|
||||
Reference in New Issue
Block a user