Add live integration test suite for vmic

- Introduced `live_test.sh` to perform end-to-end testing against a real PipeWire session.
- Tests cover all major commands (`create`, `edit`, `route`, `list`, `delete`, `wipe`) and validate topology changes, stream migrations, and cleanup behavior.
- Ensured safety with guarded destructive operations and thorough resource cleanup mechanisms.
- Designed as an opt-in script, not intended for inclusion in automated CI workflows.
This commit is contained in:
2026-08-11 15:43:03 +02:00
parent 50849540fa
commit ab99a9da3e

368
tests/live_test.sh Executable file
View File

@@ -0,0 +1,368 @@
#!/usr/bin/env bash
# Live integration test suite for vmic.
#
# Exercises every command against a REAL PipeWire session: spawns real
# pw-loopback processes, loads/unloads real pactl loopback modules, and
# moves real sink-inputs/source-outputs. This is deliberately NOT part of
# `cargo test` (see .claude/PROJECT.md: "no integration test suite" was a
# known, deliberate gap - this fills it, but live-only, opt-in, and never
# run automatically).
#
# Usage:
# tests/live_test.sh [options]
#
# Options (env var or flag; flag wins if both given):
# --bin PATH VMIC_BIN vmic binary to test (default: target/debug/vmic)
# --name NAME VMIC_TEST_NAME test vmic name (default: vmictestsuite)
# --input-app APP VMIC_TEST_INPUT_APP `route -i` filter (default: firefox)
# --output-app APP VMIC_TEST_OUTPUT_APP `route -o` filter (default: chromium)
# --hw-source SUBSTR VMIC_TEST_HW_SOURCE `route -s` filter (default: fifine)
# --skip-wipe VMIC_TEST_SKIP_WIPE=1 skip the destructive `wipe` phase
# --no-build skip the `cargo build` preflight
# -h, --help
#
# Safety:
# - Only ever creates/deletes vmics named $NAME, ${NAME}_a, ${NAME}_b.
# - `wipe` tears down EVERY vmic system-wide by design - the wipe phase
# only runs if `vmic list` shows nothing outside those three names at
# that point, or is skipped otherwise (or via --skip-wipe).
# - Moves the given real app streams into/out of the test vmic; an EXIT
# trap always attempts full cleanup (delete test vmics, kill stray
# matching pw-loopback processes, unload stray matching pactl modules)
# even on failure or interruption.
# - Never assumes the user has zero other vmics; all existence/emptiness
# checks are scoped to the test names above.
set -uo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
VMIC_BIN="${VMIC_BIN:-target/debug/vmic}"
NAME="${VMIC_TEST_NAME:-vmictestsuite}"
INPUT_APP="${VMIC_TEST_INPUT_APP:-firefox}"
OUTPUT_APP="${VMIC_TEST_OUTPUT_APP:-chromium}"
HW_SOURCE="${VMIC_TEST_HW_SOURCE:-fifine}"
SKIP_WIPE="${VMIC_TEST_SKIP_WIPE:-0}"
DO_BUILD=1
usage() { sed -n '2,/^set -uo/p' "$0" | sed '$d; s/^# \{0,1\}//'; }
while [[ $# -gt 0 ]]; do
case "$1" in
--bin) VMIC_BIN="$2"; shift 2 ;;
--name) NAME="$2"; shift 2 ;;
--input-app) INPUT_APP="$2"; shift 2 ;;
--output-app) OUTPUT_APP="$2"; shift 2 ;;
--hw-source) HW_SOURCE="$2"; shift 2 ;;
--skip-wipe) SKIP_WIPE=1; shift ;;
--no-build) DO_BUILD=0; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
NAME="$(tr '[:upper:]' '[:lower:]' <<<"$NAME")"
RED=$'\e[31m'; GREEN=$'\e[32m'; YELLOW=$'\e[33m'; BLUE=$'\e[34m'; RESET=$'\e[0m'
[[ -t 1 ]] || { RED=""; GREEN=""; YELLOW=""; BLUE=""; RESET=""; }
PASS=0; FAIL=0; SKIP=0
section() { echo; echo "${BLUE}== $1 ==${RESET}"; }
pass() { PASS=$((PASS+1)); echo " ${GREEN}PASS${RESET} $1"; }
fail() { FAIL=$((FAIL+1)); echo " ${RED}FAIL${RESET} $1"; [[ -n "${2:-}" ]] && echo " ${2//$'\n'/$'\n '}"; }
skip() { SKIP=$((SKIP+1)); echo " ${YELLOW}SKIP${RESET} $1"; }
LAST_OUT=""; LAST_CODE=0
vmic_run() { LAST_OUT="$("$VMIC_BIN" "$@" 2>&1)"; LAST_CODE=$?; }
expect_exit() { # expect_exit <desc> <expected_code>
if [[ "$LAST_CODE" == "$2" ]]; then pass "$1 (exit $LAST_CODE)"
else fail "$1 (expected exit $2, got $LAST_CODE)" "$LAST_OUT"; fi
}
expect_contains() { # expect_contains <desc> <needle>
if [[ "$LAST_OUT" == *"$2"* ]]; then pass "$1"
else fail "$1 (expected output to contain: $2)" "$LAST_OUT"; fi
}
expect_not_contains() {
if [[ "$LAST_OUT" != *"$2"* ]]; then pass "$1"
else fail "$1 (expected output NOT to contain: $2)" "$LAST_OUT"; fi
}
assert_eq() { if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected '$3', got '$2'"; fi; }
assert_ne() { if [[ "$2" != "$3" ]]; then pass "$1"; else fail "$1" "expected different from '$3', got same '$2'"; fi; }
assert_true() { if "${@:2}" >/dev/null 2>&1; then pass "$1"; else fail "$1"; fi; }
pw_loopback_count() { # pw_loopback_count <vmic-name-suffix>
local n; n=$(pgrep -c -f "pw-loopback.*vmic_${1}_" 2>/dev/null); echo "${n:-0}"
}
pulse_id_for() { # pulse_id_for <sinks|sources> <exact-name>
pactl list short "$1" 2>/dev/null | awk -v n="$2" '$2==n{print $1; exit}'
}
wait_pulse_id() { # wait_pulse_id <sinks|sources> <exact-name> -> prints id, empty on timeout
local id=""
for _ in $(seq 1 25); do
id="$(pulse_id_for "$1" "$2")"
[[ -n "$id" ]] && { echo "$id"; return 0; }
sleep 0.2
done
echo ""
}
wait_pw_loopback_count() { # wait_pw_loopback_count <name-suffix> <expected>
for _ in $(seq 1 25); do
[[ "$(pw_loopback_count "$1")" == "$2" ]] && return 0
sleep 0.2
done
return 1
}
count_sink_inputs_on() { pactl list sink-inputs 2>/dev/null | awk -v id="$1" '$1=="Sink:"{if($2==id)c++} END{print c+0}'; }
count_source_outputs_on() { pactl list source-outputs 2>/dev/null | awk -v id="$1" '$1=="Source:"{if($2==id)c++} END{print c+0}'; }
cleanup() {
section "Cleanup"
for n in "$NAME" "${NAME}_a" "${NAME}_b"; do
"$VMIC_BIN" delete "$n" >/dev/null 2>&1 || true
done
pkill -f "pw-loopback.*vmic_${NAME}_" 2>/dev/null || true
while read -r modid; do
[[ -n "$modid" ]] && pactl unload-module "$modid" >/dev/null 2>&1
done < <(pactl list modules 2>/dev/null | awk -v pat="vmic_${NAME}_" '
/^Module #/{id=$2; sub("#","",id)} /Argument:/{if (index($0, pat)) print id}')
echo " done."
echo
echo "${BLUE}== Results ==${RESET} ${GREEN}$PASS passed${RESET}, ${RED}$FAIL failed${RESET}, ${YELLOW}$SKIP skipped${RESET}"
[[ "$FAIL" -eq 0 ]]
}
trap 'cleanup; exit $(( $? ))' EXIT
echo "vmic: $VMIC_BIN"
echo "test name: $NAME (+ ${NAME}_a, ${NAME}_b for the wipe phase)"
echo "input app: $INPUT_APP output app: $OUTPUT_APP hw source: $HW_SOURCE"
if [[ "$DO_BUILD" == "1" ]]; then
section "Build"
if cargo build 2>&1 | tee /dev/stderr | grep -q '^error'; then
echo "build failed, aborting." >&2; exit 1
fi
fi
[[ -x "$VMIC_BIN" ]] || { echo "binary not found/executable: $VMIC_BIN" >&2; exit 1; }
# ---------------------------------------------------------------------------
section "Phase 1: CLI surface"
# ---------------------------------------------------------------------------
vmic_run; expect_exit "bare 'vmic' shows help" 2
expect_contains "bare 'vmic' mentions Usage" "Usage:"
vmic_run -h; expect_exit "'vmic -h'" 0
vmic_run help; expect_exit "'vmic help'" 0
vmic_run --version; expect_exit "'vmic --version'" 0
expect_contains "'--version' mentions vmic" "vmic"
vmic_run create --help; expect_exit "'vmic create --help'" 0
vmic_run route --help; expect_exit "'vmic route --help'" 0
vmic_run edit --help; expect_exit "'vmic edit --help'" 0
expect_contains "'edit --help' documents --loopback-no-mix" "--loopback-no-mix"
for shell in bash zsh fish; do
vmic_run completions "$shell"
assert_eq "'vmic completions $shell' exits 0" "$LAST_CODE" "0"
[[ -n "$LAST_OUT" ]] && pass "'vmic completions $shell' produces output" || fail "'vmic completions $shell' produces output" "(empty)"
done
# ---------------------------------------------------------------------------
section "Phase 2: error paths (pre-creation)"
# ---------------------------------------------------------------------------
"$VMIC_BIN" delete "$NAME" >/dev/null 2>&1 || true # ensure a clean slate
vmic_run create; expect_exit "'create' with no name fails" 2
vmic_run create "bad name!"; assert_true "'create' with an invalid name fails" [ "$LAST_CODE" -ne 0 ]
expect_contains "invalid name error message" "invalid name"
vmic_run route "$NAME" -s x; assert_true "'route' on nonexistent vmic fails" [ "$LAST_CODE" -ne 0 ]
expect_contains "nonexistent vmic error message (route)" "no vmic named"
vmic_run edit "$NAME" -l true; assert_true "'edit' on nonexistent vmic fails" [ "$LAST_CODE" -ne 0 ]
expect_contains "nonexistent vmic error message (edit)" "no vmic named"
vmic_run delete "$NAME"; assert_true "'delete' on nonexistent vmic fails" [ "$LAST_CODE" -ne 0 ]
expect_contains "nonexistent vmic error message (delete)" "no vmic named"
# ---------------------------------------------------------------------------
section "Phase 3: create - baseline is 2-node"
# ---------------------------------------------------------------------------
vmic_run create "$NAME"; expect_exit "'create $NAME'" 0
expect_contains "create success message" "Created virtual microphone"
assert_eq "exactly 1 pw-loopback process after create" "$(pw_loopback_count "$NAME")" "1"
vmic_run list
expect_contains "'list' shows the new vmic" "$NAME"
expect_contains "'list' reports 2-node architecture" "architecture: 2-node (simple)"
expect_contains "'list' reports active status" "status: active"
vmic_run create "$NAME"; assert_true "'create' twice fails" [ "$LAST_CODE" -ne 0 ]
expect_contains "duplicate create error message" "already exists"
vmic_run route "$NAME"; assert_true "'route' with no flags fails" [ "$LAST_CODE" -ne 0 ]
expect_contains "route nothing-to-do message" "nothing to do"
vmic_run edit "$NAME"; assert_true "'edit' with no flags fails" [ "$LAST_CODE" -ne 0 ]
expect_contains "edit nothing-to-do message" "nothing to do"
# ---------------------------------------------------------------------------
section "Phase 4: route -i/-o against real streams"
# ---------------------------------------------------------------------------
vmic_run route "$NAME" -i "$INPUT_APP"; expect_exit "'route -i $INPUT_APP'" 0
if [[ "$LAST_OUT" == *"matched 0 streams"* ]]; then
skip "no active '$INPUT_APP' sink-input right now - CLI path still ran cleanly"
else
expect_contains "'-i $INPUT_APP' reports a move" "-> moved to"
sink_id="$(wait_pulse_id sinks "vmic_${NAME}_sink")"
if [[ -n "$sink_id" ]]; then
n="$(count_sink_inputs_on "$sink_id")"
assert_true "'$INPUT_APP' stream now lands on vmic sink" [ "$n" -ge 1 ]
else
fail "resolve vmic sink's pulse id" "timed out"
fi
fi
vmic_run route "$NAME" -o "$OUTPUT_APP"; expect_exit "'route -o $OUTPUT_APP'" 0
if [[ "$LAST_OUT" == *"matched 0 streams"* ]]; then
skip "no active '$OUTPUT_APP' source-output right now - CLI path still ran cleanly"
else
expect_contains "'-o $OUTPUT_APP' reports a move" "-> moved to"
mic_id="$(wait_pulse_id sources "vmic_${NAME}_mic")"
if [[ -n "$mic_id" ]]; then
n="$(count_source_outputs_on "$mic_id")"
assert_true "'$OUTPUT_APP' stream now reads from vmic mic" [ "$n" -ge 1 ]
else
fail "resolve vmic mic's pulse id" "timed out"
fi
fi
# ---------------------------------------------------------------------------
section "Phase 5: route -s with loopback OFF - stays 2-node"
# ---------------------------------------------------------------------------
sink_before="$(pulse_id_for sinks "vmic_${NAME}_sink")"
vmic_run route "$NAME" -s "$HW_SOURCE"; expect_exit "'route -s $HW_SOURCE'" 0
expect_contains "mix message notes it's on the sink (no isolation needed)" "sink)"
assert_eq "still 1 pw-loopback process (no upgrade without loopback on)" "$(pw_loopback_count "$NAME")" "1"
vmic_run list
expect_contains "'list' shows the mixed source" "mixed source:"
vmic_run route "$NAME" -s "$HW_SOURCE"; expect_exit "re-'route -s $HW_SOURCE' (same source, idempotent)" 0
sink_after="$(pulse_id_for sinks "vmic_${NAME}_sink")"
assert_eq "re-linking the same source doesn't migrate anything" "$sink_after" "$sink_before"
# ---------------------------------------------------------------------------
section "Phase 6: edit -l true with a source mixed - upgrades to 4-node"
# ---------------------------------------------------------------------------
sink_before="$(pulse_id_for sinks "vmic_${NAME}_sink")"
mic_before="$(pulse_id_for sources "vmic_${NAME}_mic")"
vmic_run edit "$NAME" -l true; expect_exit "'edit -l true'" 0
assert_true "upgraded to 2 pw-loopback processes" wait_pw_loopback_count "$NAME" 2
vmic_run list
expect_contains "'list' reports 4-node architecture" "architecture: 4-node (source isolated from self-monitor)"
sink_id="$(wait_pulse_id sinks "vmic_${NAME}_sink")"
mic_id="$(wait_pulse_id sources "vmic_${NAME}_mic")"
assert_ne "sink node was actually recreated (new pulse id)" "$sink_id" "$sink_before"
assert_ne "mic node was actually recreated (new pulse id)" "$mic_id" "$mic_before"
if [[ -n "$sink_id" ]]; then
n="$(count_sink_inputs_on "$sink_id")"
if [[ "$n" -ge 1 ]]; then pass "'$INPUT_APP' stream auto-reconnected after upgrade"
else skip "no '$INPUT_APP' stream was active to verify reconnection"; fi
fi
if [[ -n "$mic_id" ]]; then
n="$(count_source_outputs_on "$mic_id")"
if [[ "$n" -ge 1 ]]; then pass "'$OUTPUT_APP' stream auto-reconnected after upgrade"
else skip "no '$OUTPUT_APP' stream was active to verify reconnection"; fi
fi
# ---------------------------------------------------------------------------
section "Phase 7: edit --loopback-no-mix true - downgrades to 2-node"
# ---------------------------------------------------------------------------
sink_before="$(pulse_id_for sinks "vmic_${NAME}_sink")"
vmic_run edit "$NAME" --loopback-no-mix true; expect_exit "'edit --loopback-no-mix true'" 0
assert_true "downgraded to 1 pw-loopback process" wait_pw_loopback_count "$NAME" 1
vmic_run list
expect_contains "'list' reports 2-node + no-mix architecture" "architecture: 2-node (source audible in self-monitor - see --loopback-no-mix)"
sink_after="$(pulse_id_for sinks "vmic_${NAME}_sink")"
assert_ne "sink node was recreated on downgrade" "$sink_after" "$sink_before"
# ---------------------------------------------------------------------------
section "Phase 8: edit --loopback-no-mix false - re-upgrades to 4-node"
# ---------------------------------------------------------------------------
vmic_run edit "$NAME" --loopback-no-mix false; expect_exit "'edit --loopback-no-mix false'" 0
assert_true "re-upgraded to 2 pw-loopback processes" wait_pw_loopback_count "$NAME" 2
vmic_run list
expect_contains "'list' back to 4-node architecture" "architecture: 4-node (source isolated from self-monitor)"
# ---------------------------------------------------------------------------
section "Phase 9: volumes"
# ---------------------------------------------------------------------------
vmic_run edit "$NAME" -v 55; expect_exit "'edit -v 55'" 0
vmic_run list; expect_contains "loopback volume shows 55%" "loopback volume: 55%"
vmic_run edit "$NAME" -sv 66; expect_exit "'edit -sv 66'" 0
vmic_run list; expect_contains "source volume shows 66%" "source volume: 66%"
vmic_run edit "$NAME" -v 0.8; expect_exit "'edit -v 0.8' (fraction form)" 0
vmic_run list; expect_contains "fraction volume normalizes to 80%" "loopback volume: 80%"
vmic_run edit "$NAME" -v 999; expect_exit "'edit -v 999' still succeeds (clamped)" 0
expect_contains "over-range volume warns about clamping" "clamped"
vmic_run list; expect_contains "clamped volume shows as 255%" "loopback volume: 255%"
# ---------------------------------------------------------------------------
section "Phase 10: route -s none - downgrades back to 2-node"
# ---------------------------------------------------------------------------
vmic_run route "$NAME" -s none; expect_exit "'route -s none'" 0
expect_contains "removal message" "Removed source mix"
assert_true "downgraded to 1 pw-loopback process" wait_pw_loopback_count "$NAME" 1
vmic_run list; expect_not_contains "'list' no longer shows a mixed source" "mixed source:"
vmic_run route "$NAME" -s none; expect_exit "re-'route -s none' with nothing mixed" 0
expect_contains "no-op removal message" "no source is mixed"
# ---------------------------------------------------------------------------
section "Phase 11: edit -l false with no source mixed - no-op topology"
# ---------------------------------------------------------------------------
sink_before="$(pulse_id_for sinks "vmic_${NAME}_sink")"
vmic_run edit "$NAME" -l false; expect_exit "'edit -l false'" 0
assert_eq "still 1 pw-loopback process" "$(pw_loopback_count "$NAME")" "1"
sink_after="$(pulse_id_for sinks "vmic_${NAME}_sink")"
assert_eq "sink was NOT recreated (already Simple2Node, no-op)" "$sink_after" "$sink_before"
vmic_run list; expect_contains "'list' shows loopback disabled" "self-monitor loopback: no"
# ---------------------------------------------------------------------------
section "Phase 12: route -s error paths"
# ---------------------------------------------------------------------------
vmic_run route "$NAME" -s zzz_definitely_not_a_real_source_zzz_12345
assert_true "'route -s' on a nonexistent source fails" [ "$LAST_CODE" -ne 0 ]
expect_contains "no-matching-source error message" "no matching source"
# ---------------------------------------------------------------------------
section "Phase 13: delete"
# ---------------------------------------------------------------------------
vmic_run delete "$NAME"; expect_exit "'delete $NAME'" 0
assert_true "0 pw-loopback processes after delete" wait_pw_loopback_count "$NAME" 0
vmic_run list; expect_not_contains "'list' no longer mentions $NAME" "$NAME"
vmic_run delete "$NAME"; assert_true "'delete' twice fails" [ "$LAST_CODE" -ne 0 ]
expect_contains "double-delete error message" "no vmic named"
# ---------------------------------------------------------------------------
section "Phase 14: wipe (guarded - destroys ALL vmics system-wide)"
# ---------------------------------------------------------------------------
if [[ "$SKIP_WIPE" == "1" ]]; then
skip "wipe phase (--skip-wipe passed)"
else
vmic_run list
mapfile -t existing < <(echo "$LAST_OUT" | grep -v '^ ' | grep -v '^$' | grep -v '^No virtual mics created\.$' || true)
other=0
for n in "${existing[@]:-}"; do
[[ -z "$n" ]] && continue
case "$n" in "${NAME}_a"|"${NAME}_b") ;; *) other=1 ;; esac
done
if [[ "$other" == "1" ]]; then
skip "wipe phase (other, non-test vmics exist: ${existing[*]}) - not safe to run a system-wide wipe"
else
vmic_run create "${NAME}_a"; expect_exit "create throwaway 2-node vmic for wipe test" 0
vmic_run create "${NAME}_b"; expect_exit "create throwaway vmic" 0
vmic_run route "${NAME}_b" -s "$HW_SOURCE"; expect_exit "mix a source into it" 0
vmic_run edit "${NAME}_b" -l true; expect_exit "upgrade it to 4-node" 0
assert_eq "throwaway_a is 2-node before wipe" "$(pw_loopback_count "${NAME}_a")" "1"
assert_eq "throwaway_b is 4-node before wipe" "$(pw_loopback_count "${NAME}_b")" "2"
vmic_run wipe; expect_exit "'vmic wipe'" 0
expect_contains "wipe reports what it did" "Wiped all virtual mics"
assert_true "0 processes left for throwaway_a" wait_pw_loopback_count "${NAME}_a" 0
assert_true "0 processes left for throwaway_b" wait_pw_loopback_count "${NAME}_b" 0
vmic_run list; expect_contains "'list' is empty after wipe" "No virtual mics created."
fi
fi