Files
Panama/tests/quickshell/sound-routing-contract
T

302 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# Sending one application to a different output is the thing every mixer on
# every other desktop has and Panama's did not. SoundRouting is the singleton
# that does it, because moving a stream means `pactl move-sink-input` and the
# Sound page is banned from shelling out.
#
# The parts that are easy to get wrong and impossible to notice:
#
# * an application is a *group* of streams -- a browser playing two tabs has
# two sink inputs, and moving one of them is worse than moving none;
# * pactl wants the stream's `object.serial`, not its node id, and the two
# are different numbers that both look plausible in a log;
# * a stream with no serial still has to move, on its node id;
# * an application that stopped playing between the click and the call must
# not turn into a pactl invocation with an empty argument;
# * putting an application *back* on the system default is not the same call
# in reverse -- moving it to the default sink pins it there, so it stops
# following the moment the default moves again.
#
# Runs against recording pactl and pw-metadata stubs. No real stream is ever
# moved and no real metadata key is ever deleted.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
service="$repo_dir/config/dot/quickshell/services/SoundRouting.qml"
harness="$repo_dir/config/dot/quickshell/sound-services-harness.qml"
fixture="$(mktemp -d /tmp/panama-sound-routing.XXXXXX)"
state_home="$fixture/state"
shell_log="$fixture/quickshell.log"
harness_pid=""
fail() {
printf 'sound routing contract: %s\n' "$1" >&2
[[ -s "$shell_log" ]] && sed -n '1,80p' "$shell_log" >&2
exit 1
}
[[ -f "$service" ]] || fail 'SoundRouting.qml is missing'
[[ -f "$harness" ]] || fail 'sound-services-harness.qml is missing'
mkdir -p "$fixture/bin" "$state_home"
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
if [[ "$(cat "$PANAMA_SOUND_MOVE_MODE" 2>/dev/null || printf 'good')" == "failing" ]]; then
printf 'Failure: No such entity\n' >&2
exit 1
fi
exit 0
STUB
chmod +x "$fixture/bin/pactl"
cat >"$fixture/bin/pw-metadata" <<'STUB'
#!/usr/bin/env bash
printf 'pw-metadata' >>"$PANAMA_SOUND_PACTL_LOG"
printf ' <%s>' "$@" >>"$PANAMA_SOUND_PACTL_LOG"
printf '\n' >>"$PANAMA_SOUND_PACTL_LOG"
exit 0
STUB
chmod +x "$fixture/bin/pw-metadata"
# SoundCards and SoundDefaults share this harness and would otherwise read the
# live daemon. Both take a fixture path; give them inert ones so every line in
# the log below came from SoundRouting.
printf '[]\n' >"$fixture/cards.json"
: >"$fixture/defaults"
export PANAMA_SOUND_PACTL_LOG="$fixture/pactl.log"
export PANAMA_SOUND_MOVE_MODE="$fixture/mode"
export PANAMA_SOUND_CARDS_FIXTURE="$fixture/cards.json"
export PANAMA_SOUND_DEFAULTS_FIXTURE="$fixture/defaults"
: >"$PANAMA_SOUND_PACTL_LOG"
printf 'good\n' >"$PANAMA_SOUND_MOVE_MODE"
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_MOVE_MODE="$PANAMA_SOUND_MOVE_MODE" \
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
}
await_log() {
local needle="$1"
for _ in $(seq 1 60); do
grep -Fq "$needle" "$PANAMA_SOUND_PACTL_LOG" && return 0
sleep 0.1
done
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'
# ── Every stream in the group moves ──────────────────────────────────────────
: >"$PANAMA_SOUND_PACTL_LOG"
ipc call sound-services-test moveApplication twoStream \
alsa_output.pci-0000_00_1f.3.analog-stereo >/dev/null
await_log 'move-sink-input' \
|| fail 'moving an application never reached pactl'
for _ in $(seq 1 40); do
[[ "$(grep -Fc 'move-sink-input' "$PANAMA_SOUND_PACTL_LOG")" == "2" ]] && break
sleep 0.05
done
grep -Fq 'pactl <move-sink-input> <412> <alsa_output.pci-0000_00_1f.3.analog-stereo>' \
"$PANAMA_SOUND_PACTL_LOG" \
|| fail "the first stream was not moved by its object.serial: $(cat "$PANAMA_SOUND_PACTL_LOG")"
grep -Fq 'pactl <move-sink-input> <418> <alsa_output.pci-0000_00_1f.3.analog-stereo>' \
"$PANAMA_SOUND_PACTL_LOG" \
|| fail "the second stream of the same application stayed behind: $(cat "$PANAMA_SOUND_PACTL_LOG")"
# The node id is a different number that looks just as plausible in a log, and
# pactl will happily move whatever stream happens to carry it.
if grep -Eq 'move-sink-input> <(61|62)>' "$PANAMA_SOUND_PACTL_LOG"; then
fail "streams were moved by node id instead of object.serial: $(cat "$PANAMA_SOUND_PACTL_LOG")"
fi
state="$(ipc call sound-services-test routing)"
for _ in $(seq 1 40); do
state="$(ipc call sound-services-test routing)"
jq -e '.busy == false' >/dev/null <<<"$state" && break
sleep 0.1
done
jq -e '.busy == false and .lastError == ""' >/dev/null <<<"$state" \
|| fail "a successful move left the service busy or carrying an error: $state"
# ── A stream PipeWire never gave a serial still moves ────────────────────────
: >"$PANAMA_SOUND_PACTL_LOG"
ipc call sound-services-test moveApplication serialless \
alsa_output.pci-0000_00_1f.3.analog-stereo >/dev/null
await_log 'move-sink-input' || fail 'a serial-less stream was skipped entirely'
grep -Fq 'pactl <move-sink-input> <77> <alsa_output.pci-0000_00_1f.3.analog-stereo>' \
"$PANAMA_SOUND_PACTL_LOG" \
|| fail "a stream with no object.serial did not fall back to its node id: $(cat "$PANAMA_SOUND_PACTL_LOG")"
# ── Nothing to move, nothing to run ──────────────────────────────────────────
# An application that stopped playing between the click and the call is the
# ordinary case, not an error, and it must not become `pactl move-sink-input
# "" <sink>` -- which pactl answers by moving something else.
: >"$PANAMA_SOUND_PACTL_LOG"
ipc call sound-services-test moveApplication empty \
alsa_output.pci-0000_00_1f.3.analog-stereo >/dev/null || true
ipc call sound-services-test moveApplication twoStream "" >/dev/null || true
sleep 0.3
if grep -Fq 'move-sink-input' "$PANAMA_SOUND_PACTL_LOG"; then
fail "an empty group or an unnamed sink still started a move: $(cat "$PANAMA_SOUND_PACTL_LOG")"
fi
# ── Following the system default again ───────────────────────────────────────
# Not the same call in reverse. `move-sink-input` to the current default *pins*
# the stream there, so it would stop following and quietly stay behind the next
# time the default moved -- the exact bug the button exists to undo. The pin
# lives as a `target.object` key on the stream's node id in PipeWire's default
# metadata, and releasing it means deleting that key.
#
# Note the id asymmetry, which is the other way to get this wrong: pactl speaks
# object serials (412, 418) and pw-metadata speaks node ids (61, 62). They are
# different numbers for the same stream and both look right in a log.
: >"$PANAMA_SOUND_PACTL_LOG"
ipc call sound-services-test routeToDefault twoStream >/dev/null
await_log 'target.object' || fail 'returning an application to the default ran nothing'
for _ in $(seq 1 60); do
[[ "$(grep -Fc 'pw-metadata' "$PANAMA_SOUND_PACTL_LOG")" == "4" ]] && break
sleep 0.05
done
grep -Fq 'pw-metadata <-n> <default> <-d> <61> <target.object>' "$PANAMA_SOUND_PACTL_LOG" \
|| fail "the first stream's pin was not released: $(cat "$PANAMA_SOUND_PACTL_LOG")"
grep -Fq 'pw-metadata <-n> <default> <-d> <62> <target.object>' "$PANAMA_SOUND_PACTL_LOG" \
|| fail "the second stream of the same application stayed pinned: $(cat "$PANAMA_SOUND_PACTL_LOG")"
# Streams pinned by older tooling carry `target.node` instead, and a release
# that only deletes one of the two keys leaves half the population stuck.
grep -Fq 'pw-metadata <-n> <default> <-d> <61> <target.node>' "$PANAMA_SOUND_PACTL_LOG" \
|| fail "the legacy target.node pin was left in place: $(cat "$PANAMA_SOUND_PACTL_LOG")"
if grep -Fq 'move-sink-input' "$PANAMA_SOUND_PACTL_LOG"; then
fail "returning to the default moved the stream to a sink, which pins it there instead of releasing it: $(cat "$PANAMA_SOUND_PACTL_LOG")"
fi
if grep -Eq 'pw-metadata <.*> <(412|418)>' "$PANAMA_SOUND_PACTL_LOG"; then
fail "pw-metadata was given an object serial where it wants a node id: $(cat "$PANAMA_SOUND_PACTL_LOG")"
fi
: >"$PANAMA_SOUND_PACTL_LOG"
ipc call sound-services-test routeToDefault empty >/dev/null || true
sleep 0.3
[[ ! -s "$PANAMA_SOUND_PACTL_LOG" ]] \
|| fail "an empty group still ran a command: $(cat "$PANAMA_SOUND_PACTL_LOG")"
# ── Where an application is playing ──────────────────────────────────────────
# Fixture streams are not in the real graph, so nothing links them to a sink.
# That is exactly the "follows the system default" case, and it reads as an
# empty string rather than "unknown", a null, or a thrown error.
[[ "$(ipc call sound-services-test currentSinkFor twoStream)" == "" ]] \
|| fail 'an unlinked stream did not read as following the system default'
[[ "$(ipc call sound-services-test currentSinkFor empty)" == "" ]] \
|| fail 'an application with no streams did not read as following the system default'
# ── pactl refusing ───────────────────────────────────────────────────────────
printf 'failing\n' >"$PANAMA_SOUND_MOVE_MODE"
: >"$PANAMA_SOUND_PACTL_LOG"
ipc call sound-services-test moveApplication twoStream \
alsa_output.pci-0000_00_1f.3.analog-stereo >/dev/null
for _ in $(seq 1 60); do
state="$(ipc call sound-services-test routing)"
[[ -n "$(jq -r '.lastError' <<<"$state")" ]] && break
sleep 0.1
done
jq -e '.lastError != "" and .busy == false' >/dev/null <<<"$state" \
|| fail "a refused move was neither reported nor finished: $state"
# An error is a state, not a terminal one.
printf 'good\n' >"$PANAMA_SOUND_MOVE_MODE"
ipc call sound-services-test moveApplication twoStream \
alsa_output.pci-0000_00_1f.3.analog-stereo >/dev/null
for _ in $(seq 1 60); do
state="$(ipc call sound-services-test routing)"
jq -e '.lastError == "" and .busy == false' >/dev/null <<<"$state" && break
sleep 0.1
done
jq -e '.lastError == "" and .busy == false' >/dev/null <<<"$state" \
|| fail "the service never cleared the error from a move that then succeeded: $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 'SoundRouting is not a singleton'
# There is more than one way to put an application back on the system default
# and they do not behave the same. Whichever one is here, the next person has
# to be able to find out why, without running it.
python3 - "$service" <<'PY' || fail 'routeToDefault does not say which mechanism it uses'
import re
import sys
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
index = next((i for i, line in enumerate(lines)
if re.search(r'\bfunction\s+routeToDefault\b', line)), None)
if index is None:
raise SystemExit("routeToDefault is missing")
above = [line.strip() for line in lines[max(0, index - 12):index]]
if not any(line.startswith("//") for line in above):
raise SystemExit("routeToDefault has no comment explaining its mechanism")
PY
trap - EXIT
cleanup
printf 'sound routing contract: PASS\n'