264 lines
13 KiB
Bash
Executable File
264 lines
13 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# Behavioral coverage for the QML handoff after the helper commits a restore.
|
|
# The harness has a unique shell identity, isolated XDG roots, and fake external
|
|
# consumers. It records the real SettingsBackup call order without touching the
|
|
# daily-driver shell, compositor, keymap, or wallpaper.
|
|
|
|
set -euo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
service="$repo_dir/config/dot/quickshell/services/SettingsBackup.qml"
|
|
harness="$repo_dir/config/dot/quickshell/settings-backup-harness.qml"
|
|
work="$(mktemp -d /tmp/panama-settings-backup-live.XXXXXX)"
|
|
|
|
fail() {
|
|
printf 'settings backup live contract: %s\n' "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
qs_test() {
|
|
XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" qs -p "$harness" "$@"
|
|
}
|
|
|
|
cleanup() {
|
|
qs_test kill >/dev/null 2>&1 || true
|
|
rm -rf "$work"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
# The production command boundary must remain argv-only.
|
|
rg -Fq 'actionRun.exec([root.helperPath, "save", root.serializeHomeState()]);' "$service" \
|
|
|| fail 'save does not pass live Home state as one argument'
|
|
rg -Fq 'actionRun.exec([root.helperPath, "restore", name]);' "$service" \
|
|
|| fail 'restore is not executed through an argument array'
|
|
if rg -q 'bash.*-c|sh.*-c' "$service"; then
|
|
fail 'the restore service constructs a shell command'
|
|
fi
|
|
|
|
# The harness replaces these seams, while these mappings prove the production
|
|
# defaults still delegate to Panama's existing public service APIs.
|
|
for mapping in \
|
|
'HomePreferences.resetHomeDefaults();' \
|
|
'HomePreferences.initialize(ids);' \
|
|
'HomePreferences.setAlias(id, alias);' \
|
|
'DesktopPreferences.reload();' \
|
|
'DesktopPreferences.set("displays", value);' \
|
|
'Displays.currentLayout();' \
|
|
'Displays.applyProtectedLayout(layout);' \
|
|
'Displays.canConfirm;' \
|
|
'Displays.confirm();' \
|
|
'IdleLock.apply();' \
|
|
'Displays.externalChangeBlocked = blocked;' \
|
|
'SystemSettings.applyPersistedDisplayPolicy();' \
|
|
'Keybinds.applyReload();' \
|
|
'Wallpaper.applyCurrentPolicy(false);' \
|
|
'LockScreen.regenerate();' \
|
|
'Quickshell.reload(false);'; do
|
|
rg -Fq "$mapping" "$service" || fail "production restore seam is missing: $mapping"
|
|
done
|
|
|
|
qs_test --daemonize >"$work/quickshell.log" 2>&1
|
|
ready=false
|
|
for _ in $(seq 1 60); do
|
|
if qs_test ipc show 2>/dev/null | rg -q '^target settings-backup-behavior$'; then
|
|
ready=true
|
|
break
|
|
fi
|
|
sleep 0.1
|
|
done
|
|
if [[ "$ready" != true ]]; then
|
|
sed -n '1,200p' "$work/quickshell.log" >&2
|
|
fail 'isolated SettingsBackup harness did not start'
|
|
fi
|
|
|
|
qs_test ipc call settings-backup-behavior reset >/dev/null
|
|
payload='{"restored":"settings-20260818-010203004.json","home":{"present":true,"data":{"initialized":true,"favorites":[{"id":"light.desk","alias":"Desk"},{"id":"light.office","alias":"Office"}]}}}'
|
|
[[ "$(qs_test ipc call settings-backup-behavior apply "$payload")" == "true" ]] \
|
|
|| fail 'valid restore output was rejected'
|
|
|
|
status=""
|
|
for _ in $(seq 1 50); do
|
|
status="$(qs_test ipc call settings-backup-behavior status)"
|
|
jq -e '.calls[-1] == "shell.reload"' <<<"$status" >/dev/null 2>&1 && break
|
|
sleep 0.1
|
|
done
|
|
jq -e '
|
|
.calls == [
|
|
"home.reset",
|
|
"home.initialize:light.desk,light.office",
|
|
"home.alias:light.desk=Desk",
|
|
"home.alias:light.office=Office",
|
|
"desktop.reload",
|
|
"display.apply:[{\"name\":\"DP-2\",\"width\":4500,\"height\":3000,\"refreshRate\":60,\"mode\":\"4500x3000@60\",\"scale\":1.5,\"transform\":0,\"x\":-2560,\"y\":0,\"primary\":false},{\"name\":\"HDMI-A-1\",\"width\":2560,\"height\":1440,\"refreshRate\":60,\"mode\":\"2560x1440@60\",\"scale\":1,\"transform\":0,\"x\":0,\"y\":0,\"primary\":true}]",
|
|
"display.confirm",
|
|
"idle.apply",
|
|
"lock.regenerate",
|
|
"wallpaper.apply-policy",
|
|
"keybinds.reload",
|
|
"system.apply",
|
|
"display.block:false",
|
|
"shell.reload"
|
|
]
|
|
and .initialized == true
|
|
and .favorites == [
|
|
{"id":"light.desk","alias":"Desk"},
|
|
{"id":"light.office","alias":"Office"}
|
|
]
|
|
' <<<"$status" >/dev/null || fail "restore handoff order/state was wrong: $status"
|
|
|
|
# Invalid output is rejected before Home state or external consumers change.
|
|
qs_test ipc call settings-backup-behavior reset >/dev/null
|
|
invalid='{"home":{"present":true,"data":{"initialized":true,"favorites":[{"id":"light.desk","alias":"One"},{"id":"light.desk","alias":"Two"}]}}}'
|
|
[[ "$(qs_test ipc call settings-backup-behavior apply "$invalid")" == "false" ]] \
|
|
|| fail 'duplicate Home state was accepted'
|
|
status="$(qs_test ipc call settings-backup-behavior status)"
|
|
jq -e '.calls == [] and .initialized == false and .favorites == []' <<<"$status" >/dev/null \
|
|
|| fail 'invalid restore output caused partial live mutations'
|
|
|
|
# An absent Home generation uses the same ordered external handoff but leaves
|
|
# the live Home service reset rather than manufacturing an initialized store.
|
|
qs_test ipc call settings-backup-behavior reset >/dev/null
|
|
absent='{"restored":"settings-20260818-010203005.json","home":{"present":false}}'
|
|
[[ "$(qs_test ipc call settings-backup-behavior apply "$absent")" == "true" ]] \
|
|
|| fail 'absent Home restore output was rejected'
|
|
for _ in $(seq 1 50); do
|
|
status="$(qs_test ipc call settings-backup-behavior status)"
|
|
jq -e '.calls[-1] == "shell.reload"' <<<"$status" >/dev/null 2>&1 && break
|
|
sleep 0.1
|
|
done
|
|
jq -e '
|
|
.calls == [
|
|
"home.reset",
|
|
"desktop.reload",
|
|
"display.apply:[{\"name\":\"DP-2\",\"width\":4500,\"height\":3000,\"refreshRate\":60,\"mode\":\"4500x3000@60\",\"scale\":1.5,\"transform\":0,\"x\":-2560,\"y\":0,\"primary\":false},{\"name\":\"HDMI-A-1\",\"width\":2560,\"height\":1440,\"refreshRate\":60,\"mode\":\"2560x1440@60\",\"scale\":1,\"transform\":0,\"x\":0,\"y\":0,\"primary\":true}]",
|
|
"display.confirm",
|
|
"idle.apply",
|
|
"lock.regenerate",
|
|
"wallpaper.apply-policy",
|
|
"keybinds.reload",
|
|
"system.apply",
|
|
"display.block:false",
|
|
"shell.reload"
|
|
]
|
|
and .initialized == false
|
|
and .favorites == []
|
|
' <<<"$status" >/dev/null || fail "absent Home handoff was wrong: $status"
|
|
|
|
# If the restored complete layout is rejected before it can be verified, the
|
|
# original persisted layout is put back and the shell is not reloaded over an
|
|
# unproven display state.
|
|
qs_test ipc call settings-backup-behavior reset >/dev/null
|
|
[[ "$(qs_test ipc call settings-backup-behavior applyDisplayFailure "$payload")" == "false" ]] \
|
|
|| fail 'a rejected restored display layout was reported as successful'
|
|
status="$(qs_test ipc call settings-backup-behavior status)"
|
|
jq -e '.calls == [
|
|
"home.reset",
|
|
"home.initialize:light.desk,light.office",
|
|
"home.alias:light.desk=Desk",
|
|
"home.alias:light.office=Office",
|
|
"desktop.reload",
|
|
"display.apply:[{\"name\":\"DP-2\",\"width\":4500,\"height\":3000,\"refreshRate\":60,\"mode\":\"4500x3000@60\",\"scale\":1.5,\"transform\":0,\"x\":-2560,\"y\":0,\"primary\":false},{\"name\":\"HDMI-A-1\",\"width\":2560,\"height\":1440,\"refreshRate\":60,\"mode\":\"2560x1440@60\",\"scale\":1,\"transform\":0,\"x\":0,\"y\":0,\"primary\":true}]",
|
|
"display.protect:{\"DP-2\":{\"mode\":\"4500x3000@60\",\"scale\":1.5,\"transform\":0,\"x\":0,\"y\":0,\"primary\":true},\"HDMI-A-1\":{\"mode\":\"2560x1440@60\",\"scale\":1,\"transform\":0,\"x\":3000,\"y\":0,\"primary\":false}}",
|
|
"display.block:false"
|
|
] and (.lastError | contains("display layout"))' <<<"$status" >/dev/null \
|
|
|| fail "rejected display restore did not retain the original layout: $status"
|
|
|
|
# Restore refuses before launching the helper while a display apply/recovery is
|
|
# active, so no snapshot can race the confirmation boundary.
|
|
qs_test ipc call settings-backup-behavior reset >/dev/null
|
|
[[ "$(qs_test ipc call settings-backup-behavior restoreWhileDisplayBusy)" == "false" ]] \
|
|
|| fail 'snapshot restore started during an active display operation'
|
|
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'
|