Files
Panama/tests/quickshell/sound-defaults-contract

280 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# The device list used to show only what is present, which meant that unplugging
# a headset made the row that owned the sound vanish and left no trace of why
# the laptop speakers had taken over. PipeWire remembers the choice; the page
# did not show it.
#
# SoundDefaults is what makes the ghost row possible. Its whole job is the
# difference between two keys that look the same in a log:
#
# default.audio.sink what is playing right now
# default.configured.audio.sink what was chosen, present or not
#
# Reading the first one is the bug, and it is a quiet one: it always names a
# device that exists, so the ghost row never appears and the feature does
# nothing on every machine where the configured device happens to be plugged
# in -- which is most machines, most of the time. This pins the second.
#
# It also pins the label, because an absent device has no description to borrow
# and the stored name is all there is to work with.
#
# Runs against canned metadata through the service's own fixture seam. The
# session's real defaults are never read and never written.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
service="$repo_dir/config/dot/quickshell/services/SoundDefaults.qml"
harness="$repo_dir/config/dot/quickshell/sound-services-harness.qml"
fixture="$(mktemp -d /tmp/panama-sound-defaults.XXXXXX)"
state_home="$fixture/state"
shell_log="$fixture/quickshell.log"
harness_pid=""
# A Bluetooth address no adapter in this house has ever seen, so the "not
# present" assertions cannot be flipped by what happens to be paired during the
# sweep. The effective sink is a plausible built-in card, and the point of the
# fixture is that the two disagree.
configured_sink="bluez_output.AA_BB_CC_11_22_33.1"
effective_sink="alsa_output.pci-0000_00_1f.3.analog-stereo"
configured_source="alsa_input.usb-Blue_Microphones_Yeti-00.analog-stereo"
fail() {
printf 'sound defaults contract: %s\n' "$1" >&2
[[ -s "$shell_log" ]] && sed -n '1,80p' "$shell_log" >&2
exit 1
}
[[ -f "$service" ]] || fail 'SoundDefaults.qml is missing'
[[ -f "$harness" ]] || fail 'sound-services-harness.qml is missing'
mkdir -p "$fixture/bin" "$state_home"
# The headset is the configured sink and is not here; the built-in card is what
# is actually playing. A service that read the effective key would report the
# built-in card and be wrong in the one case this exists for. The configured
# line is written *first* so that "last key wins" is not what makes it pass.
cat >"$fixture/metadata-absent" <<JSON
Found "default" metadata 30
update: id:0 key:'default.configured.audio.sink' value:'{"name":"$configured_sink"}' type:'Spa:String:JSON'
update: id:0 key:'default.audio.sink' value:'{"name":"$effective_sink"}' type:'Spa:String:JSON'
update: id:0 key:'default.configured.audio.source' value:'{"name":"$configured_source"}' type:'Spa:String:JSON'
update: id:0 key:'default.audio.source' value:'{"name":"$configured_source"}' type:'Spa:String:JSON'
JSON
# Nothing was ever configured: PipeWire is picking by priority. There is no
# ghost to draw, and an empty string is how that has to read -- not the name of
# whatever happens to be playing, which would render a ghost row for a device
# that is present, sitting underneath its own real row.
cat >"$fixture/metadata-unconfigured" <<JSON
Found "default" metadata 30
update: id:0 key:'default.audio.sink' value:'{"name":"$effective_sink"}' type:'Spa:String:JSON'
update: id:0 key:'default.audio.source' value:'{"name":"$configured_source"}' type:'Spa:String:JSON'
JSON
cat >"$fixture/metadata-empty" <<'JSON'
Found "default" metadata 30
JSON
# pw-metadata prints keys it does not own alongside the ones it does, and some
# of them are bare strings rather than JSON. One of those must not take the
# whole read down with it.
cat >"$fixture/metadata-noise" <<JSON
Found "default" metadata 30
update: id:0 key:'log.level' value:'2' type:'Spa:String:JSON'
update: id:0 key:'clock.force-quantum' value:'not json at all' type:'Spa:String:JSON'
update: id:0 key:'default.configured.audio.sink' value:'{"name":"$configured_sink"}' type:'Spa:String:JSON'
JSON
# SoundCards shares this harness. An inert fixture keeps it off the live daemon.
printf '[]\n' >"$fixture/cards.json"
# Nothing here should reach pipewire, but if the fixture seam ever regressed,
# these stubs are what stands between the contract and the session's real
# defaults. They answer nothing, which fails the assertions loudly rather than
# passing them against real hardware.
for tool in pw-metadata pw-dump pactl wpctl; do
printf '%s\n' '#!/usr/bin/env bash' 'exit 1' >"$fixture/bin/$tool"
chmod +x "$fixture/bin/$tool"
done
export PANAMA_SOUND_DEFAULTS_FIXTURE="$fixture/defaults"
export PANAMA_SOUND_CARDS_FIXTURE="$fixture/cards.json"
# The seam is a path read once at construction, so a case changes what the file
# says rather than where it points. `absent` deletes it, which is what a read
# that fails looks like from the service's side.
defaults_fixture() {
if [[ "$1" == "failing" ]]; then
rm -f "$PANAMA_SOUND_DEFAULTS_FIXTURE"
else
cp "$fixture/metadata-$1" "$PANAMA_SOUND_DEFAULTS_FIXTURE"
fi
}
defaults_fixture absent
instances_for_harness() {
qs list --all 2>/dev/null | awk -v expected="$harness" '
/^Instance / { pid = "" }
/^[[:space:]]*Process ID:/ { pid = $3 }
/^[[:space:]]*Config path:/ {
path = $0
sub(/^[[:space:]]*Config path: /, "", path)
if (path == expected && pid ~ /^[0-9]+$/) print pid
}
'
}
cleanup() {
if [[ "$harness_pid" =~ ^[0-9]+$ ]] && kill -0 "$harness_pid" 2>/dev/null; then
kill "$harness_pid" 2>/dev/null || true
for _ in $(seq 1 40); do
kill -0 "$harness_pid" 2>/dev/null || break
sleep 0.05
done
fi
rm -rf "$fixture"
}
trap cleanup EXIT
run() {
PATH="$fixture/bin:$PATH" \
XDG_STATE_HOME="$state_home" \
PANAMA_SOUND_DEFAULTS_FIXTURE="$PANAMA_SOUND_DEFAULTS_FIXTURE" \
PANAMA_SOUND_CARDS_FIXTURE="$PANAMA_SOUND_CARDS_FIXTURE" \
qs -p "$harness" "$@"
}
ipc() {
if [[ "$harness_pid" =~ ^[0-9]+$ ]]; then
run ipc --pid "$harness_pid" "$@"
else
run ipc "$@"
fi
}
# Refresh, then wait for the parsed value to settle into the shape the filter
# describes. Everything passed here goes to jq, so a case can bring its own
# `--arg`.
await_defaults() {
local state=""
ipc call sound-services-test refreshDefaults >/dev/null
for _ in $(seq 1 60); do
state="$(ipc call sound-services-test defaults)"
jq -e "$@" >/dev/null <<<"$state" && { printf '%s' "$state"; return 0; }
sleep 0.1
done
printf '%s' "$state"
return 1
}
run --daemonize >"$shell_log" 2>&1 || fail 'sound services harness did not launch'
for _ in $(seq 1 60); do
harness_pid="$(instances_for_harness | head -1)"
if [[ "$harness_pid" =~ ^[0-9]+$ ]] \
&& ipc show 2>/dev/null | rg -q '^target sound-services-test$'; then
break
fi
sleep 0.1
done
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'sound services harness process did not start'
# ── Configured, and not here ─────────────────────────────────────────────────
state="$(await_defaults --arg sink "$configured_sink" '.sink == $sink')" \
|| fail "the configured sink was not read from the configured key: $state"
[[ "$(jq -r .sink <<<"$state")" != "$effective_sink" ]] \
|| fail 'the effective sink was reported as the configured one, so no ghost row can ever appear'
[[ "$(jq -r .source <<<"$state")" == "$configured_source" ]] \
|| fail "the configured source was not read: $state"
# ── The ghost row ────────────────────────────────────────────────────────────
# Configured, absent from the graph, and labelled from the stored name -- which
# is all there is, because the node that carried the description is gone.
ghost="$(ipc call sound-services-test absent output)"
jq -e --arg sink "$configured_sink" '.present == false and .name == $sink' \
>/dev/null <<<"$ghost" || fail "a configured device that is not present did not become a ghost row: $ghost"
[[ "$(jq -r .label <<<"$ghost")" == "Bluetooth device (AA:BB:CC:11:22:33)" ]] \
|| fail "the ghost row's label is the raw node name: $(jq -r .label <<<"$ghost")"
# The label is the only part of a ghost row anyone reads, and the names it has
# to work with are structured differently per transport.
[[ "$(ipc call sound-services-test labelFor raop_sink.Living-Room.local.192.168.1.162.7000)" \
== "Living Room" ]] \
|| fail 'an AirPlay speaker is not named by its mDNS hostname'
[[ "$(ipc call sound-services-test labelFor alsa_output.usb-Generic_USB_Audio-00.analog-stereo)" \
== "Generic USB Audio" ]] \
|| fail 'a USB device is not named by its product string'
[[ "$(ipc call sound-services-test labelFor "")" == "" ]] \
|| fail 'an empty name produced a label out of nothing'
# ── Keys that are not ours ───────────────────────────────────────────────────
# pw-metadata prints the whole store, and some of it is not JSON. One bad line
# must not take the read down with it.
defaults_fixture noise
state="$(await_defaults --arg sink "$configured_sink" '.sink == $sink')" \
|| fail "a non-JSON value on an unrelated key discarded the whole read: $state"
# ── Nothing configured ───────────────────────────────────────────────────────
defaults_fixture unconfigured
state="$(await_defaults '.sink == ""')" \
|| fail "an unconfigured default did not read as empty: $state"
[[ "$(jq -r .source <<<"$state")" == "" ]] \
|| fail "an unconfigured source did not read as empty: $state"
jq -e '.present == true' >/dev/null <<<"$(ipc call sound-services-test absent output)" \
|| fail 'a session with no configured default still drew a ghost row'
# ── An empty metadata store ──────────────────────────────────────────────────
defaults_fixture empty
state="$(await_defaults '.sink == "" and .source == ""')" \
|| fail "an empty metadata store did not read as empty: $state"
# ── The read failing ─────────────────────────────────────────────────────────
# A session where this cannot be read is a session with no ghost rows, which is
# the pre-redesign behaviour and perfectly usable. It is not a session where the
# Sound page reports a device nobody configured.
defaults_fixture failing
state="$(await_defaults '.sink == "" and .source == ""')" \
|| fail "a failed read left a stale or invented configured device: $state"
# ── Recovery ─────────────────────────────────────────────────────────────────
defaults_fixture absent
state="$(await_defaults --arg sink "$configured_sink" '.sink == $sink')" \
|| fail "the service never recovered after a failed read: $state"
if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign' "$shell_log"; then
fail 'sound services harness emitted a QML runtime warning'
fi
# ── Static ───────────────────────────────────────────────────────────────────
rg -Fq 'pragma Singleton' "$service" || fail 'SoundDefaults is not a singleton'
# The two greps that say which keys are being read. A regression here is
# invisible at runtime on any machine whose configured device is plugged in.
rg -Fq 'default.configured.audio.sink' "$service" \
|| fail 'SoundDefaults does not name the configured sink key'
rg -Fq 'default.configured.audio.source' "$service" \
|| fail 'SoundDefaults does not name the configured source key'
# The live command, which the fixture seam means nothing above exercises.
rg -Fq '"pw-metadata", "-n", "default", "0"' "$service" \
|| fail 'the live read is not pw-metadata against the default metadata store'
# `Pipewire.preferredDefaultAudioSink` is the same configured value typed as a
# node pointer, so it reads null in exactly the case this service exists for.
# Binding the ghost row to it would make the ghost row impossible. The file's
# own comment is allowed to say so; the code is not allowed to do it.
python3 - "$service" <<'PY' || fail 'the configured device was read as a node pointer, which is null precisely when the device is absent'
import sys
for line in open(sys.argv[1], encoding="utf-8"):
if line.strip().startswith("//"):
continue
if "preferredDefaultAudio" in line:
raise SystemExit(1)
PY
trap - EXIT
cleanup
printf 'sound defaults contract: PASS\n'