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) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-22 17:52:24 +02:00
parent 434cb7587e
commit a5b6663c02
7 changed files with 582 additions and 11 deletions

View File

@ -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)."
}

View File

@ -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."
]
}

View File

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

View File

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

View File

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

View File

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

View File

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