//! 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 { 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 = 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 = 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 = 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" ); }); }); }