From b9b99868331d90a5e95640cdefe95de710b69906 Mon Sep 17 00:00:00 2001 From: Overlord Date: Tue, 11 Aug 2026 15:51:26 +0200 Subject: [PATCH] Add comprehensive live integration test suite in `live_test.rs`. - Ported functionality from `live_test.sh` to Rust, enabling end-to-end testing of `vmic` against a real PipeWire session. - Tests validate all major commands (`create`, `edit`, `route`, `list`, `delete`, `wipe`), topology transitions, and stream migrations. - Implemented robust cleanup mechanisms and guarded destructive operations to ensure safety. - Designed as an opt-in test, excluded from default `cargo test` runs. --- tests/live_test.rs | 554 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 554 insertions(+) create mode 100644 tests/live_test.rs diff --git a/tests/live_test.rs b/tests/live_test.rs new file mode 100644 index 0000000..ef92fdb --- /dev/null +++ b/tests/live_test.rs @@ -0,0 +1,554 @@ +//! 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); +}