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))