Files
Panama/tests/quickshell/settings-commit-reset-contract

273 lines
15 KiB
Bash
Executable File

#!/usr/bin/env bash
# Two behaviors the settings rows depend on:
#
# commitPreference(key, value)
# One entry point for every row. A compositor-backed key must reach Hyprland
# and be confirmed before it is stored; a local key is written directly.
# Rows bind a schema key and call this, so they never need to know which
# kind they hold -- and a row must not be able to store a value the
# compositor rejected.
#
# restoreDefaults()
# Panama keeps user state in more than one file. Resetting only the schema
# store would leave a customized Home accessory arrangement in place while
# claiming to have restored Panama's defaults. That is worse than having no
# reset at all, because it is silent.
#
# It is also the only irreversible action Panama offers, and the confirm
# promises it is undoable because a backup is taken first. That promise is
# only worth something if the backup contains the settings being replaced.
# The backup is written by a separate process reading settings.json, so
# "the call returned" is not "the file is safe": this contract holds the
# snapshot open and proves that NOTHING is wiped until it completes, and
# that a snapshot which fails -- or cannot start -- aborts the reset
# entirely rather than proceeding without an undo.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/settings-system-harness.qml"
system_settings="$repo_dir/config/dot/quickshell/services/SystemSettings.qml"
settings_backup="$repo_dir/config/dot/quickshell/services/SettingsBackup.qml"
wallpaper_service="$repo_dir/config/dot/quickshell/services/Wallpaper.qml"
# Preferences are committed to $XDG_CONFIG_HOME, and the Home store lives under
# $XDG_STATE_HOME. Both are isolated so this contract cannot touch the real
# desktop's settings. The harness replaces the compositor write seam as well,
# so interruption cannot leave the daily desktop modified.
config_home="$(mktemp -d /tmp/panama-commit-config.XXXXXX)"
state_home="$(mktemp -d /tmp/panama-commit-state.XXXXXX)"
fail() {
printf 'settings commit/reset contract: %s\n' "$1" >&2
exit 1
}
rg -Fq 'HomePreferences.resetHomeDefaults();' "$system_settings" \
|| fail 'restoreDefaults does not use the durable Home reset boundary'
if rg -q 'HomePreferences\.(favorites|initialized)\s*=' "$system_settings"; then
fail 'restoreDefaults mutates Home aliases instead of using resetHomeDefaults'
fi
rg -Fq 'DesktopPreferences.resetDesktopDefaults();' "$system_settings" \
|| fail 'restoreDefaults does not clear confirmed display layout fields'
rg -Fq 'Keybinds.applyReload();' "$system_settings" \
|| fail 'restoreDefaults does not replay shipped keybindings'
rg -Fq 'root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));' "$system_settings" \
|| fail 'restoreDefaults does not visibly reapply the shipped wallpaper'
rg -Fq 'LockScreen.regenerate();' "$system_settings" \
|| fail 'restoreDefaults does not regenerate the shipped lock screen'
rg -Fq 'const effectivePath = path === "" ? root.shippedPath : path;' "$wallpaper_service" \
|| fail 'clearing wallpaper preference leaves the old image visible'
rg -Fq 'storedPath: rawGlobal === root.shippedPath ? "" : rawGlobal' "$wallpaper_service" \
|| fail 'the shipped wallpaper cannot remain represented by the default empty preference'
# The completion signal is the whole mechanism: without it the caller has no way
# to know the helper finished, and the only thing left to wait for is nothing.
rg -Fq 'signal saveFinished(bool success)' "$settings_backup" \
|| fail 'SettingsBackup.save() reports no completion, so a caller cannot wait for the backup'
qs_for_harness() {
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \
PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1 qs -p "$harness" "$@"
}
restore() {
qs_for_harness kill >/dev/null 2>&1 || true
rm -rf "$config_home" "$state_home"
}
trap restore EXIT
# This contract shares its harness file with settings-hyprland-write-contract,
# and Quickshell identifies an instance by config path -- so if that run's
# instance is still alive, the IPC wait below is satisfied by ITS target. That
# direction is the dangerous one: this contract believes the compositor seam is
# stubbed, so it would happily drive the DAILY DESKTOP's real compositor while
# reporting isolation. Refuse to start rather than find out.
harness_instances() {
# rg -c prints nothing when there are no matches, so an unguarded
# substitution yields "" rather than "0".
local count
count="$(qs list 2>/dev/null | rg -c "^ Config path: $harness\$" || true)"
printf '%s' "${count:-0}"
}
for _ in $(seq 1 50); do
[[ "$(harness_instances)" == "0" ]] && break
sleep 0.1
done
[[ "$(harness_instances)" == "0" ]] \
|| fail 'another instance of the settings harness is still running -- this contract would drive it instead of its own isolated one, and that instance may be writing to the real compositor'
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \
PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1 qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do
qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$' && break
sleep 0.1
done
qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$' || fail 'test IPC target did not start'
# ── A local key is stored directly ───────────────────────────────────────────
[[ "$(qs_for_harness ipc call settings-system-test commit showSeconds false)" == "true" ]] \
|| fail 'commitPreference refused a local key'
[[ "$(qs_for_harness ipc call settings-system-test stored showSeconds)" == "false" ]] \
|| fail 'a local key was not stored'
# ── A compositor key reaches the verified apply boundary, then is stored ────
target_rounding=11
[[ "$(qs_for_harness ipc call settings-system-test commit windowRounding "$target_rounding")" == "true" ]] \
|| fail 'commitPreference refused a compositor key'
apply_state="$(qs_for_harness ipc call settings-system-test applyState)"
jq -e '.[-1].windowRounding == 11' <<<"$apply_state" >/dev/null \
|| fail "a compositor-backed commit did not reach the apply boundary: $apply_state"
[[ "$(qs_for_harness ipc call settings-system-test stored windowRounding)" == "$target_rounding" ]] \
|| fail 'a verified compositor commit was not stored'
# ── A value the schema rejects is never stored ───────────────────────────────
before="$(qs_for_harness ipc call settings-system-test stored windowRounding)"
[[ "$(qs_for_harness ipc call settings-system-test commit windowRounding 9999)" == "true" ]] \
|| fail 'an out-of-range value should be clamped by the schema, not refused outright'
[[ "$(qs_for_harness ipc call settings-system-test stored windowRounding)" != "9999" ]] \
|| fail 'an out-of-range value was stored unclamped'
[[ "$(qs_for_harness ipc call settings-system-test commit __not_a_setting__ 1)" == "false" ]] \
|| fail 'commitPreference accepted a key outside the schema'
# ── Reset spans every store, not just the schema one ─────────────────────────
qs_for_harness ipc call settings-system-test seedHome >/dev/null
qs_for_harness ipc call settings-system-test commit dockHideDelayMs 900 >/dev/null
display_fixture='{"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}}'
[[ "$(qs_for_harness ipc call settings-system-test commit displays "$display_fixture")" == "true" ]] \
|| fail 'the protected display fixture did not apply'
sleep 0.4
home_before="$(qs_for_harness ipc call settings-system-test homeState)"
jq -e '.count == 1 and .initialized == true' <<<"$home_before" >/dev/null \
|| fail "the Home fixture did not apply: $home_before"
[[ "$(qs_for_harness ipc call settings-system-test stored dockHideDelayMs)" == "900" ]] \
|| fail 'the dock fixture did not apply'
# Everything below leans on the fixtures still being in place, so each aborted
# case re-asserts that they are.
fixtures_intact() {
local why="$1" home dock
home="$(qs_for_harness ipc call settings-system-test homeState)"
dock="$(qs_for_harness ipc call settings-system-test stored dockHideDelayMs)"
jq -e '.count == 1 and .initialized == true' <<<"$home" >/dev/null \
|| fail "$why (Home store was cleared: $home)"
[[ "$dock" == "900" ]] || fail "$why (schema store was cleared: dockHideDelayMs=$dock)"
[[ "$(qs_for_harness ipc call settings-system-test stored displays | jq -cS .)" != '{}' ]] \
|| fail "$why (display records were cleared)"
}
last_error() {
qs_for_harness ipc call settings-system-test status | jq -r .lastError
}
# ── A snapshot that cannot even start refuses the reset ──────────────────────
# The user was promised an undo. Without one, the honest answer is to do
# nothing and say so, not to wipe the stores anyway.
qs_for_harness ipc call settings-system-test clearError >/dev/null
qs_for_harness ipc call settings-system-test snapshotCanStart false >/dev/null
[[ "$(qs_for_harness ipc call settings-system-test restoreDefaults)" == "false" ]] \
|| fail 'reset proceeded although no safety backup could be started'
sleep 0.5
fixtures_intact 'a reset with no safety backup still wiped the stores'
refused_state="$(qs_for_harness ipc call settings-system-test resetState)"
jq -e '.calls == ["snapshot.start"] and .displayBlocked == false' <<<"$refused_state" >/dev/null \
|| fail "a refused reset still began its work: $refused_state"
[[ -n "$(last_error)" ]] || fail 'a refused reset said nothing about why nothing happened'
[[ "$(qs_for_harness ipc call settings-system-test resetPending)" == "false" ]] \
|| fail 'a refused reset left the page believing a reset is under way'
qs_for_harness ipc call settings-system-test snapshotCanStart true >/dev/null
# ── Nothing is wiped while the snapshot is still being written ───────────────
# This is the race the old code lost: takeSafetySnapshot() returned at launch
# and the reset rewrote settings.json in the same frame, so the helper read back
# the defaults it had just been handed and called them the user's settings.
qs_for_harness ipc call settings-system-test clearError >/dev/null
[[ "$(qs_for_harness ipc call settings-system-test restoreDefaults)" == "true" ]] \
|| fail 'restoreDefaults refused a safe reset'
sleep 0.5
[[ "$(qs_for_harness ipc call settings-system-test resetPending)" == "true" ]] \
|| fail 'the reset did not stay pending while the safety backup was being written'
fixtures_intact 'the stores were wiped while the safety backup was still being written'
inflight_state="$(qs_for_harness ipc call settings-system-test resetState)"
jq -e '.calls == ["snapshot.start"] and .displayBlocked == false' <<<"$inflight_state" >/dev/null \
|| fail "the reset began before its safety backup finished: $inflight_state"
# ── A snapshot that fails aborts the reset ──────────────────────────────────
[[ "$(qs_for_harness ipc call settings-system-test finishSnapshot false)" == "true" ]] \
|| fail 'no snapshot was waiting to be failed'
sleep 0.5
fixtures_intact 'a failed safety backup still let the reset wipe the stores'
failed_state="$(qs_for_harness ipc call settings-system-test resetState)"
jq -e '.calls == ["snapshot.start", "snapshot.finish:false"] and .displayBlocked == false' \
<<<"$failed_state" >/dev/null \
|| fail "a failed safety backup did not abort the reset: $failed_state"
[[ -n "$(last_error)" ]] || fail 'a reset aborted by a failed backup said nothing'
[[ "$(qs_for_harness ipc call settings-system-test resetPending)" == "false" ]] \
|| fail 'an aborted reset stayed pending, so the page can never offer the reset again'
# ── The reset happens in the snapshot's success path, and only there ─────────
qs_for_harness ipc call settings-system-test clearError >/dev/null
[[ "$(qs_for_harness ipc call settings-system-test restoreDefaults)" == "true" ]] \
|| fail 'restoreDefaults refused a safe reset'
sleep 0.3
fixtures_intact 'the stores were wiped while the safety backup was still being written'
[[ "$(qs_for_harness ipc call settings-system-test finishSnapshot true)" == "true" ]] \
|| fail 'no snapshot was waiting to be completed'
sleep 0.6
reset_state="$(qs_for_harness ipc call settings-system-test resetState)"
# The snapshot must COMPLETE first. Restoring defaults is the only irreversible
# action Panama offers, and a snapshot that read the stores after they were
# cleared would faithfully record the wiped state as if it were the user's.
jq -e '.calls[0] == "snapshot.start" and .calls[1] == "snapshot.finish:true"' \
<<<"$reset_state" >/dev/null \
|| fail "reset did not wait for the snapshot to finish before wiping the stores: $reset_state"
jq -e '.calls == [
"snapshot.start",
"snapshot.finish:true",
"display.block:true",
"keybinds.reload",
"wallpaper.set:",
"lock.regenerate",
"display.block:false"
] and .displayBlocked == false' <<<"$reset_state" >/dev/null \
|| fail "reset did not safely replay non-reactive state: $reset_state"
[[ "$(qs_for_harness ipc call settings-system-test stored dockHideDelayMs)" == "250" ]] \
|| fail 'reset did not restore a schema default'
for default_case in \
'wallpaperMode|"single"' \
'wallpaperSlideshowPaths|[]' \
'wallpaperIntervalMinutes|30' \
'wallpaperShuffle|true' \
'wallpaperPerMonitor|{}' \
'lockBackgroundMode|"screenshot"' \
'lockBlurLevel|3' \
'lockShowClock|true' \
'lockShowDate|true' \
'lockShowUser|true' \
'lockFadeOnEmpty|false'; do
IFS='|' read -r key expected <<<"$default_case"
[[ "$(qs_for_harness ipc call settings-system-test stored "$key")" == "$expected" ]] \
|| fail "reset did not restore $key to $expected"
done
[[ "$(qs_for_harness ipc call settings-system-test stored displays | jq -cS .)" == '{}' ]] \
|| fail 'reset retained confirmed arrangement fields instead of returning startup to shipped placement'
home_after="$(qs_for_harness ipc call settings-system-test homeState)"
jq -e '.count == 0 and .initialized == false' <<<"$home_after" >/dev/null \
|| fail "reset left the Home accessory store customized: $home_after"
# Resetting a stored value does not itself apply compositor policy, so the last
# isolated batch must contain the shipped default.
jq -e '.appliedBatches[-1].windowRounding == 18' <<<"$reset_state" >/dev/null \
|| fail "reset did not re-apply the compositor default: $reset_state"
trap - EXIT
restore
printf 'settings commit/reset contract: PASS\n'