//! Live integration test suite for vmic - the Rust counterpart to //! `tests/live_test.sh` (see that file's header for the full safety/ //! parameter rationale; this mirrors it phase for phase, same messages //! where practical, so the two stay easy to cross-reference). //! //! Exercises every command against a REAL PipeWire session: spawns real //! pw-loopback processes, loads/unloads real pactl loopback modules, moves //! real sink-inputs/source-outputs, and (guarded) runs the destructive //! `wipe` command. `#[ignore]`d so a plain `cargo test` never touches a //! live session - run it explicitly: //! //! cargo test --test live_test -- --ignored --nocapture //! //! `--nocapture` only matters for watching progress live; on failure cargo //! prints the captured output regardless. //! //! Parameters (env vars, same names/defaults as the shell version): //! VMIC_TEST_NAME test vmic name (default: vmictestsuite) //! VMIC_TEST_INPUT_APP `route -i` filter (default: firefox) //! VMIC_TEST_OUTPUT_APP `route -o` filter (default: chromium) //! VMIC_TEST_HW_SOURCE `route -s` filter (default: fifine) //! VMIC_TEST_SKIP_WIPE=1 skip the destructive `wipe` phase //! //! Safety: only ever creates/deletes vmics named $NAME, ${NAME}_a, //! ${NAME}_b. `wipe` tears down every vmic system-wide by design, so that //! phase only runs if `vmic list` shows nothing outside those three names //! at that point (or is skipped via VMIC_TEST_SKIP_WIPE). A `Drop`-based //! guard always attempts full cleanup, even on panic/failure. use std::io::IsTerminal; use std::process::Command; use std::time::{Duration, Instant}; // --------------------------------------------------------------------------- // Running the binary under test // --------------------------------------------------------------------------- struct CmdOut { code: i32, combined: String, } fn vmic(args: &[&str]) -> CmdOut { let out = Command::new(env!("CARGO_BIN_EXE_vmic")) .args(args) .output() .expect("failed to spawn the vmic binary under test"); let mut combined = String::from_utf8_lossy(&out.stdout).into_owned(); combined.push_str(&String::from_utf8_lossy(&out.stderr)); CmdOut { code: out.status.code().unwrap_or(-1), combined } } // --------------------------------------------------------------------------- // Live-system introspection (independent of vmic's own internals - this is // a bin-only crate with no lib target, so these mirror rather than reuse // vmic's own pactl-parsing/proc-scanning logic) // --------------------------------------------------------------------------- fn pactl(args: &[&str]) -> String { let out = Command::new("pactl").args(args).output().expect("pactl not found on PATH"); String::from_utf8_lossy(&out.stdout).into_owned() } /// Number of live `pw-loopback` processes whose cmdline mentions /// `vmic__` - mirrors `wipe.rs`'s own `/proc` matching logic. fn pw_loopback_count(name_fragment: &str) -> usize { let needle = format!("vmic_{name_fragment}_"); let Ok(entries) = std::fs::read_dir("/proc") else { return 0 }; entries .flatten() .filter(|entry| { let Ok(pid) = entry.file_name().to_string_lossy().parse::() else { return false }; let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).unwrap_or_default(); if comm.trim() != "pw-loopback" { return false; } let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else { return false }; String::from_utf8_lossy(&cmdline).contains(&needle) }) .count() } fn wait_pw_loopback_count(name_fragment: &str, expected: usize, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; loop { if pw_loopback_count(name_fragment) == expected { return true; } if Instant::now() >= deadline { return pw_loopback_count(name_fragment) == expected; } std::thread::sleep(Duration::from_millis(200)); } } fn pulse_id_for(kind: &str, name: &str) -> Option { pactl(&["list", "short", kind]).lines().find_map(|l| { let mut cols = l.split_whitespace(); let id = cols.next()?.parse::().ok()?; (cols.next()? == name).then_some(id) }) } fn wait_pulse_id(kind: &str, name: &str) -> Option { for _ in 0..25 { if let Some(id) = pulse_id_for(kind, name) { return Some(id); } std::thread::sleep(Duration::from_millis(200)); } None } fn count_sink_inputs_on(sink_id: u32) -> usize { pactl(&["list", "sink-inputs"]) .lines() .filter(|l| l.trim_start().strip_prefix("Sink: ").and_then(|v| v.trim().parse::().ok()) == Some(sink_id)) .count() } fn count_source_outputs_on(source_id: u32) -> usize { pactl(&["list", "source-outputs"]) .lines() .filter(|l| { l.trim_start().strip_prefix("Source: ").and_then(|v| v.trim().parse::().ok()) == Some(source_id) }) .count() } /// Top-level (non-indented) lines from `vmic list` output, i.e. vmic names. fn existing_vmic_names(list_output: &str) -> Vec { list_output .lines() .filter(|l| !l.starts_with(" ") && !l.is_empty() && *l != "No virtual mics created.") .map(str::to_string) .collect() } // --------------------------------------------------------------------------- // Reporting // --------------------------------------------------------------------------- struct Colors { red: &'static str, green: &'static str, yellow: &'static str, blue: &'static str, reset: &'static str, } fn colors() -> Colors { if std::io::stdout().is_terminal() { Colors { red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", blue: "\x1b[34m", reset: "\x1b[0m" } } else { Colors { red: "", green: "", yellow: "", blue: "", reset: "" } } } struct Report { n_pass: u32, n_fail: u32, n_skip: u32, c: Colors, } impl Report { fn new() -> Self { Self { n_pass: 0, n_fail: 0, n_skip: 0, c: colors() } } fn section(&self, title: &str) { println!("\n{}== {title} =={}", self.c.blue, self.c.reset); } fn pass(&mut self, desc: &str) { self.n_pass += 1; println!(" {}PASS{} {desc}", self.c.green, self.c.reset); } fn fail(&mut self, desc: &str, detail: &str) { self.n_fail += 1; println!(" {}FAIL{} {desc}", self.c.red, self.c.reset); for line in detail.lines() { println!(" {line}"); } } fn skip(&mut self, desc: &str) { self.n_skip += 1; println!(" {}SKIP{} {desc}", self.c.yellow, self.c.reset); } fn check(&mut self, desc: &str, ok: bool) { if ok { self.pass(desc); } else { self.fail(desc, ""); } } fn eq(&mut self, desc: &str, actual: T, expected: T) { if actual == expected { self.pass(desc); } else { self.fail(desc, &format!("expected {expected:?}, got {actual:?}")); } } fn ne(&mut self, desc: &str, actual: T, unexpected: T) { if actual != unexpected { self.pass(desc); } else { self.fail(desc, &format!("expected different from {unexpected:?}, got the same value")); } } fn expect_exit(&mut self, desc: &str, out: &CmdOut, expected: i32) { if out.code == expected { self.pass(&format!("{desc} (exit {})", out.code)); } else { self.fail(&format!("{desc} (expected exit {expected}, got {})", out.code), &out.combined); } } fn expect_contains(&mut self, desc: &str, out: &CmdOut, needle: &str) { if out.combined.contains(needle) { self.pass(desc); } else { self.fail(desc, &format!("expected output to contain: {needle}\n{}", out.combined)); } } fn expect_not_contains(&mut self, desc: &str, out: &CmdOut, needle: &str) { if !out.combined.contains(needle) { self.pass(desc); } else { self.fail(desc, &format!("expected output NOT to contain: {needle}\n{}", out.combined)); } } } // --------------------------------------------------------------------------- // Cleanup - always runs on scope exit, including panics (unwinding, not // aborting, is still the default outside `[profile.release]`). // --------------------------------------------------------------------------- struct CleanupGuard { name: String, } impl Drop for CleanupGuard { fn drop(&mut self) { println!("\n== Cleanup =="); for n in [self.name.clone(), format!("{}_a", self.name), format!("{}_b", self.name)] { let _ = vmic(&["delete", n.as_str()]); } let needle = format!("vmic_{}_", self.name); if let Ok(entries) = std::fs::read_dir("/proc") { for entry in entries.flatten() { let Ok(pid) = entry.file_name().to_string_lossy().parse::() else { continue }; let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).unwrap_or_default(); if comm.trim() != "pw-loopback" { continue; } let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else { continue }; if String::from_utf8_lossy(&cmdline).contains(&needle) { let _ = Command::new("kill").args(["-TERM", &pid.to_string()]).status(); } } } let mut current_id: Option = None; for line in pactl(&["list", "modules"]).lines() { if let Some(rest) = line.strip_prefix("Module #") { current_id = rest.trim().parse().ok(); } else if line.trim_start().starts_with("Argument:") && line.contains(&needle) { if let Some(id) = current_id { let _ = Command::new("pactl").args(["unload-module", &id.to_string()]).status(); } } } println!(" done."); } } // --------------------------------------------------------------------------- #[test] #[ignore = "touches a live PipeWire session: spawns real processes, moves real streams, uses a real hardware source, and may run the destructive `wipe` command"] fn live_suite() { let name = std::env::var("VMIC_TEST_NAME").unwrap_or_else(|_| "vmictestsuite".into()).to_lowercase(); let input_app = std::env::var("VMIC_TEST_INPUT_APP").unwrap_or_else(|_| "firefox".into()); let output_app = std::env::var("VMIC_TEST_OUTPUT_APP").unwrap_or_else(|_| "chromium".into()); let hw_source = std::env::var("VMIC_TEST_HW_SOURCE").unwrap_or_else(|_| "fifine".into()); let skip_wipe = std::env::var("VMIC_TEST_SKIP_WIPE").as_deref() == Ok("1"); println!("vmic: {}", env!("CARGO_BIN_EXE_vmic")); println!("test name: {name} (+ {name}_a, {name}_b for the wipe phase)"); println!("input app: {input_app} output app: {output_app} hw source: {hw_source}"); let _cleanup = CleanupGuard { name: name.clone() }; let _ = vmic(&["delete", name.as_str()]); // ensure a clean slate let mut r = Report::new(); // -- Phase 1: CLI surface -- r.section("Phase 1: CLI surface"); let out = vmic(&[]); r.expect_exit("bare 'vmic' shows help", &out, 2); r.expect_contains("bare 'vmic' mentions Usage", &out, "Usage:"); let out = vmic(&["-h"]); r.expect_exit("'vmic -h'", &out, 0); let out = vmic(&["help"]); r.expect_exit("'vmic help'", &out, 0); let out = vmic(&["--version"]); r.expect_exit("'vmic --version'", &out, 0); r.expect_contains("'--version' mentions vmic", &out, "vmic"); let out = vmic(&["create", "--help"]); r.expect_exit("'vmic create --help'", &out, 0); let out = vmic(&["route", "--help"]); r.expect_exit("'vmic route --help'", &out, 0); let out = vmic(&["edit", "--help"]); r.expect_exit("'vmic edit --help'", &out, 0); r.expect_contains("'edit --help' documents --loopback-no-mix", &out, "--loopback-no-mix"); for shell in ["bash", "zsh", "fish"] { let out = vmic(&["completions", shell]); r.eq(&format!("'vmic completions {shell}' exits 0"), out.code, 0); r.check(&format!("'vmic completions {shell}' produces output"), !out.combined.is_empty()); } // -- Phase 2: error paths (pre-creation) -- r.section("Phase 2: error paths (pre-creation)"); let out = vmic(&["create"]); r.expect_exit("'create' with no name fails", &out, 2); let out = vmic(&["create", "bad name!"]); r.check("'create' with an invalid name fails", out.code != 0); r.expect_contains("invalid name error message", &out, "invalid name"); let out = vmic(&["route", name.as_str(), "-s", "x"]); r.check("'route' on nonexistent vmic fails", out.code != 0); r.expect_contains("nonexistent vmic error message (route)", &out, "no vmic named"); let out = vmic(&["edit", name.as_str(), "-l", "true"]); r.check("'edit' on nonexistent vmic fails", out.code != 0); r.expect_contains("nonexistent vmic error message (edit)", &out, "no vmic named"); let out = vmic(&["delete", name.as_str()]); r.check("'delete' on nonexistent vmic fails", out.code != 0); r.expect_contains("nonexistent vmic error message (delete)", &out, "no vmic named"); // -- Phase 3: create - baseline is 2-node -- r.section("Phase 3: create - baseline is 2-node"); let out = vmic(&["create", name.as_str()]); r.expect_exit(&format!("'create {name}'"), &out, 0); r.expect_contains("create success message", &out, "Created virtual microphone"); r.eq("exactly 1 pw-loopback process after create", pw_loopback_count(&name), 1); let out = vmic(&["list"]); r.expect_contains("'list' shows the new vmic", &out, &name); r.expect_contains("'list' reports 2-node architecture", &out, "architecture: 2-node (simple)"); r.expect_contains("'list' reports active status", &out, "status: active"); let out = vmic(&["create", name.as_str()]); r.check("'create' twice fails", out.code != 0); r.expect_contains("duplicate create error message", &out, "already exists"); let out = vmic(&["route", name.as_str()]); r.check("'route' with no flags fails", out.code != 0); r.expect_contains("route nothing-to-do message", &out, "nothing to do"); let out = vmic(&["edit", name.as_str()]); r.check("'edit' with no flags fails", out.code != 0); r.expect_contains("edit nothing-to-do message", &out, "nothing to do"); // -- Phase 4: route -i/-o against real streams -- r.section("Phase 4: route -i/-o against real streams"); let out = vmic(&["route", name.as_str(), "-i", input_app.as_str()]); r.expect_exit(&format!("'route -i {input_app}'"), &out, 0); if out.combined.contains("matched 0 streams") { r.skip(&format!("no active '{input_app}' sink-input right now - CLI path still ran cleanly")); } else { r.expect_contains(&format!("'-i {input_app}' reports a move"), &out, "-> moved to"); match wait_pulse_id("sinks", &format!("vmic_{name}_sink")) { Some(id) => r.check(&format!("'{input_app}' stream now lands on vmic sink"), count_sink_inputs_on(id) >= 1), None => r.fail("resolve vmic sink's pulse id", "timed out"), } } let out = vmic(&["route", name.as_str(), "-o", output_app.as_str()]); r.expect_exit(&format!("'route -o {output_app}'"), &out, 0); if out.combined.contains("matched 0 streams") { r.skip(&format!("no active '{output_app}' source-output right now - CLI path still ran cleanly")); } else { r.expect_contains(&format!("'-o {output_app}' reports a move"), &out, "-> moved to"); match wait_pulse_id("sources", &format!("vmic_{name}_mic")) { Some(id) => { r.check(&format!("'{output_app}' stream now reads from vmic mic"), count_source_outputs_on(id) >= 1) } None => r.fail("resolve vmic mic's pulse id", "timed out"), } } // -- Phase 5: route -s with loopback OFF - stays 2-node -- r.section("Phase 5: route -s with loopback OFF - stays 2-node"); let sink_before = pulse_id_for("sinks", &format!("vmic_{name}_sink")); let out = vmic(&["route", name.as_str(), "-s", hw_source.as_str()]); r.expect_exit(&format!("'route -s {hw_source}'"), &out, 0); r.expect_contains("mix message notes it's on the sink (no isolation needed)", &out, "sink)"); r.eq("still 1 pw-loopback process (no upgrade without loopback on)", pw_loopback_count(&name), 1); let out = vmic(&["list"]); r.expect_contains("'list' shows the mixed source", &out, "mixed source:"); let out = vmic(&["route", name.as_str(), "-s", hw_source.as_str()]); r.expect_exit(&format!("re-'route -s {hw_source}' (same source, idempotent)"), &out, 0); let sink_after = pulse_id_for("sinks", &format!("vmic_{name}_sink")); r.eq("re-linking the same source doesn't migrate anything", sink_after, sink_before); // -- Phase 6: edit -l true with a source mixed - upgrades to 4-node -- r.section("Phase 6: edit -l true with a source mixed - upgrades to 4-node"); let sink_before = pulse_id_for("sinks", &format!("vmic_{name}_sink")); let mic_before = pulse_id_for("sources", &format!("vmic_{name}_mic")); let out = vmic(&["edit", name.as_str(), "-l", "true"]); r.expect_exit("'edit -l true'", &out, 0); r.check("upgraded to 2 pw-loopback processes", wait_pw_loopback_count(&name, 2, Duration::from_secs(5))); let out = vmic(&["list"]); r.expect_contains("'list' reports 4-node architecture", &out, "architecture: 4-node (source isolated from self-monitor)"); let sink_id = wait_pulse_id("sinks", &format!("vmic_{name}_sink")); let mic_id = wait_pulse_id("sources", &format!("vmic_{name}_mic")); r.ne("sink node was actually recreated (new pulse id)", sink_id, sink_before); r.ne("mic node was actually recreated (new pulse id)", mic_id, mic_before); match sink_id { Some(id) if count_sink_inputs_on(id) >= 1 => r.pass(&format!("'{input_app}' stream auto-reconnected after upgrade")), _ => r.skip(&format!("no '{input_app}' stream was active to verify reconnection")), } match mic_id { Some(id) if count_source_outputs_on(id) >= 1 => { r.pass(&format!("'{output_app}' stream auto-reconnected after upgrade")) } _ => r.skip(&format!("no '{output_app}' stream was active to verify reconnection")), } // -- Phase 7: edit --loopback-no-mix true - downgrades to 2-node -- r.section("Phase 7: edit --loopback-no-mix true - downgrades to 2-node"); let sink_before = pulse_id_for("sinks", &format!("vmic_{name}_sink")); let out = vmic(&["edit", name.as_str(), "--loopback-no-mix", "true"]); r.expect_exit("'edit --loopback-no-mix true'", &out, 0); r.check("downgraded to 1 pw-loopback process", wait_pw_loopback_count(&name, 1, Duration::from_secs(5))); let out = vmic(&["list"]); r.expect_contains( "'list' reports 2-node + no-mix architecture", &out, "architecture: 2-node (source audible in self-monitor - see --loopback-no-mix)", ); let sink_after = pulse_id_for("sinks", &format!("vmic_{name}_sink")); r.ne("sink node was recreated on downgrade", sink_after, sink_before); // -- Phase 8: edit --loopback-no-mix false - re-upgrades to 4-node -- r.section("Phase 8: edit --loopback-no-mix false - re-upgrades to 4-node"); let out = vmic(&["edit", name.as_str(), "--loopback-no-mix", "false"]); r.expect_exit("'edit --loopback-no-mix false'", &out, 0); r.check("re-upgraded to 2 pw-loopback processes", wait_pw_loopback_count(&name, 2, Duration::from_secs(5))); let out = vmic(&["list"]); r.expect_contains("'list' back to 4-node architecture", &out, "architecture: 4-node (source isolated from self-monitor)"); // -- Phase 9: volumes -- r.section("Phase 9: volumes"); let out = vmic(&["edit", name.as_str(), "-v", "55"]); r.expect_exit("'edit -v 55'", &out, 0); let out = vmic(&["list"]); r.expect_contains("loopback volume shows 55%", &out, "loopback volume: 55%"); let out = vmic(&["edit", name.as_str(), "-sv", "66"]); r.expect_exit("'edit -sv 66'", &out, 0); let out = vmic(&["list"]); r.expect_contains("source volume shows 66%", &out, "source volume: 66%"); let out = vmic(&["edit", name.as_str(), "-v", "0.8"]); r.expect_exit("'edit -v 0.8' (fraction form)", &out, 0); let out = vmic(&["list"]); r.expect_contains("fraction volume normalizes to 80%", &out, "loopback volume: 80%"); let out = vmic(&["edit", name.as_str(), "-v", "999"]); r.expect_exit("'edit -v 999' still succeeds (clamped)", &out, 0); r.expect_contains("over-range volume warns about clamping", &out, "clamped"); let out = vmic(&["list"]); r.expect_contains("clamped volume shows as 255%", &out, "loopback volume: 255%"); // -- Phase 10: route -s none - downgrades back to 2-node -- r.section("Phase 10: route -s none - downgrades back to 2-node"); let out = vmic(&["route", name.as_str(), "-s", "none"]); r.expect_exit("'route -s none'", &out, 0); r.expect_contains("removal message", &out, "Removed source mix"); r.check("downgraded to 1 pw-loopback process", wait_pw_loopback_count(&name, 1, Duration::from_secs(5))); let out = vmic(&["list"]); r.expect_not_contains("'list' no longer shows a mixed source", &out, "mixed source:"); let out = vmic(&["route", name.as_str(), "-s", "none"]); r.expect_exit("re-'route -s none' with nothing mixed", &out, 0); r.expect_contains("no-op removal message", &out, "no source is mixed"); // -- Phase 11: edit -l false with no source mixed - no-op topology -- r.section("Phase 11: edit -l false with no source mixed - no-op topology"); let sink_before = pulse_id_for("sinks", &format!("vmic_{name}_sink")); let out = vmic(&["edit", name.as_str(), "-l", "false"]); r.expect_exit("'edit -l false'", &out, 0); r.eq("still 1 pw-loopback process", pw_loopback_count(&name), 1); let sink_after = pulse_id_for("sinks", &format!("vmic_{name}_sink")); r.eq("sink was NOT recreated (already Simple2Node, no-op)", sink_after, sink_before); let out = vmic(&["list"]); r.expect_contains("'list' shows loopback disabled", &out, "self-monitor loopback: no"); // -- Phase 12: route -s error paths -- r.section("Phase 12: route -s error paths"); let out = vmic(&["route", name.as_str(), "-s", "zzz_definitely_not_a_real_source_zzz_12345"]); r.check("'route -s' on a nonexistent source fails", out.code != 0); r.expect_contains("no-matching-source error message", &out, "no matching source"); // -- Phase 13: delete -- r.section("Phase 13: delete"); let out = vmic(&["delete", name.as_str()]); r.expect_exit(&format!("'delete {name}'"), &out, 0); r.check("0 pw-loopback processes after delete", wait_pw_loopback_count(&name, 0, Duration::from_secs(5))); let out = vmic(&["list"]); r.expect_not_contains(&format!("'list' no longer mentions {name}"), &out, &name); let out = vmic(&["delete", name.as_str()]); r.check("'delete' twice fails", out.code != 0); r.expect_contains("double-delete error message", &out, "no vmic named"); // -- Phase 14: wipe (guarded - destroys ALL vmics system-wide) -- r.section("Phase 14: wipe (guarded - destroys ALL vmics system-wide)"); if skip_wipe { r.skip("wipe phase (VMIC_TEST_SKIP_WIPE=1)"); } else { let out = vmic(&["list"]); let existing = existing_vmic_names(&out.combined); let a = format!("{name}_a"); let b = format!("{name}_b"); let other: Vec<&String> = existing.iter().filter(|n| **n != a && **n != b).collect(); if !other.is_empty() { r.skip(&format!( "wipe phase (other, non-test vmics exist: {other:?}) - not safe to run a system-wide wipe" )); } else { let out = vmic(&["create", a.as_str()]); r.expect_exit("create throwaway 2-node vmic for wipe test", &out, 0); let out = vmic(&["create", b.as_str()]); r.expect_exit("create throwaway vmic", &out, 0); let out = vmic(&["route", b.as_str(), "-s", hw_source.as_str()]); r.expect_exit("mix a source into it", &out, 0); let out = vmic(&["edit", b.as_str(), "-l", "true"]); r.expect_exit("upgrade it to 4-node", &out, 0); r.eq("throwaway_a is 2-node before wipe", pw_loopback_count(&a), 1); r.eq("throwaway_b is 4-node before wipe", pw_loopback_count(&b), 2); let out = vmic(&["wipe"]); r.expect_exit("'vmic wipe'", &out, 0); r.expect_contains("wipe reports what it did", &out, "Wiped all virtual mics"); r.check("0 processes left for throwaway_a", wait_pw_loopback_count(&a, 0, Duration::from_secs(5))); r.check("0 processes left for throwaway_b", wait_pw_loopback_count(&b, 0, Duration::from_secs(5))); let out = vmic(&["list"]); r.expect_contains("'list' is empty after wipe", &out, "No virtual mics created."); } } println!( "\n{}== Results =={} {}{} passed{}, {}{} failed{}, {}{} skipped{}", r.c.blue, r.c.reset, r.c.green, r.n_pass, r.c.reset, r.c.red, r.n_fail, r.c.reset, r.c.yellow, r.n_skip, r.c.reset ); assert_eq!(r.n_fail, 0, "{} check(s) failed - see output above", r.n_fail); }