lesavka/testing/tests/server_runtime_smoke_contract.rs

349 lines
11 KiB
Rust

//! Integration smoke coverage for server runtime helpers.
//!
//! Scope: exercise public runtime helpers that should be stable even on hosts
//! without full gadget/audio plumbing.
//! Targets: `server/src/audio.rs`, `server/src/gadget.rs`,
//! `server/src/runtime_support.rs`.
//! Why: these contracts provide broad safety coverage around startup/recovery
//! code paths that are otherwise hard to hit from unit-only tests.
use lesavka_server::audio::{self, ClipTap};
use lesavka_server::gadget::UsbGadget;
use lesavka_server::runtime_support;
use serial_test::serial;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use temp_env::with_var;
use tempfile::NamedTempFile;
use tokio::runtime::Runtime;
use tokio::sync::Mutex;
fn tmp_files_with_prefix(prefix: &str) -> HashSet<PathBuf> {
std::fs::read_dir("/tmp")
.ok()
.into_iter()
.flat_map(|iter| iter.flatten())
.map(|entry| entry.path())
.filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.map(|name| name.starts_with(prefix))
.unwrap_or(false)
})
.collect()
}
#[test]
#[serial]
fn clip_tap_writes_rotating_file_when_period_elapses() {
let tag = "lesavka-cliptap-contract";
let prefix = format!("{tag}-");
let before = tmp_files_with_prefix(&prefix);
let mut tap = ClipTap::new(tag, Duration::from_millis(5));
tap.feed(b"abc");
std::thread::sleep(Duration::from_millis(10));
tap.feed(b"def");
tap.flush();
let after = tmp_files_with_prefix(&prefix);
let created: Vec<PathBuf> = after.difference(&before).cloned().collect();
assert!(
!created.is_empty(),
"expected at least one clip file to be written"
);
for path in created {
let _ = std::fs::remove_file(path);
}
}
#[test]
#[serial]
fn clip_tap_drop_flushes_pending_audio() {
let tag = "lesavka-cliptap-drop-contract";
let prefix = format!("{tag}-");
let before = tmp_files_with_prefix(&prefix);
let mut tap = ClipTap::new(tag, Duration::from_secs(60));
tap.feed(b"pending-bytes");
drop(tap);
let after = tmp_files_with_prefix(&prefix);
let created: Vec<PathBuf> = after.difference(&before).cloned().collect();
assert!(
!created.is_empty(),
"expected drop() to flush buffered clip bytes"
);
for path in created {
let _ = std::fs::remove_file(path);
}
}
#[test]
#[serial]
fn clip_tap_flush_is_noop_when_buffer_is_empty() {
let tag = "lesavka-cliptap-empty-contract";
let prefix = format!("{tag}-");
let before = tmp_files_with_prefix(&prefix);
let mut tap = ClipTap::new(tag, Duration::from_secs(60));
tap.flush();
drop(tap);
let after = tmp_files_with_prefix(&prefix);
let created: Vec<PathBuf> = after.difference(&before).cloned().collect();
assert!(
created.is_empty(),
"empty flush/drop should not create clip artifacts"
);
}
#[test]
#[serial]
fn gadget_queries_handle_missing_controller_paths() {
let _gadget = UsbGadget::new("lesavka");
assert!(
UsbGadget::state("definitely-missing-udc").is_err(),
"state() should fail for a non-existent UDC controller"
);
match UsbGadget::find_controller() {
Ok(name) => assert!(!name.trim().is_empty()),
Err(err) => assert!(!err.to_string().trim().is_empty()),
}
assert!(
UsbGadget::wait_state_any("definitely-missing-udc", 0).is_err(),
"wait_state_any() should fail quickly for a non-existent controller"
);
}
#[test]
#[serial]
fn runtime_open_voice_with_retry_stays_bounded_for_missing_device() {
with_var("LESAVKA_MIC_INIT_ATTEMPTS", Some("1"), || {
with_var("LESAVKA_MIC_INIT_DELAY_MS", Some("1"), || {
let rt = Runtime::new().expect("create runtime");
let outcome = rt.block_on(async {
tokio::time::timeout(
Duration::from_secs(3),
runtime_support::open_voice_with_retry("hw:DefinitelyMissing,0"),
)
.await
});
match outcome {
Ok(Ok(mut voice)) => {
voice.finish();
}
Ok(Err(err)) => {
assert!(!err.to_string().trim().is_empty());
}
Err(_) => panic!("open_voice_with_retry timed out"),
}
});
});
}
#[test]
#[serial]
fn runtime_ear_handles_malformed_alsa_source_without_hanging() {
let rt = Runtime::new().expect("create runtime");
let outcome = rt.block_on(async {
tokio::time::timeout(
Duration::from_secs(3),
audio::ear("hw:UAC2Gadget,0\" ! malformed-pipeline", 0),
)
.await
});
match outcome {
Ok(Ok(stream)) => drop(stream),
Ok(Err(err)) => assert!(!err.to_string().trim().is_empty()),
Err(_) => panic!("audio::ear timed out for malformed ALSA source"),
}
}
#[test]
#[serial]
fn runtime_recover_hid_ignores_non_transport_errors() {
let rt = Runtime::new().expect("create runtime");
rt.block_on(async {
let kb_tmp = NamedTempFile::new().expect("temp keyboard file");
let ms_tmp = NamedTempFile::new().expect("temp mouse file");
let kb = tokio::fs::OpenOptions::new()
.write(true)
.open(kb_tmp.path())
.await
.expect("open temp kb");
let ms = tokio::fs::OpenOptions::new()
.write(true)
.open(ms_tmp.path())
.await
.expect("open temp ms");
let kb = Arc::new(Mutex::new(Some(kb)));
let ms = Arc::new(Mutex::new(Some(ms)));
let did_cycle = Arc::new(AtomicBool::new(false));
let err = std::io::Error::from_raw_os_error(libc::EAGAIN);
runtime_support::recover_hid_if_needed(
&err,
UsbGadget::new("lesavka"),
kb,
ms,
kb_tmp.path().to_string_lossy().to_string(),
ms_tmp.path().to_string_lossy().to_string(),
did_cycle.clone(),
)
.await;
assert!(
!did_cycle.load(Ordering::SeqCst),
"non-transport errors should not trigger gadget recovery"
);
});
}
#[test]
#[serial]
fn runtime_recover_hid_short_circuits_when_cycle_already_in_progress() {
let rt = Runtime::new().expect("create runtime");
rt.block_on(async {
let kb_tmp = NamedTempFile::new().expect("temp keyboard file");
let ms_tmp = NamedTempFile::new().expect("temp mouse file");
let kb = tokio::fs::OpenOptions::new()
.write(true)
.open(kb_tmp.path())
.await
.expect("open temp kb");
let ms = tokio::fs::OpenOptions::new()
.write(true)
.open(ms_tmp.path())
.await
.expect("open temp ms");
let kb = Arc::new(Mutex::new(Some(kb)));
let ms = Arc::new(Mutex::new(Some(ms)));
let did_cycle = Arc::new(AtomicBool::new(true));
let err = std::io::Error::from_raw_os_error(libc::EPIPE);
runtime_support::recover_hid_if_needed(
&err,
UsbGadget::new("lesavka"),
kb,
ms,
kb_tmp.path().to_string_lossy().to_string(),
ms_tmp.path().to_string_lossy().to_string(),
did_cycle.clone(),
)
.await;
assert!(
did_cycle.load(Ordering::SeqCst),
"existing cycle lock should short-circuit recovery"
);
});
}
#[test]
#[serial]
fn runtime_recover_hid_resets_cycle_flag_after_async_recovery_path() {
with_var("LESAVKA_ALLOW_GADGET_CYCLE", None::<&str>, || {
let rt = Runtime::new().expect("create runtime");
rt.block_on(async {
let kb_tmp = NamedTempFile::new().expect("temp keyboard file");
let ms_tmp = NamedTempFile::new().expect("temp mouse file");
let kb = tokio::fs::OpenOptions::new()
.write(true)
.open(kb_tmp.path())
.await
.expect("open temp kb");
let ms = tokio::fs::OpenOptions::new()
.write(true)
.open(ms_tmp.path())
.await
.expect("open temp ms");
let kb = Arc::new(Mutex::new(Some(kb)));
let ms = Arc::new(Mutex::new(Some(ms)));
let did_cycle = Arc::new(AtomicBool::new(false));
let err = std::io::Error::from_raw_os_error(libc::EPIPE);
runtime_support::recover_hid_if_needed(
&err,
UsbGadget::new("lesavka"),
kb.clone(),
ms.clone(),
kb_tmp.path().to_string_lossy().to_string(),
ms_tmp.path().to_string_lossy().to_string(),
did_cycle.clone(),
)
.await;
assert!(
did_cycle.load(Ordering::SeqCst),
"transport error should acquire recovery lock"
);
tokio::time::sleep(Duration::from_millis(2_300)).await;
assert!(
!did_cycle.load(Ordering::SeqCst),
"recovery task should release lock after cooldown"
);
});
});
}
#[test]
#[serial]
fn runtime_recover_hid_attempts_cycle_when_enabled() {
with_var("LESAVKA_ALLOW_GADGET_CYCLE", Some("1"), || {
let rt = Runtime::new().expect("create runtime");
rt.block_on(async {
let kb_tmp = NamedTempFile::new().expect("temp keyboard file");
let ms_tmp = NamedTempFile::new().expect("temp mouse file");
let kb = tokio::fs::OpenOptions::new()
.write(true)
.open(kb_tmp.path())
.await
.expect("open temp kb");
let ms = tokio::fs::OpenOptions::new()
.write(true)
.open(ms_tmp.path())
.await
.expect("open temp ms");
let kb = Arc::new(Mutex::new(Some(kb)));
let ms = Arc::new(Mutex::new(Some(ms)));
let did_cycle = Arc::new(AtomicBool::new(false));
let err = std::io::Error::from_raw_os_error(libc::EPIPE);
runtime_support::recover_hid_if_needed(
&err,
UsbGadget::new("lesavka"),
kb.clone(),
ms.clone(),
kb_tmp.path().to_string_lossy().to_string(),
ms_tmp.path().to_string_lossy().to_string(),
did_cycle.clone(),
)
.await;
tokio::time::sleep(Duration::from_millis(2_300)).await;
assert!(
!did_cycle.load(Ordering::SeqCst),
"cycle-enabled recovery should eventually clear lock"
);
});
});
}