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

375 lines
15 KiB
Bash
Executable File

#!/usr/bin/env bash
# Device profiles are the last thing the Sound page handed to GNOME Settings,
# and the reason it had to was that reading them means shelling out to pactl.
# SoundCards does that, so the page can show the profile dropdown itself.
#
# What is worth pinning is the parsing, because pactl's JSON is not the shape
# the UI wants and every mistake in the conversion is invisible until someone
# with a headset and a surround card opens the page:
#
# * profiles arrive keyed by name in an object, and come out as an ordered
# list, because a dropdown has an order and an object does not;
# * a profile pactl marked unavailable is kept and flagged, not dropped --
# "Headset" missing entirely is a bug report, "Headset (unavailable)" is an
# explanation;
# * the port hint says what is physically plugged in, which is the one thing
# the profile name never tells you;
# * pactl failing produces an error string, not an empty list that reads as
# "this machine has no sound card".
#
# Runs against canned pactl output. The real audio graph is never touched:
# pactl is replaced on PATH and through the service's own helper seam.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
service="$repo_dir/config/dot/quickshell/services/SoundCards.qml"
harness="$repo_dir/config/dot/quickshell/sound-services-harness.qml"
fixture="$(mktemp -d /tmp/panama-sound-cards.XXXXXX)"
state_home="$fixture/state"
shell_log="$fixture/quickshell.log"
harness_pid=""
fail() {
printf 'sound cards contract: %s\n' "$1" >&2
[[ -s "$shell_log" ]] && sed -n '1,80p' "$shell_log" >&2
exit 1
}
[[ -f "$service" ]] || fail 'SoundCards.qml is missing'
[[ -f "$harness" ]] || fail 'sound-services-harness.qml is missing'
mkdir -p "$fixture/bin" "$state_home"
# Two cards, chosen for the two things that go wrong. The built-in card has a
# connected line out and an available analog profile. The headset has no port
# connected at all and a profile pactl reports as unavailable, which is what a
# Bluetooth device looks like between "paired" and "actually here".
cat >"$fixture/cards.good.json" <<'JSON'
[
{
"index": 47,
"name": "alsa_card.pci-0000_00_1f.3",
"driver": "module-alsa-card.c",
"properties": {
"device.description": "Built-in Audio",
"device.api": "alsa"
},
"profiles": {
"off": {
"description": "Off",
"sinks": 0,
"sources": 0,
"priority": 0,
"available": true
},
"output:hdmi-stereo": {
"description": "Digital Stereo (HDMI) Output",
"sinks": 1,
"sources": 0,
"priority": 5900,
"available": false
},
"output:analog-stereo+input:analog-stereo": {
"description": "Analog Stereo Duplex",
"sinks": 1,
"sources": 1,
"priority": 6565,
"available": true
}
},
"active_profile": "output:analog-stereo+input:analog-stereo",
"ports": {
"analog-output-lineout": {
"description": "Line Out",
"type": "Line",
"priority": 9900,
"availability_group": "Legacy 1",
"availability": "available"
},
"analog-output-headphones": {
"description": "Headphones",
"type": "Headphones",
"priority": 9000,
"availability_group": "Legacy 2",
"availability": "not available"
}
}
},
{
"index": 51,
"name": "bluez_card.74_15_F5_13_A4_28",
"driver": "module-bluez5-device.c",
"properties": {
"device.description": "WH-1000XM4",
"device.api": "bluez5"
},
"profiles": {
"a2dp-sink": {
"description": "High Fidelity Playback (A2DP Sink)",
"sinks": 1,
"sources": 0,
"priority": 40,
"available": true
},
"headset-head-unit": {
"description": "Headset Head Unit (HSP/HFP)",
"sinks": 1,
"sources": 1,
"priority": 30,
"available": false
}
},
"active_profile": "a2dp-sink",
"ports": {
"bluez-output": {
"description": "Headphone",
"type": "Headphones",
"priority": 0,
"availability_group": "",
"availability": "not available"
}
}
}
]
JSON
# The read side never runs pactl: SoundCards' own seam,
# PANAMA_SOUND_CARDS_FIXTURE, points it at a file it `cat`s instead. So this
# stub exists for the *write* side -- `pactl set-card-profile` -- and to prove
# the read side did not quietly fall back to the live daemon.
cat >"$fixture/bin/pactl" <<'STUB'
#!/usr/bin/env bash
printf 'pactl' >>"$PANAMA_SOUND_PACTL_LOG"
printf ' <%s>' "$@" >>"$PANAMA_SOUND_PACTL_LOG"
printf '\n' >>"$PANAMA_SOUND_PACTL_LOG"
for arg in "$@"; do
[[ "$arg" == "cards" ]] && { printf '[]\n'; exit 0; }
done
exit 0
STUB
chmod +x "$fixture/bin/pactl"
# SoundDefaults shares this harness and reads on construction. Give it a
# fixture of its own so it cannot reach the session's real metadata.
: >"$fixture/defaults"
export PANAMA_SOUND_PACTL_LOG="$fixture/pactl.log"
export PANAMA_SOUND_CARDS_FIXTURE="$fixture/cards.json"
export PANAMA_SOUND_DEFAULTS_FIXTURE="$fixture/defaults"
: >"$PANAMA_SOUND_PACTL_LOG"
# 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 how a
# read that fails looks from the service's side.
cards_fixture() {
case "$1" in
good) cp "$fixture/cards.good.json" "$PANAMA_SOUND_CARDS_FIXTURE" ;;
malformed) printf 'Failure: Module initialization failed\n' >"$PANAMA_SOUND_CARDS_FIXTURE" ;;
absent) rm -f "$PANAMA_SOUND_CARDS_FIXTURE" ;;
esac
}
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_PACTL_LOG="$PANAMA_SOUND_PACTL_LOG" \
PANAMA_SOUND_CARDS_FIXTURE="$PANAMA_SOUND_CARDS_FIXTURE" \
PANAMA_SOUND_DEFAULTS_FIXTURE="$PANAMA_SOUND_DEFAULTS_FIXTURE" \
qs -p "$harness" "$@"
}
ipc() {
if [[ "$harness_pid" =~ ^[0-9]+$ ]]; then
run ipc --pid "$harness_pid" "$@"
else
run ipc "$@"
fi
}
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'
ipc show 2>/dev/null | rg -q '^target sound-services-test$' \
|| fail 'sound-services-test IPC target did not register'
# ── The good parse ───────────────────────────────────────────────────────────
cards_fixture good
ipc call sound-services-test refreshCards >/dev/null
state=""
for _ in $(seq 1 60); do
state="$(ipc call sound-services-test cards)"
[[ "$(jq -r '.cards | length' <<<"$state")" == "2" ]] && break
sleep 0.1
done
jq -e '.cards | length == 2' >/dev/null <<<"$state" \
|| fail "canned pactl output did not become two cards: $state"
jq -e '.lastError == ""' >/dev/null <<<"$state" \
|| fail "a successful listing carried an error: $state"
# The fixture is the whole read. A service that fell back to the live daemon
# when handed one would pass every assertion below on the developer's machine
# and none of them on anyone else's.
if grep -Fq 'list' "$PANAMA_SOUND_PACTL_LOG"; then
fail "the fixture was ignored and the live daemon was read: $(cat "$PANAMA_SOUND_PACTL_LOG")"
fi
# Which leaves the live command itself unexercised, so pin it where it is
# written. `-f json` is the load-bearing half: without it pactl prints a
# human-readable block that JSON.parse rejects, and every card disappears.
rg -Fq '"pactl", "-f", "json", "list", "cards"' "$service" \
|| fail 'the live card listing is not the JSON one'
jq -e '.cards[0].name == "alsa_card.pci-0000_00_1f.3"
and .cards[0].description == "Built-in Audio"
and .cards[0].activeProfile == "output:analog-stereo+input:analog-stereo"' \
>/dev/null <<<"$state" || fail "the built-in card lost its identity: $state"
# Profiles become an ordered list. An object has no order, and a dropdown does.
jq -e '.cards[0].profiles | type == "array"' >/dev/null <<<"$state" \
|| fail "profiles are still keyed by name, so the dropdown has no order: $state"
jq -e '(.cards[0].profiles | map(.name)) as $names
| ($names | index("output:analog-stereo+input:analog-stereo")) != null
and ($names | index("output:hdmi-stereo")) != null' \
>/dev/null <<<"$state" || fail "a profile pactl reported went missing: $state"
jq -e '(.cards[0].profiles[] | select(.name == "output:analog-stereo+input:analog-stereo") | .description)
== "Analog Stereo Duplex"' >/dev/null <<<"$state" \
|| fail "profiles are labelled by their internal name rather than their description: $state"
# The order is pactl's priority, descending -- the order PulseAudio and GNOME
# both present, and the one that puts "Off" at the bottom where nobody clicks
# it by accident. The fixture's priorities (6565, 5900, 0) are deliberately not
# the order the JSON lists them in, so a service that kept insertion order
# fails here.
jq -e '(.cards[0].profiles | map(.name))
== ["output:analog-stereo+input:analog-stereo", "output:hdmi-stereo", "off"]' \
>/dev/null <<<"$state" || fail "profiles are not ordered by priority: $state"
# Unavailable profiles are kept and flagged. Dropping them is how a card ends
# up silently missing the mode someone is looking for.
jq -e '(.cards[0].profiles[] | select(.name == "output:hdmi-stereo") | .available) == false
and (.cards[0].profiles[] | select(.name == "output:analog-stereo+input:analog-stereo") | .available) == true' \
>/dev/null <<<"$state" || fail "profile availability was not carried through: $state"
# The port hint says what is plugged in. The spec's own example is "Line out
# connected"; this pins the two halves rather than the exact casing, so a
# sentence tweak does not read as a regression -- but a hint that names the
# wrong port, or none, does.
hint="$(jq -r '.cards[0].portHint' <<<"$state")"
[[ "$(tr '[:upper:]' '[:lower:]' <<<"$hint")" == *"line out"* ]] \
|| fail "the built-in card's port hint does not name its connected port: $hint"
[[ "$(tr '[:upper:]' '[:lower:]' <<<"$hint")" == *"connected"* ]] \
|| fail "the port hint does not say the port is connected: $hint"
# Nothing plugged in says so, in the spec's words.
[[ "$(jq -r '.cards[1].portHint' <<<"$state")" == "No port connected" ]] \
|| fail "a card with no available port did not say so: $(jq -r '.cards[1].portHint' <<<"$state")"
jq -e '.cards[1].name == "bluez_card.74_15_F5_13_A4_28"
and .cards[1].description == "WH-1000XM4"
and .cards[1].activeProfile == "a2dp-sink"
and ((.cards[1].profiles[] | select(.name == "headset-head-unit") | .available) == false)' \
>/dev/null <<<"$state" || fail "the Bluetooth card did not survive the parse: $state"
# ── Switching a profile ──────────────────────────────────────────────────────
: >"$PANAMA_SOUND_PACTL_LOG"
ipc call sound-services-test setProfile \
alsa_card.pci-0000_00_1f.3 output:hdmi-stereo >/dev/null
for _ in $(seq 1 60); do
grep -Fq 'set-card-profile' "$PANAMA_SOUND_PACTL_LOG" && break
sleep 0.1
done
grep -Fq 'pactl <set-card-profile> <alsa_card.pci-0000_00_1f.3> <output:hdmi-stereo>' \
"$PANAMA_SOUND_PACTL_LOG" \
|| fail "profile switch did not reach pactl: $(cat "$PANAMA_SOUND_PACTL_LOG")"
# A card or profile nobody named is not a reason to run pactl with an empty
# argument and let it decide.
: >"$PANAMA_SOUND_PACTL_LOG"
ipc call sound-services-test setProfile "" output:hdmi-stereo >/dev/null || true
ipc call sound-services-test setProfile alsa_card.pci-0000_00_1f.3 "" >/dev/null || true
sleep 0.3
if grep -Fq 'set-card-profile' "$PANAMA_SOUND_PACTL_LOG"; then
fail "an empty card or profile name still started a pactl write: $(cat "$PANAMA_SOUND_PACTL_LOG")"
fi
# ── pactl answering with something that is not JSON ──────────────────────────
cards_fixture malformed
ipc call sound-services-test refreshCards >/dev/null
for _ in $(seq 1 60); do
state="$(ipc call sound-services-test cards)"
[[ -n "$(jq -r '.lastError' <<<"$state")" ]] && break
sleep 0.1
done
jq -e '.lastError != "" and (.cards | type == "array")' >/dev/null <<<"$state" \
|| fail "unparseable pactl output did not degrade into an error: $state"
# ── pactl not answering at all ───────────────────────────────────────────────
cards_fixture absent
ipc call sound-services-test refreshCards >/dev/null
for _ in $(seq 1 60); do
state="$(ipc call sound-services-test cards)"
[[ -n "$(jq -r '.lastError' <<<"$state")" ]] && break
sleep 0.1
done
jq -e '.lastError != "" and .busy == false' >/dev/null <<<"$state" \
|| fail "a failing pactl left the service busy or silent: $state"
# ── Recovery ─────────────────────────────────────────────────────────────────
# An error is a state, not a terminal one. The card comes back on the next
# refresh, and the error goes away with it.
cards_fixture good
ipc call sound-services-test refreshCards >/dev/null
for _ in $(seq 1 60); do
state="$(ipc call sound-services-test cards)"
jq -e '.lastError == "" and (.cards | length) == 2' >/dev/null <<<"$state" && break
sleep 0.1
done
jq -e '.lastError == "" and (.cards | length) == 2' >/dev/null <<<"$state" \
|| fail "the service never recovered from a failed listing: $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
# The Sound page is a PipeWire surface. SoundCards is allowed to shell out --
# it is the sibling singleton that exists so the page does not have to -- but
# it must be the one place that speaks pactl about cards.
rg -Fq 'pragma Singleton' "$service" || fail 'SoundCards is not a singleton'
trap - EXIT
cleanup
printf 'sound cards contract: PASS\n'