Tier 0: render what the services already decided, honestly
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -1,32 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Bluetooth discovery holds the radio, so who turns it on and off is a contract
|
||||
# rather than a detail.
|
||||
#
|
||||
# The pins below started life against modules/quicksettings/BluetoothList.qml,
|
||||
# which computed the desired state and wrote BlueZ itself. That was correct
|
||||
# until a second surface -- the settings page, through Connectivity.qml -- began
|
||||
# doing the same thing from its own visibility flag, at which point the last
|
||||
# writer decided for both: closing the settings page stopped discovery under an
|
||||
# open quick-settings panel, which then sat on "Searching…" over a radio that
|
||||
# had stopped.
|
||||
#
|
||||
# The fix moved the BlueZ write into Connectivity.qml behind named holds, so the
|
||||
# same five guarantees are checked there, plus a sixth that keeps them there:
|
||||
#
|
||||
# 1. the desired state is calculated from state, not toggled;
|
||||
# 2. a redundant start is not issued;
|
||||
# 3. discovery this desktop did not start is never stopped;
|
||||
# 4. merely constructing an inactive picker does not write BlueZ state;
|
||||
# 5. an adapter appearing does not start discovery nobody asked for;
|
||||
# 6. a surface releases its hold when it is destroyed.
|
||||
#
|
||||
# Read-only. It changes no Bluetooth state.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
bluetooth="$repo_dir/config/dot/quickshell/modules/quicksettings/BluetoothList.qml"
|
||||
service="$repo_dir/config/dot/quickshell/services/Connectivity.qml"
|
||||
|
||||
rg -q 'const shouldDiscover = root\.active && root\.adapter\.enabled' "$bluetooth" || {
|
||||
for path in "$bluetooth" "$service"; do
|
||||
[[ -r "$path" ]] || {
|
||||
printf 'bluetooth discovery contract: missing %s\n' "$path" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
rg -q 'const shouldDiscover = root\.discoveryWanted' "$service" || {
|
||||
printf 'bluetooth discovery contract: desired state is not calculated idempotently\n' >&2
|
||||
exit 1
|
||||
}
|
||||
rg -q 'shouldDiscover && !root\.adapter\.discovering' "$bluetooth" || {
|
||||
rg -q 'shouldDiscover && !root\.adapter\.discovering' "$service" || {
|
||||
printf 'bluetooth discovery contract: redundant BlueZ starts are not guarded\n' >&2
|
||||
exit 1
|
||||
}
|
||||
rg -q '!shouldDiscover && root\.discoveryOwned && root\.adapter\.discovering' "$bluetooth" || {
|
||||
printf 'bluetooth discovery contract: the picker may stop discovery it does not own\n' >&2
|
||||
rg -q '!shouldDiscover && root\.discoveryOwned && root\.adapter\.discovering' "$service" || {
|
||||
printf 'bluetooth discovery contract: the service may stop discovery it does not own\n' >&2
|
||||
exit 1
|
||||
}
|
||||
! rg -q 'Component\.onCompleted: root\.syncDiscovery' "$bluetooth" || {
|
||||
printf 'bluetooth discovery contract: inactive construction still writes BlueZ state\n' >&2
|
||||
|
||||
# The holds themselves. Without both halves the refcount is decorative and the
|
||||
# two surfaces are back to overwriting each other.
|
||||
rg -q 'function acquireDiscovery' "$service" || {
|
||||
printf 'bluetooth discovery contract: nothing can take a hold on discovery\n' >&2
|
||||
exit 1
|
||||
}
|
||||
rg -Uq 'onAdapterChanged:[^{\n]*\{[^}]*if \(root\.active\)' "$bluetooth" || {
|
||||
printf 'bluetooth discovery contract: adapter changes are not gated by an open panel\n' >&2
|
||||
rg -q 'function releaseDiscovery' "$service" || {
|
||||
printf 'bluetooth discovery contract: a hold on discovery cannot be released\n' >&2
|
||||
exit 1
|
||||
}
|
||||
rg -Uq 'Component\.onDestruction:[^{\n]*\{[^}]*root\.discoveryOwned' "$bluetooth" || {
|
||||
printf 'bluetooth discovery contract: owned discovery is not released on destruction\n' >&2
|
||||
rg -q 'discoveryWanted: root\.discoveryHolders\.length > 0' "$service" || {
|
||||
printf 'bluetooth discovery contract: discovery does not follow the outstanding holds\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 6. One writer. A picker that still wrote adapter.discovering would reopen the
|
||||
# exact fight the holds exist to settle, and it would do it silently.
|
||||
! rg -q 'discovering *=' "$bluetooth" || {
|
||||
printf 'bluetooth discovery contract: the picker writes BlueZ discovery directly, behind the holds\n' >&2
|
||||
exit 1
|
||||
}
|
||||
! rg -q 'Component\.onCompleted: root\.(syncDiscovery|acquireDiscovery)' "$bluetooth" || {
|
||||
printf 'bluetooth discovery contract: inactive construction still takes a discovery hold\n' >&2
|
||||
exit 1
|
||||
}
|
||||
rg -Uq 'onActiveChanged:[^{\n]*\{[^}]*Connectivity\.acquireDiscovery' "$bluetooth" || {
|
||||
printf 'bluetooth discovery contract: discovery is not gated by an open panel\n' >&2
|
||||
exit 1
|
||||
}
|
||||
rg -q 'Component\.onDestruction: Connectivity\.releaseDiscovery' "$bluetooth" || {
|
||||
printf 'bluetooth discovery contract: the hold is not released on destruction\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
service="$repo_dir/config/dot/quickshell/services/Displays.qml"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/DisplaysPage.qml"
|
||||
harness="$repo_dir/config/dot/quickshell/displays-harness.qml"
|
||||
|
||||
fail() {
|
||||
@@ -46,6 +47,20 @@ done
|
||||
rg -q 'vrrMode\s*(!==\s*-1|>\s*-1|>=\s*0)' "$service" \
|
||||
|| fail 'nothing in the service decides when the vrr key is omitted, so following the global policy would be written as an override'
|
||||
|
||||
# ── The pickers show what you chose, for as long as the choice is undecided ──
|
||||
#
|
||||
# confirm() is what writes, so during the keep-or-revert window nothing is
|
||||
# stored yet and currentLayout() answers vrrMode from the PREVIOUS stored
|
||||
# record -- the one field the compositor cannot report. The variable-refresh
|
||||
# picker therefore snapped back to its old value the instant the change was
|
||||
# applied, while the banner beside it asked whether to keep the change it had
|
||||
# just stopped showing. The requested layout is what the page must render until
|
||||
# the window closes.
|
||||
rg -Fq 'Displays.awaitingConfirmation && Displays.pendingRequestedLayout' "$page" \
|
||||
|| fail 'the displays page ignores the requested layout during confirmation, so a picker snaps back while the Keep banner is still up'
|
||||
rg -Fq 'Displays.pendingRequestedLayout.find(entry => entry.name === name)' "$page" \
|
||||
|| fail 'the displays page does not read the selected output out of the requested layout'
|
||||
|
||||
rg -Fq 'function applyLayoutFixture(' "$harness" \
|
||||
|| fail 'the fixture cannot exercise complete layout transactions'
|
||||
rg -Fq 'function injectReadback(' "$harness" \
|
||||
|
||||
@@ -306,6 +306,8 @@ process_identity_matches "$harness_pid" "$harness_start_time" \
|
||||
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"
|
||||
jq -e '.headlineState == "warning" and .headline == "Needs attention" and .tone == "warn"' \
|
||||
>/dev/null <<<"$state" || fail "a warning snapshot did not produce the shared warning verdict: $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'
|
||||
@@ -373,6 +375,18 @@ state="$(run ipc call health-test status)"
|
||||
jq -e '.diagnosticUnavailable == true and .checks == ["integration.calendar", "panama.caffeine"]' \
|
||||
>/dev/null <<<"$state" || fail "malformed snapshot discarded the last valid checks: $state"
|
||||
|
||||
# The split-brain state, named. A rejected snapshot on top of a good one leaves
|
||||
# BOTH conditions true: the checks are still there, and the diagnostic is
|
||||
# unavailable. The Health hero asked "unavailable?" first and the sidebar footer
|
||||
# asked "any checks?" first, so the same desktop was a red "Health check
|
||||
# unavailable" and a green "Desktop is healthy" at the same time, six inches
|
||||
# apart. There is one verdict now, and unavailable wins it -- it is the only
|
||||
# state that says the other four are not known to be true.
|
||||
jq -e '.headlineState == "unavailable" and .headline == "Health check unavailable"
|
||||
and .tone == "danger"' \
|
||||
>/dev/null <<<"$state" \
|
||||
|| fail "a rejected snapshot over a good one did not produce one unavailable verdict: $state"
|
||||
|
||||
before_generation="$(jq -r .generation <<<"$state")"
|
||||
run ipc call health-test queue >/dev/null
|
||||
state="$(run ipc call health-test status)"
|
||||
|
||||
@@ -51,6 +51,23 @@ rg -Fq 'onTapped: root.pageRequested("services")' "$settings_dir/SettingsSidebar
|
||||
|| fail 'health footer does not open the stable services route'
|
||||
rg -Fq 'height: 54' "$settings_dir/SettingsSidebar.qml" \
|
||||
|| fail 'health footer lost its 54px target'
|
||||
|
||||
# ── One verdict, two surfaces ────────────────────────────────────────────────
|
||||
# The sidebar footer and the Health hero sit six inches apart and describe the
|
||||
# same desktop. They each used to decide the headline for themselves, in
|
||||
# different orders, so a health check that failed after a successful one showed
|
||||
# a red "unavailable" hero beside a green "Desktop is healthy" footer. Neither
|
||||
# surface may test Health.status or Health.checks.length to work out the
|
||||
# headline again; both render Health.headline and Health.tone.
|
||||
rg -Fq 'return Health.headline;' "$settings_dir/SettingsSidebar.qml" \
|
||||
|| fail 'the sidebar footer does not render the shared health headline'
|
||||
rg -Fq 'Health.headline' "$settings_dir/HealthSummary.qml" \
|
||||
|| fail 'the health hero does not render the shared health headline'
|
||||
for health_surface in SettingsSidebar HealthSummary; do
|
||||
if rg -q 'Health\.(status|checks\.length) ===' "$settings_dir/$health_surface.qml"; then
|
||||
fail "$health_surface re-derives the health headline instead of rendering Health.headline"
|
||||
fi
|
||||
done
|
||||
rg -Fq 'onClicked: Health.copyReport()' "$settings_dir/HealthSummary.qml" \
|
||||
|| fail 'Copy Report does not use the redacted Health report path'
|
||||
rg -Fq 'text: "Checking…"' "$settings_dir/HealthSummary.qml" \
|
||||
|
||||
@@ -26,6 +26,23 @@ settings="$repo_dir/config/dot/quickshell/config/Settings.qml"
|
||||
[[ -f "$schema" ]] || fail 'preference schema is missing'
|
||||
[[ -f "$settings" ]] || fail 'settings singleton is missing'
|
||||
|
||||
# ── The gate is decided once ────────────────────────────────────────────────
|
||||
#
|
||||
# handleNotification weighs Do Not Disturb against its two exceptions and puts
|
||||
# what survives into `popups`. The banner window then re-tested
|
||||
# !Notifs.doNotDisturb on its `visible` binding, which is not a narrowing of
|
||||
# that decision -- it is a replacement of it. An application on a focus mode's
|
||||
# allow list, or a critical notification with breakthrough switched on, was
|
||||
# admitted by the service, chimed, and then rendered onto a window that was
|
||||
# hidden. The exceptions existed, were configurable, and did nothing.
|
||||
toasts="$repo_dir/config/dot/quickshell/modules/notifications/Toasts.qml"
|
||||
[[ -f "$toasts" ]] || fail 'the banner window is missing'
|
||||
rg -Fq 'visible: Notifs.popups.length > 0' "$toasts" \
|
||||
|| fail 'the banner window does not show exactly what the service admitted'
|
||||
if rg -q 'visible:.*doNotDisturb' "$toasts"; then
|
||||
fail 'the banner window re-gates on Do Not Disturb, overriding the allow-list and breakthrough exceptions the service already applied'
|
||||
fi
|
||||
|
||||
# The expanded row body lives in its own component now. It is part of the same
|
||||
# surface as the page, so the per-application assertions read both rather than
|
||||
# only the file that happens to hold the card today -- otherwise pulling a row
|
||||
|
||||
@@ -14,12 +14,22 @@
|
||||
# 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
|
||||
@@ -52,6 +62,11 @@ rg -Fq 'const effectivePath = path === "" ? root.shippedPath : path;' "$wallpape
|
||||
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" "$@"
|
||||
@@ -131,19 +146,89 @@ jq -e '.count == 1 and .initialized == true' <<<"$home_before" >/dev/null \
|
||||
[[ "$(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 come FIRST. Restoring defaults is the only irreversible
|
||||
# action Panama offers, and a snapshot taken after the stores were cleared would
|
||||
# faithfully record the wiped state as if it were the user's.
|
||||
jq -e '.calls[0] == "snapshot"' <<<"$reset_state" >/dev/null \
|
||||
|| fail "reset did not snapshot before wiping the stores: $reset_state"
|
||||
# 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",
|
||||
"snapshot.start",
|
||||
"snapshot.finish:true",
|
||||
"display.block:true",
|
||||
"keybinds.reload",
|
||||
"wallpaper.set:",
|
||||
|
||||
@@ -283,6 +283,83 @@ grep -Fq 'dnf-automatic is not installed' "$helper" \
|
||||
grep -q 'def set_auto_dnf' "$helper" \
|
||||
|| fail 'automatic package updates are reported but cannot be turned on'
|
||||
|
||||
# ── 9. A check that failed is not an up-to-date machine ─────────────────────
|
||||
#
|
||||
# Every source used to answer a failure with an empty list and no error: dnf
|
||||
# outside exit 0/100, flatpak against an unreachable remote, fwupdmgr printing
|
||||
# nothing (its --json exits 0 on failure and says so in the payload). The page
|
||||
# then added three zeroes together, said "Up to date", and stamped the clock --
|
||||
# the reassuring wrong answer, on the one page whose whole job is security
|
||||
# fixes.
|
||||
#
|
||||
# Proved with the same stubs the changelog section uses: every tool present and
|
||||
# every tool failing.
|
||||
check_work="$(mktemp -d /tmp/panama-updates-check.XXXXXX)"
|
||||
trap 'rm -rf "$changelog_work" "$check_work"' EXIT
|
||||
mkdir -p "$check_work/cache/panama"
|
||||
|
||||
run_check() {
|
||||
PATH="$changelog_bin:$PATH" XDG_CACHE_HOME="$check_work/cache" "$helper" "$@"
|
||||
}
|
||||
|
||||
failed_check="$(run_check check)" || fail 'check crashed instead of reporting the failure'
|
||||
for source in dnf flatpak firmware; do
|
||||
[[ -n "$(jq -r ".$source.error // \"\"" <<<"$failed_check")" ]] \
|
||||
|| fail "$source reported no error after failing, so its empty list reads as nothing to do: $failed_check"
|
||||
[[ "$(jq -r ".$source.count" <<<"$failed_check")" == "0" ]] \
|
||||
|| fail "$source invented a count out of a failed check: $failed_check"
|
||||
[[ "$(jq -r ".$source.checkedAt" <<<"$failed_check")" == "0" ]] \
|
||||
|| fail "$source stamped its clock on a check that failed: $failed_check"
|
||||
done
|
||||
[[ "$(jq -r .checkedAt <<<"$failed_check")" == "0" ]] \
|
||||
|| fail "a check where nothing answered still stamped the overall clock: $failed_check"
|
||||
[[ "$(jq -r '.dnf.securityKnown' <<<"$failed_check")" == "false" ]] \
|
||||
|| fail "a failed advisory query still claims the security count is known: $failed_check"
|
||||
|
||||
# A clean stamp already on record is CARRIED, not refreshed. "Checked 2 minutes
|
||||
# ago" beside a stale count is the same lie wearing a timestamp.
|
||||
printf '%s' '{"checkedAt":1000,"dnf":{"available":true,"count":0,"packages":[],"securityCount":0,"securityKnown":true,"error":"","checkedAt":1000},"flatpak":{"available":true,"count":0,"applications":[],"error":"","checkedAt":1000},"firmware":{"available":true,"count":0,"devices":[],"error":"","checkedAt":1000}}' \
|
||||
>"$check_work/cache/panama/updates.json"
|
||||
stale_check="$(run_check check)" || fail 'check crashed over an existing cache'
|
||||
[[ "$(jq -r .checkedAt <<<"$stale_check")" == "1000" ]] \
|
||||
|| fail "a failed check moved the overall clock forward: $stale_check"
|
||||
[[ "$(jq -r '.dnf.checkedAt' <<<"$stale_check")" == "1000" ]] \
|
||||
|| fail "a failed source moved its own clock forward: $stale_check"
|
||||
|
||||
# An old cache with none of these fields must still read, rather than losing
|
||||
# them at the surface where a missing error looks exactly like no error.
|
||||
printf '%s' '{"checkedAt":1000,"dnf":{"available":true,"count":0,"packages":[],"securityCount":0}}' \
|
||||
>"$check_work/cache/panama/updates.json"
|
||||
legacy="$(run_check snapshot)" || fail 'snapshot crashed over a cache written before per-source errors'
|
||||
jq -e '(.dnf | has("error") and has("checkedAt"))
|
||||
and (.flatpak | has("error") and has("checkedAt"))
|
||||
and (.firmware | has("error") and has("checkedAt"))' <<<"$legacy" >/dev/null \
|
||||
|| fail "an older cache lost the per-source honesty fields: $legacy"
|
||||
|
||||
# The service must refuse "Up to date" while a source is unknown, and must ask
|
||||
# that question BEFORE it asks whether the total is zero -- a failed source
|
||||
# contributes zero, which is what made the two indistinguishable.
|
||||
grep -Fq 'if (root.anySourceFailed) {' "$service" \
|
||||
|| fail 'the summary does not consider a source that could not answer'
|
||||
python3 - "$service" <<'PY' || fail 'the summary can still say "Up to date" over a source that never answered'
|
||||
import sys
|
||||
|
||||
body = open(sys.argv[1], encoding="utf-8").read()
|
||||
start = body.index("function summary()")
|
||||
end = body.index("\n }", start)
|
||||
summary = body[start:end]
|
||||
if "anySourceFailed" not in summary:
|
||||
raise SystemExit('summary() does not test anySourceFailed')
|
||||
if summary.index("anySourceFailed") > summary.index('"Up to date"'):
|
||||
raise SystemExit('summary() says "Up to date" before it asks whether a source failed')
|
||||
PY
|
||||
grep -Fq 'Updates.sourceError("flatpak") === ""' "$page" \
|
||||
|| fail 'the applications row says "Current" without asking whether the list was read'
|
||||
grep -Fq 'Updates.sourceError("firmware") === ""' "$page" \
|
||||
|| fail 'the firmware row says "Current" without asking whether the list was read'
|
||||
grep -Fq 'securityKnown' "$page" \
|
||||
|| fail 'the headline says "nothing security-critical" without asking whether advisories were read'
|
||||
|
||||
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")" \
|
||||
|
||||
@@ -279,6 +279,27 @@ grep -q 'confirmingRemoval' "$delete_page" \
|
||||
grep -q 'This cannot be undone' "$delete_page" \
|
||||
|| fail 'the page does not say that deleting an account destroys their files'
|
||||
|
||||
# ── The consent names the real directory, or none ───────────────────────────
|
||||
#
|
||||
# Four sentences, the armed confirmation among them, built "/home/" + userName
|
||||
# and presented the result as fact. A home directory is not reliably there: it
|
||||
# can be moved, or live on another mount. accountsservice reports the real one
|
||||
# and the helper already carries it, unread. The one place the app was most
|
||||
# confident was the one place it was guessing, and it was asking for consent to
|
||||
# an irreversible deletion at the time.
|
||||
grep -Fq '"homeDirectory": str(values.get("HomeDirectory") or "")' "$helper" \
|
||||
|| fail 'the helper no longer reports the real home directory'
|
||||
grep -Fq 'function homeDirectory(user: var): string' "$service" \
|
||||
|| fail 'the service does not expose the reported home directory'
|
||||
if grep -q '"/home/"' "$delete_page"; then
|
||||
fail 'the deletion consent constructs a home path instead of reading the reported one'
|
||||
fi
|
||||
grep -Fq 'UserAccounts.homeDirectory(' "$delete_page" \
|
||||
|| fail 'the deletion consent does not read the reported home directory'
|
||||
# And an account with no reported path must be described, not invented.
|
||||
grep -Fq 'their home directory' "$delete_page" \
|
||||
|| fail 'an account with no reported home directory has nothing honest to say about it'
|
||||
|
||||
# The page must not offer to change the type of the only administrator either.
|
||||
type_page="$(file_calling 'administratorCount')"
|
||||
[[ -n "$type_page" ]] || fail 'nothing on the page knows how many administrators there are'
|
||||
|
||||
Reference in New Issue
Block a user