From a5b6663c02b3d618b3ef760d76750b242981b41b Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 22 Aug 2026 17:52:24 +0200 Subject: [PATCH] Test bt_audio.sh to 96.97% and fix its wpctl health check Fourth of the 16 untested fixes/lib libs, the largest at 214 lines, and the last of the bluetooth family. 47 assertions across three files. The lib STAYS on the allowlist: 128/132 = 96.97%, twice with the same line set. The bug the tests found, fixed here. Line 21 read: if timeout 3 _run_as_user wpctl status &>/dev/null; then `timeout` is a binary and `_run_as_user` is a shell function that is never exported, so this always failed with rc=127 -- "timeout: failed to run command '_run_as_user'". check_pipewire_health therefore reported PipeWire hung on EVERY run and restarted a healthy audio stack every time fix_bluetooth.sh ran. The healthy branch was unreachable, which is why the test for it could not be written without first noticing. Now `_run_as_user timeout 3 wpctl status`: same bound, inside the sudo'd command. Why 96.97% and not 100%. An earlier run of this suite read 99/99 = 100.00%, and that number was wrong -- it measured the file before pre-commit's `shfmt -w` reformatted it. shfmt rewrites the `{ ...; } \` + `| bluetoothctl` one-liners into multi-line blocks ending in `} |`, which raises the denominator 99 -> 132. Confirmed unavoidable by running shfmt against HEAD's own copy: it rewrites there too, so reverting the reformat is not stable. The four uncovered lines (90, 113, 137, 157) are each that `} |` -- the pipe operator joining a block to bluetoothctl, not a statement. They do execute: a `{ echo a; echo b; } | consumer` block writes every marker to a file even though bash's xtrace never reports the left-hand side, because a pipe's LHS runs in a subshell whose xtrace is not captured. kcov instruments them and cannot attribute them; defect (a) in docs/kcov-under-report.md, third flavour. Scope, measured: the continuation-pipe form appears ONLY in bt_adapter.sh (pre-format) and bt_audio.sh (post-shfmt). None of the remaining twelve untested libs carries either, so this does not block the campaign and the detector fix stays off the critical path. Also worth 90.91% -> 96.97% on its own: the bluetoothctl stubs now drain stdin before exiting. Real bluetoothctl consumes its whole input; a stub that read one line and exited closed the pipe early, so the writing block took SIGPIPE partway through and its later lines never ran. NOTE for whoever edits this file next: bt_audio.sh is now exactly 250 lines, at the cap. The next change to it needs a split, not a comment trim. Co-Authored-By: Claude Opus 5 (1M context) --- .../bt-audio-coverage-2026-08-22.json | 19 +++ .../bt-audio-coverage-2026-08-22.json | 67 ++++++++ .../scripts/single_use/fixes/lib/bt_audio.sh | 58 +++++-- .../fixes/lib/tests/lib_test_core.sh | 14 ++ .../fixes/lib/tests/test_bt_audio.sh | 153 +++++++++++++++++ .../fixes/lib/tests/test_bt_audio_connect.sh | 160 ++++++++++++++++++ .../fixes/lib/tests/test_bt_audio_health.sh | 122 +++++++++++++ 7 files changed, 582 insertions(+), 11 deletions(-) create mode 100644 docs/superpowers/contracts/bt-audio-coverage-2026-08-22.json create mode 100644 docs/superpowers/evidence/bt-audio-coverage-2026-08-22.json create mode 100755 linux_configuration/scripts/single_use/fixes/lib/tests/test_bt_audio.sh create mode 100755 linux_configuration/scripts/single_use/fixes/lib/tests/test_bt_audio_connect.sh create mode 100755 linux_configuration/scripts/single_use/fixes/lib/tests/test_bt_audio_health.sh diff --git a/docs/superpowers/contracts/bt-audio-coverage-2026-08-22.json b/docs/superpowers/contracts/bt-audio-coverage-2026-08-22.json new file mode 100644 index 00000000..1e911c2e --- /dev/null +++ b/docs/superpowers/contracts/bt-audio-coverage-2026-08-22.json @@ -0,0 +1,19 @@ +{ + "title": "Test bt_audio.sh to its reachable ceiling, and fix the timeout bug the tests found", + "objective": "Finish the bluetooth family, the fourth of the sixteen untested fixes/lib libs and the largest at 214 lines. Writing the tests exposed a real production bug -- `timeout 3 _run_as_user wpctl status` can never succeed, because timeout is a binary and _run_as_user is a shell function -- so this lands the fix alongside the coverage rather than testing a broken branch into permanence. The lib reaches 96.97% and stays exempt: the last four lines are pipe operators no test can reach.", + "acceptance_criteria": [ + "bt_audio.sh measures 96.97% (128/132) through lib/tests/run_all.sh, twice, with the same line set both times.", + "The four uncovered lines are shown to be `} |` pipe operators rather than statements, and shown to execute, before the shortfall is accepted.", + "shfmt is shown to rewrite HEAD's one-line block form, proving the reformat that raised the denominator is unavoidable rather than something to revert.", + "The healthy branch of check_pipewire_health is actually reachable: a test asserts 'PipeWire is responsive' and passes, which was impossible before the fix.", + "meta/shell-coverage-allowlist.txt is UNCHANGED -- bt_audio.sh does not meet the 100% bar.", + "Every test file stays under the repo's 250-line cap, and pre-commit passes with no suppression added." + ], + "out_of_scope": [ + "Clearing bt_audio.sh or bt_adapter.sh from the allowlist. Both are capped by defect (a) in docs/kcov-under-report.md.", + "Changing continuation_lines() in meta/scripts/lib/shell_coverage_lines.py to exclude `} |` and backslash-continued assignments. It changes the denominator for every measured lib and must land on its own; measured to affect only these two libs.", + "Reverting shfmt's reformat of bt_audio.sh, which the pre-commit hook would reapply on the next commit touching the file.", + "The remaining twelve untested fixes/lib libs, none of which carry either continuation-pipe form." + ], + "verifier": "bash linux_configuration/scripts/single_use/fixes/lib/tests/run_all.sh (expects all files passing, 20 + 15 + 12 for the three bt_audio files), plus bash meta/scripts/shell_coverage_jail.sh --subject linux_configuration/scripts/single_use/fixes/lib/tests/run_all.sh --measure bt_audio.sh --min 1 --fail-on-case-error -- \"\" (expects 128/132 = 96.97%, uncovered 90 113 137 157), plus bash meta/scripts/check_shell_coverage.sh (expects exit 0, with bt_audio.sh still exempt)." +} diff --git a/docs/superpowers/evidence/bt-audio-coverage-2026-08-22.json b/docs/superpowers/evidence/bt-audio-coverage-2026-08-22.json new file mode 100644 index 00000000..af7b5b0e --- /dev/null +++ b/docs/superpowers/evidence/bt-audio-coverage-2026-08-22.json @@ -0,0 +1,67 @@ +{ + "intent": "Test linux_configuration/scripts/single_use/fixes/lib/bt_audio.sh, fourth of the 16 untested fixes/lib libs, and fix a production bug the tests exposed: check_pipewire_health could never see a healthy PipeWire. The lib reaches 96.97% and STAYS on the allowlist -- the four remaining lines are unreachable by construction, for the instrument reason recorded below.", + "scope": [ + "linux_configuration/scripts/single_use/fixes/lib/bt_audio.sh (one-line behaviour fix at the timeout call)", + "linux_configuration/scripts/single_use/fixes/lib/tests/test_bt_audio.sh (new, 20 assertions)", + "linux_configuration/scripts/single_use/fixes/lib/tests/test_bt_audio_connect.sh (new, 15 assertions)", + "linux_configuration/scripts/single_use/fixes/lib/tests/test_bt_audio_health.sh (new, 12 assertions)", + "linux_configuration/scripts/single_use/fixes/lib/tests/lib_test_core.sh (new _t_stub_stdin helper)", + "Non-goal: the remaining twelve untested fixes/lib libs", + "meta/shell-coverage-allowlist.txt is deliberately UNCHANGED: bt_audio.sh does not meet the 100% bar" + ], + "changes": [ + "Fixed lib/bt_audio.sh:21. It read `if timeout 3 _run_as_user wpctl status`, but `timeout` is a binary and cannot invoke a shell function, so the call always failed with rc=127 ('timeout: failed to run command _run_as_user'). check_pipewire_health therefore reported PipeWire hung on EVERY run and restarted a healthy audio stack every time. Now `_run_as_user timeout 3 wpctl status`, which keeps the 3-second bound but puts it inside the sudo'd command where it can actually run. Confirmed _run_as_user is never exported, so no caller relied on the old shape.", + "Added test_bt_audio.sh covering check_pipewire_health (wpctl absent, responsive, and hung), _run_as_user (SUDO_USER set and the USER fallback), _restart_pipewire_stack, and set_audio_profile (empty TARGET_MAC, pactl absent, no matching card, the SBC-XQ switch, and a healthy profile left alone).", + "Added test_bt_audio_connect.sh covering connect_device: empty TARGET_MAC, already connected, paired with a successful direct connect, a failed direct connect that falls through to scan-and-fail, a full scan/pair/trust/connect for an unpaired device, and a device found by the scan whose connection never becomes healthy.", + "Added test_bt_audio_health.sh covering _check_connection_health: not connected, services resolved on the first probe, services resolving only after the full adapter reset (driven by a counting dbus-send stub), and services that never resolve at all.", + "Added _t_stub_stdin to lib_test_core.sh. Stub bodies are data containing $1 and ${VAR} that must survive verbatim, and writing them as single-quoted strings tripped SC2016 on every use; reading the body from stdin via a quoted heredoc keeps it literal with no suppression.", + "Made the bluetoothctl stubs drain their stdin (`cat >/dev/null`) before exiting. The real tool consumes its whole input; a stub that read one line and exited closed the pipe early, so the writing `{ ...; } |` block took SIGPIPE partway through and its later lines never ran. That alone was worth 90.91% -> 96.97%." + ], + "verification": [ + { + "command": "bash linux_configuration/scripts/single_use/fixes/lib/tests/run_all.sh", + "result": "pass", + "evidence": "bt_audio (pipewire): 20 passed; bt_audio (connect): 15 passed; bt_audio (health): 12 passed; all 0 failed. All fixes lib test files passed, so bt_report, bt_pairing, bt_adapter and the eight pacman_hook_stall suites stay green." + }, + { + "command": "bash meta/scripts/shell_coverage_jail.sh --subject linux_configuration/scripts/single_use/fixes/lib/tests/run_all.sh --measure bt_audio.sh --min 1 --fail-on-case-error -- \"\"", + "result": "pass", + "evidence": "bt_audio.sh: 128/132 lines = 96.97%, uncovered 90, 113, 137, 157." + }, + { + "command": "bash meta/scripts/shell_coverage_jail.sh --subject linux_configuration/scripts/single_use/fixes/lib/tests/run_all.sh --measure bt_audio.sh --min 1 --fail-on-case-error -- \"\"", + "result": "pass", + "evidence": "bt_audio.sh: 128/132 lines = 96.97%, identical to the first run. Two matching runs, so the figure is not timing-dependent." + }, + { + "command": "Read the uncovered lines before deciding whether the lib could be cleared", + "result": "pass", + "evidence": "Lines 90, 113, 137 and 157 are each the `} |` that closes a piped block -- the pipe OPERATOR joining the block to `bluetoothctl`, not a statement. Proven executable with a marker file: a `{ echo a; echo b; } | consumer` block writes every marker even though bash's xtrace never reports the left-hand side, because a pipe's LHS runs in a subshell whose xtrace is not captured. So the lines run; kcov cannot attribute them." + }, + { + "command": "bash -c 'timeout 3 _run_as_user wpctl status'", + "result": "pass", + "evidence": "Reproduced the bug directly before fixing: \"timeout: failed to run command '_run_as_user': No such file or directory\", rc=127. After the fix the healthy branch is reachable and its test ('PipeWire is responsive') passes." + }, + { + "command": "shfmt -w on HEAD's bt_audio.sh, then diff against HEAD", + "result": "pass", + "evidence": "shfmt REWRITES HEAD's `{ ...; } \\` + `| bluetoothctl` one-liners into multi-line blocks ending in `} |`. The pre-commit hook runs `shfmt -w` on every staged shell file, so this reformat is unavoidable on any commit touching the file and reverting it is not stable. That is why 96.97% is accepted rather than restored to the pre-format 100%." + }, + { + "command": "grep -lnE '^[[:space:]]*\\|' and grep -lE '\\}[[:space:]]*\\|[[:space:]]*$' over all 22 fixes/lib libs", + "result": "pass", + "evidence": "The continuation-pipe form is confined to the bluetooth family: bt_adapter.sh carries the pre-format flavour and bt_audio.sh the post-shfmt one. None of the remaining twelve untested libs has either, so this artifact does not block the rest of the campaign." + } + ], + "risks": [ + "bt_audio.sh stays on the allowlist at 96.97%. The four uncovered lines are `} |` pipe operators that kcov instruments but bash never attributes a statement to -- the same defect (a) class as bt_adapter.sh line 97, in a third flavour. Clearing either lib needs meta/scripts/lib/shell_coverage_lines.py to exclude these forms, which changes the denominator for every measured lib and must land on its own.", + "An earlier measurement of this lib read 99/99 = 100.00%. That number measured the file BEFORE pre-commit's shfmt reformatted it, and is superseded by 128/132 = 96.97% here. The reformat raised the denominator from 99 to 132 by splitting six one-line blocks into their constituent statements.", + "The bt_audio.sh fix changes runtime behaviour: fix_bluetooth.sh will now SKIP the PipeWire/WirePlumber restart when wpctl answers within 3 seconds, where before it restarted the audio stack on every run. That is the intended behaviour of the function as written.", + "bt_audio.sh is now exactly 250 lines, i.e. AT the repo cap. The next edit to it must split the file; a comment trim will not buy room twice." + ], + "rollback": [ + "git revert the commit; it adds three test files, one core helper and a one-line behaviour fix, and touches no allowlist entry.", + "After rollback, run bash linux_configuration/scripts/single_use/fixes/lib/tests/run_all.sh and confirm the remaining suites pass. No coverage gate changes, since bt_audio.sh stays exempt either way." + ] +} diff --git a/linux_configuration/scripts/single_use/fixes/lib/bt_audio.sh b/linux_configuration/scripts/single_use/fixes/lib/bt_audio.sh index 378d3faa..14acf5a0 100755 --- a/linux_configuration/scripts/single_use/fixes/lib/bt_audio.sh +++ b/linux_configuration/scripts/single_use/fixes/lib/bt_audio.sh @@ -17,8 +17,10 @@ check_pipewire_health() { return 0 fi - # Test if PipeWire is responding within 3 seconds - if timeout 3 _run_as_user wpctl status &>/dev/null; then + # Timeout goes INSIDE _run_as_user: `timeout` is a binary and cannot + # invoke a shell function, so wrapping it outside always returned 127 + # and reported a healthy PipeWire as hung. + if _run_as_user timeout 3 wpctl status &>/dev/null; then log_ok "PipeWire is responsive." return 0 fi @@ -77,8 +79,16 @@ connect_device() { # ---- Attempt 1: direct connect (existing pairing) ---- if echo "$info" | grep -q "Paired: yes"; then log_info "Device is already paired. Trying direct connect..." - { echo "agent on"; echo "default-agent"; sleep 1; echo "power on"; sleep 1; echo "connect $TARGET_MAC"; sleep 15; } \ - | bluetoothctl 2>/dev/null || true + { + echo "agent on" + echo "default-agent" + sleep 1 + echo "power on" + sleep 1 + echo "connect $TARGET_MAC" + sleep 15 + } | + bluetoothctl 2>/dev/null || true if _check_connection_health "$TARGET_MAC"; then return 0 @@ -93,8 +103,15 @@ connect_device() { # ---- Attempt 2: scan + pair from scratch ---- log_info "Scanning for $TARGET_MAC (20 seconds)..." log_info "Make sure the device is in pairing mode." - { echo "power on"; sleep 1; echo "scan on"; sleep 20; echo "scan off"; sleep 2; } \ - | bluetoothctl 2>/dev/null || true + { + echo "power on" + sleep 1 + echo "scan on" + sleep 20 + echo "scan off" + sleep 2 + } | + bluetoothctl 2>/dev/null || true # Check if device was found local devices @@ -109,17 +126,36 @@ connect_device() { # Pair log_info "Pairing..." - { echo "agent on"; echo "default-agent"; sleep 1; echo "power on"; sleep 1; echo "pair $TARGET_MAC"; sleep 5; } \ - | bluetoothctl 2>/dev/null || true + { + echo "agent on" + echo "default-agent" + sleep 1 + echo "power on" + sleep 1 + echo "pair $TARGET_MAC" + sleep 5 + } | + bluetoothctl 2>/dev/null || true # Trust (so it auto-reconnects in the future) log_info "Trusting..." - { echo "trust $TARGET_MAC"; sleep 2; } | bluetoothctl 2>/dev/null || true + { + echo "trust $TARGET_MAC" + sleep 2 + } | bluetoothctl 2>/dev/null || true # Connect log_info "Connecting..." - { echo "agent on"; echo "default-agent"; sleep 1; echo "power on"; sleep 1; echo "connect $TARGET_MAC"; sleep 15; } \ - | bluetoothctl 2>/dev/null || true + { + echo "agent on" + echo "default-agent" + sleep 1 + echo "power on" + sleep 1 + echo "connect $TARGET_MAC" + sleep 15 + } | + bluetoothctl 2>/dev/null || true # Verify connection + services + audio if _check_connection_health "$TARGET_MAC"; then diff --git a/linux_configuration/scripts/single_use/fixes/lib/tests/lib_test_core.sh b/linux_configuration/scripts/single_use/fixes/lib/tests/lib_test_core.sh index 527d700a..c41649d8 100755 --- a/linux_configuration/scripts/single_use/fixes/lib/tests/lib_test_core.sh +++ b/linux_configuration/scripts/single_use/fixes/lib/tests/lib_test_core.sh @@ -106,6 +106,20 @@ exit 0" _t_stub "$name" "$body" } +# _t_stub_stdin NAME — stub NAME with a multi-line body read from stdin. +# +# The body is data, not code this file evaluates, so a `$1` or `${VAR}` in it +# must survive verbatim to the stub file. Feeding it through stdin (from a +# quoted heredoc at the call site) keeps it literal without wrapping it in a +# single-quoted string, which static analysis flags as a non-expanding +# expression on every use. +_t_stub_stdin() { + local name="$1" + local body + body="$(cat)" + _t_stub "$name" "$body" +} + # _t_unstub NAME... — remove stubs and drop bash's cached executable paths. # # `rm` alone does NOT hide a stub: bash caches the resolved location of every diff --git a/linux_configuration/scripts/single_use/fixes/lib/tests/test_bt_audio.sh b/linux_configuration/scripts/single_use/fixes/lib/tests/test_bt_audio.sh new file mode 100755 index 00000000..469715fe --- /dev/null +++ b/linux_configuration/scripts/single_use/fixes/lib/tests/test_bt_audio.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# Tests for lib/bt_audio.sh: check_pipewire_health, _run_as_user, +# _restart_pipewire_stack and set_audio_profile. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bt_harness.sh +. "${SCRIPT_DIR}/bt_harness.sh" + +# shellcheck source=../bt_audio.sh +. "${FIXES_DIR}/lib/bt_audio.sh" + +# _stub_out NAME TEXT — stub NAME to print TEXT (kept out of a quoted string +# so no $ needs to survive quoting). +_stub_out() { + printf '%s\n' "$2" >"${DEV}/out_$1" + local body + body="cat \"\${LIB_TEST_DEV}/out_$1\"" + _t_stub "$1" "$body" +} + +# _record_restart ARGS... — stand-in for the real pipewire restarter. +_record_restart() { + printf 'restart_called %s\n' "$*" >>"${DEV}/fixes" +} + +# --- check_pipewire_health -------------------------------------------------- + +# wpctl absent: the check is skipped. This host really has wpctl, so _t_hide +# removes it from PATH rather than relying on a stub dir that cannot mask it. +bt_reset +_t_unstub wpctl +_t_hide wpctl +out="$(check_pipewire_health 2>&1)" +_t_full_path +_t_contains "$out" "wpctl not found" \ + "check_pipewire_health: skips the check when wpctl is not installed" +_t_eq "" "$(_t_fixes)" "check_pipewire_health: applies no fix when wpctl is absent" + +# wpctl answers promptly: PipeWire is healthy. This case only passes because +# the timeout now runs INSIDE _run_as_user -- `timeout 3 _run_as_user ...` +# could never reach the stub, since timeout cannot invoke a shell function. +bt_reset +_t_stub wpctl 'exit 0' +_t_stub timeout 'shift; exec "$@"' +out="$(check_pipewire_health 2>&1)" +_t_contains "$out" "PipeWire is responsive" \ + "check_pipewire_health: reports a responsive PipeWire" +_t_eq "" "$(_t_fixes)" "check_pipewire_health: applies no fix when PipeWire responds" + +# wpctl fails (as a timeout would): the restart fix runs. +bt_reset +_t_stub wpctl 'exit 1' +out="$( + eval '_restart_pipewire_stack() { _record_restart "$@"; }' + check_pipewire_health 2>&1 +)" +_t_contains "$out" "appears hung" \ + "check_pipewire_health: warns when wpctl does not answer" +_t_contains "$out" "Restarting PipeWire + WirePlumber audio stack" \ + "check_pipewire_health: applies the audio-stack restart fix" +_t_contains "$(_t_fixes)" "restart_called" \ + "check_pipewire_health: calls the restart helper" + +# --- _run_as_user ----------------------------------------------------------- + +bt_reset +_t_stub id 'echo 1000' +out="$(SUDO_USER="kuhy" _run_as_user echo hello 2>&1)" +_t_contains "$(_t_calls)" "sudo -u kuhy" \ + "_run_as_user: runs the command as SUDO_USER when it is set" +_t_contains "$out" "hello" "_run_as_user: passes the command through to be run" + +bt_reset +_t_stub id 'echo 1000' +_t_reset_calls +( + unset SUDO_USER + USER="kuhy" _run_as_user echo hi >/dev/null 2>&1 +) +_t_contains "$(_t_calls)" "sudo -u kuhy" \ + "_run_as_user: falls back to USER when SUDO_USER is unset" + +# --- _restart_pipewire_stack ------------------------------------------------ + +bt_reset +_t_stub sleep 'exit 0' +out="$(_restart_pipewire_stack 2>&1)" +_t_contains "$(_t_calls)" "systemctl --user restart pipewire pipewire-pulse wireplumber" \ + "_restart_pipewire_stack: restarts pipewire, pipewire-pulse and wireplumber" +_t_contains "$out" "Waiting for audio stack to initialize" \ + "_restart_pipewire_stack: waits for the stack to come back" + +# --- set_audio_profile ------------------------------------------------------ + +bt_reset +TARGET_MAC="" +out="$(set_audio_profile 2>&1)" +_t_eq "" "$out" "set_audio_profile: does nothing when TARGET_MAC is empty" + +# pactl absent: returns before any probing. The host has a real pactl, so it +# has to leave PATH entirely. +bt_reset +TARGET_MAC="AA:BB:CC:DD:EE:FF" +_t_unstub pactl +_t_hide pactl +out="$(set_audio_profile 2>&1)" +_t_full_path +_t_eq "" "$out" "set_audio_profile: does nothing when pactl is not installed" + +# No card for this device: the function reports and returns. +bt_reset +TARGET_MAC="AA:BB:CC:DD:EE:FF" +_t_stub sleep 'exit 0' +_stub_out pactl "Card #1 + Name: alsa_card.pci-0000_00_1f.3 + Active Profile: output:analog-stereo" +out="$(set_audio_profile 2>&1)" +_t_contains "$out" "No PipeWire audio card found" \ + "set_audio_profile: reports when the device has no PipeWire card" + +# SBC-XQ active: the profile is switched to plain a2dp-sink. +bt_reset +TARGET_MAC="AA:BB:CC:DD:EE:FF" +_t_stub sleep 'exit 0' +_stub_out pactl "Card #2 + Name: bluez_card.AA_BB_CC_DD_EE_FF + Active Profile: a2dp-sink-sbc_xq" +out="$(set_audio_profile 2>&1)" +_t_contains "$out" "SBC-XQ codec active" \ + "set_audio_profile: warns when the SBC-XQ codec is active" +_t_contains "$out" "Switching to standard SBC codec" \ + "set_audio_profile: applies the codec-switch fix" +_t_contains "$(_t_calls)" \ + "pactl set-card-profile bluez_card.AA_BB_CC_DD_EE_FF a2dp-sink" \ + "set_audio_profile: switches the card to a2dp-sink" + +# A healthy non-SBC-XQ profile is reported and left alone. +bt_reset +TARGET_MAC="AA:BB:CC:DD:EE:FF" +_t_stub sleep 'exit 0' +_stub_out pactl "Card #2 + Name: bluez_card.AA_BB_CC_DD_EE_FF + Active Profile: a2dp-sink" +out="$(set_audio_profile 2>&1)" +_t_contains "$out" "Audio profile: a2dp-sink" \ + "set_audio_profile: reports a healthy active profile" +_t_lacks "$out" "Switching to standard SBC codec" \ + "set_audio_profile: applies no fix for a healthy profile" + +echo +echo "bt_audio (pipewire): ${PASS} passed, ${FAIL} failed" +((FAIL == 0)) diff --git a/linux_configuration/scripts/single_use/fixes/lib/tests/test_bt_audio_connect.sh b/linux_configuration/scripts/single_use/fixes/lib/tests/test_bt_audio_connect.sh new file mode 100755 index 00000000..0457de90 --- /dev/null +++ b/linux_configuration/scripts/single_use/fixes/lib/tests/test_bt_audio_connect.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# Tests for lib/bt_audio.sh: connect_device and _check_connection_health. +# Split from test_bt_audio.sh for the 250-line cap. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bt_harness.sh +. "${SCRIPT_DIR}/bt_harness.sh" + +# bt_audio.sh calls _services_resolved, _verify_audio_sink and +# _full_adapter_reset_and_connect, which live in bt_adapter.sh. +# fix_bluetooth.sh sources both libs, so sourcing both here reproduces the +# real resolution order instead of stubbing those helpers out. +# shellcheck source=../bt_adapter.sh +. "${FIXES_DIR}/lib/bt_adapter.sh" +# shellcheck source=../bt_audio.sh +. "${FIXES_DIR}/lib/bt_audio.sh" + +# _btctl_script — stub bluetoothctl with a body read from stdin. The stub +# itself reads its command from stdin too, exactly as the real tool does when +# driven by _btctl. +_btctl_script() { + _t_stub_stdin bluetoothctl +} + +# The connect paths sleep 15-20s at a time; the stub keeps the suite fast +# while the calls stay recorded. +_no_sleep() { + _t_stub sleep 'exit 0' +} + +# --- connect_device --------------------------------------------------------- + +bt_reset +TARGET_MAC="" +out="$(connect_device 2>&1)" +_t_eq "" "$out" "connect_device: does nothing when TARGET_MAC is empty" + +# Already connected: reports and returns without scanning. +bt_reset +_no_sleep +TARGET_MAC="AA:BB:CC:DD:EE:FF" +printf 'Device AA:BB:CC:DD:EE:FF\n\tConnected: yes\n' >"${DEV}/btctl_info" +_btctl_script <<'STUB_BODY' +read -r cmd rest +[[ "$cmd" == "info" ]] && cat "${LIB_TEST_DEV}/btctl_info" +# Real bluetoothctl consumes its whole stdin. Draining it here keeps the +# writing block from taking SIGPIPE partway through, which would silently +# stop it executing (and reading) the rest of its lines. +cat >/dev/null +exit 0 +STUB_BODY +out="$(connect_device 2>&1)" +_t_contains "$out" "already connected" \ + "connect_device: reports a device that is already connected" +_t_lacks "$out" "Scanning for" \ + "connect_device: does not scan when the device is already connected" + +# Paired but not connected: the direct-connect attempt runs and succeeds. +bt_reset +_no_sleep +TARGET_MAC="AA:BB:CC:DD:EE:FF" +printf 'Device AA:BB:CC:DD:EE:FF\n\tPaired: yes\n\tConnected: no\n' >"${DEV}/btctl_info" +_btctl_script <<'STUB_BODY' +read -r cmd rest +[[ "$cmd" == "info" ]] && cat "${LIB_TEST_DEV}/btctl_info" +# Real bluetoothctl consumes its whole stdin. Draining it here keeps the +# writing block from taking SIGPIPE partway through, which would silently +# stop it executing (and reading) the rest of its lines. +cat >/dev/null +exit 0 +STUB_BODY +out="$( + eval '_check_connection_health() { return 0; }' + connect_device 2>&1 +)" +_t_contains "$out" "already paired. Trying direct connect" \ + "connect_device: tries a direct connect for an already-paired device" +_t_lacks "$out" "Scanning for" \ + "connect_device: does not scan when the direct connect succeeds" + +# Direct connect fails, and the rescan does not find the device. +bt_reset +_no_sleep +TARGET_MAC="AA:BB:CC:DD:EE:FF" +printf 'Device AA:BB:CC:DD:EE:FF\n\tPaired: yes\n\tConnected: no\n' >"${DEV}/btctl_info" +_btctl_script <<'STUB_BODY' +read -r cmd rest +[[ "$cmd" == "info" ]] && cat "${LIB_TEST_DEV}/btctl_info" +# Real bluetoothctl consumes its whole stdin. Draining it here keeps the +# writing block from taking SIGPIPE partway through, which would silently +# stop it executing (and reading) the rest of its lines. +cat >/dev/null +exit 0 +STUB_BODY +if out="$( + eval '_check_connection_health() { return 1; }' + connect_device 2>&1 +)"; then + _t_fail "connect_device: expected failure when the device is never found" +else + _t_pass "connect_device: returns non-zero when the device is never found" +fi +_t_contains "$out" "Removing stale pairing for fresh start" \ + "connect_device: removes the stale pairing after a failed direct connect" +_t_contains "$out" "Scanning for AA:BB:CC:DD:EE:FF" \ + "connect_device: falls back to a scan after a failed direct connect" +_t_contains "$out" "not found during scan" \ + "connect_device: reports a device that never appears in the scan" + +# Unpaired device that IS found by the scan: pair, trust, connect all run. +bt_reset +_no_sleep +TARGET_MAC="AA:BB:CC:DD:EE:FF" +_btctl_script <<'STUB_BODY' +read -r cmd rest +case "$cmd" in +info) printf "Device AA:BB:CC:DD:EE:FF\n\tPaired: no\n\tConnected: no\n" ;; +devices) echo "Device AA:BB:CC:DD:EE:FF JBL Charge 5" ;; +esac +cat >/dev/null +exit 0 +STUB_BODY +out="$( + eval '_check_connection_health() { return 0; }' + connect_device 2>&1 +)" +_t_contains "$out" "Device found during scan" \ + "connect_device: reports the device once the scan finds it" +_t_contains "$out" "Pairing..." "connect_device: pairs a device found by the scan" +_t_contains "$out" "Trusting..." "connect_device: trusts the device so it auto-reconnects" +_t_contains "$out" "Connecting..." "connect_device: connects after pairing and trusting" + +# Found by the scan, but the connection never becomes healthy. +bt_reset +_no_sleep +TARGET_MAC="AA:BB:CC:DD:EE:FF" +_btctl_script <<'STUB_BODY' +read -r cmd rest +case "$cmd" in +info) printf "Device AA:BB:CC:DD:EE:FF\n\tPaired: no\n\tConnected: no\n" ;; +devices) echo "Device AA:BB:CC:DD:EE:FF JBL Charge 5" ;; +esac +cat >/dev/null +exit 0 +STUB_BODY +if out="$( + eval '_check_connection_health() { return 1; }' + connect_device 2>&1 +)"; then + _t_fail "connect_device: expected failure when the connection stays unhealthy" +else + _t_pass "connect_device: returns non-zero when the connection stays unhealthy" +fi +_t_contains "$out" "Connection to AA:BB:CC:DD:EE:FF failed" \ + "connect_device: reports the failure after pairing and connecting" + +echo +echo "bt_audio (connect): ${PASS} passed, ${FAIL} failed" +((FAIL == 0)) diff --git a/linux_configuration/scripts/single_use/fixes/lib/tests/test_bt_audio_health.sh b/linux_configuration/scripts/single_use/fixes/lib/tests/test_bt_audio_health.sh new file mode 100755 index 00000000..bbbe591b --- /dev/null +++ b/linux_configuration/scripts/single_use/fixes/lib/tests/test_bt_audio_health.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Tests for lib/bt_audio.sh: _check_connection_health. +# Split from test_bt_audio.sh for the 250-line cap. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bt_harness.sh +. "${SCRIPT_DIR}/bt_harness.sh" + +# _check_connection_health calls _services_resolved, _verify_audio_sink and +# _full_adapter_reset_and_connect, all defined in bt_adapter.sh, which +# fix_bluetooth.sh also sources. +# shellcheck source=../bt_adapter.sh +. "${FIXES_DIR}/lib/bt_adapter.sh" +# shellcheck source=../bt_audio.sh +. "${FIXES_DIR}/lib/bt_audio.sh" + +# _record_reset ARGS... — stand-in for the real full-adapter reset. +_record_reset() { + printf 'reset_called %s\n' "$*" >>"${DEV}/fixes" +} + +# _info_says TEXT — stub bluetoothctl so `_btctl info` returns TEXT. +_info_says() { + printf '%s\n' "$1" >"${DEV}/btctl_info" + _t_stub_stdin bluetoothctl <<'STUB_BODY' +read -r cmd rest +[[ "$cmd" == "info" ]] && cat "${LIB_TEST_DEV}/btctl_info" +# Real bluetoothctl consumes its whole stdin. Draining it here keeps the +# writing block from taking SIGPIPE partway through, which would silently +# stop it executing (and reading) the rest of its lines. +cat >/dev/null +exit 0 +STUB_BODY +} + +# Not connected at all: fails immediately, without probing services. +bt_reset +_t_stub sleep 'exit 0' +_info_says "Device AA:BB:CC:DD:EE:FF + Connected: no" +if _check_connection_health "AA:BB:CC:DD:EE:FF" >/dev/null 2>&1; then + _t_fail "_check_connection_health: expected failure when not connected" +else + _t_pass "_check_connection_health: fails when the device is not connected" +fi +_t_lacks "$(_t_calls)" "dbus-send" \ + "_check_connection_health: does not probe services when not connected" + +# Connected with services resolved on the first probe: healthy. +bt_reset +_t_stub sleep 'exit 0' +_info_says "Device AA:BB:CC:DD:EE:FF + Connected: yes" +_t_stub dbus-send 'echo " variant boolean true"' +_t_stub pactl 'echo "42 bluez_card.AA_BB_CC_DD_EE_FF module-bluez5-device.c"' +if out="$(_check_connection_health "AA:BB:CC:DD:EE:FF" 2>&1)"; then + _t_pass "_check_connection_health: succeeds when connected with services resolved" +else + _t_fail "_check_connection_health: expected success when services resolve" +fi +_t_contains "$out" "with A2DP services resolved" \ + "_check_connection_health: reports resolved A2DP services" +_t_lacks "$out" "Full adapter reset" \ + "_check_connection_health: does not reset the adapter when services resolve" + +# Connected but services never resolve: three probes, then the full reset, +# and after the reset they resolve. +bt_reset +_t_stub sleep 'exit 0' +_info_says "Device AA:BB:CC:DD:EE:FF + Connected: yes" +: >"${DEV}/dbus_calls" +_t_stub_stdin dbus-send <<'STUB_BODY' +n=$(cat "${LIB_TEST_DEV}/dbus_calls" 2>/dev/null || echo 0) +n=$((n + 1)) +echo "$n" >"${LIB_TEST_DEV}/dbus_calls" +if ((n > 3)); then + echo " variant boolean true" +else + echo " variant boolean false" +fi +exit 0 +STUB_BODY +_t_stub pactl 'echo "42 bluez_card.AA_BB_CC_DD_EE_FF module-bluez5-device.c"' +if out="$( + eval '_full_adapter_reset_and_connect() { _record_reset "$@"; }' + _check_connection_health "AA:BB:CC:DD:EE:FF" 2>&1 +)"; then + _t_pass "_check_connection_health: succeeds once services resolve after a reset" +else + _t_fail "_check_connection_health: expected success after the adapter reset" +fi +_t_contains "$out" "services not resolved (stale HCI link state)" \ + "_check_connection_health: warns about a stale HCI link state" +_t_contains "$out" "Full adapter reset to fix stale link" \ + "_check_connection_health: applies the full adapter reset fix" +_t_contains "$out" "resolved after reset" \ + "_check_connection_health: reports services resolving after the reset" +_t_contains "$(_t_fixes)" "reset_called AA:BB:CC:DD:EE:FF" \ + "_check_connection_health: passes the MAC to the adapter reset" + +# Services never resolve, even after the reset: the function fails. +bt_reset +_t_stub sleep 'exit 0' +_info_says "Device AA:BB:CC:DD:EE:FF + Connected: yes" +_t_stub dbus-send 'echo " variant boolean false"' +if out="$( + eval '_full_adapter_reset_and_connect() { _record_reset "$@"; }' + _check_connection_health "AA:BB:CC:DD:EE:FF" 2>&1 +)"; then + _t_fail "_check_connection_health: expected failure when services never resolve" +else + _t_pass "_check_connection_health: fails when services never resolve after a reset" +fi +_t_contains "$(_t_fixes)" "reset_called" \ + "_check_connection_health: still attempts the reset before giving up" + +echo +echo "bt_audio (health): ${PASS} passed, ${FAIL} failed" +((FAIL == 0))