- 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.
555 lines
25 KiB
Rust
555 lines
25 KiB
Rust
//! 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_<name_fragment>_` - 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::<u32>() 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<u32> {
|
|
pactl(&["list", "short", kind]).lines().find_map(|l| {
|
|
let mut cols = l.split_whitespace();
|
|
let id = cols.next()?.parse::<u32>().ok()?;
|
|
(cols.next()? == name).then_some(id)
|
|
})
|
|
}
|
|
|
|
fn wait_pulse_id(kind: &str, name: &str) -> Option<u32> {
|
|
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::<u32>().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::<u32>().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<String> {
|
|
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<T: PartialEq + std::fmt::Debug>(&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<T: PartialEq + std::fmt::Debug>(&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::<u32>() 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<u32> = 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);
|
|
}
|