From 7c1f441387975df7cd8d9c32ae7c8461941b18ff Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sun, 12 Apr 2026 18:41:13 -0300 Subject: [PATCH] testing: add runtime smoke contracts for media paths --- .../tests/client_runtime_smoke_contract.rs | 142 +++++++++++++++++ .../tests/server_runtime_smoke_contract.rs | 149 ++++++++++++++++++ .../tests/server_video_sink_smoke_contract.rs | 84 ++++++++++ 3 files changed, 375 insertions(+) create mode 100644 testing/tests/client_runtime_smoke_contract.rs create mode 100644 testing/tests/server_runtime_smoke_contract.rs create mode 100644 testing/tests/server_video_sink_smoke_contract.rs diff --git a/testing/tests/client_runtime_smoke_contract.rs b/testing/tests/client_runtime_smoke_contract.rs new file mode 100644 index 0000000..440bbd2 --- /dev/null +++ b/testing/tests/client_runtime_smoke_contract.rs @@ -0,0 +1,142 @@ +//! Integration smoke coverage for client runtime constructors. +//! +//! Scope: execute public client startup/media constructors through stable APIs +//! without requiring hardware-specific assertions. +//! Targets: `client/src/app.rs`, `client/src/input/camera.rs`, +//! `client/src/input/microphone.rs`, and `client/src/output/audio.rs`. +//! Why: these paths are operationally important but often depend on runtime +//! host capabilities; smoke contracts keep them exercised in CI. + +use lesavka_client::LesavkaClientApp; +use lesavka_client::input::camera::{CameraCapture, CameraCodec, CameraConfig}; +use lesavka_client::input::inputs::InputAggregator; +use lesavka_client::input::microphone::MicrophoneCapture; +use lesavka_client::output::audio::AudioOut; +use lesavka_client::output::video::MonitorWindow; +use lesavka_common::lesavka::AudioPacket; +use lesavka_common::lesavka::{KeyboardReport, MouseReport, VideoPacket}; +use serial_test::serial; +use temp_env::with_var; +use tokio::sync::{broadcast, mpsc}; + +#[test] +#[serial] +fn client_app_new_supports_headless_mode() { + with_var("LESAVKA_HEADLESS", Some("1"), || { + with_var( + "LESAVKA_SERVER_ADDR", + Some("http://127.0.0.1:50051"), + || { + let app = LesavkaClientApp::new(); + assert!(app.is_ok(), "expected headless app construction to succeed"); + }, + ); + }); +} + +#[test] +fn camera_capture_stub_pull_returns_none() { + gstreamer::init().ok(); + let cam = CameraCapture::new_stub(); + assert!( + cam.pull().is_none(), + "stub capture should not produce real frames" + ); +} + +#[test] +#[serial] +fn camera_capture_test_pattern_constructor_is_stable() { + with_var("LESAVKA_CAM_TEST_PATTERN", Some("ball"), || { + let result = CameraCapture::new( + Some("test"), + Some(CameraConfig { + codec: CameraCodec::Mjpeg, + width: 320, + height: 240, + fps: 5, + }), + ); + + match result { + Ok(_cam) => {} + Err(err) => { + assert!( + !err.to_string().trim().is_empty(), + "camera constructor returned an empty error" + ); + } + } + }); +} + +#[test] +#[serial] +fn microphone_capture_constructor_is_stable_with_missing_source_hint() { + with_var( + "LESAVKA_MIC_SOURCE", + Some("definitely-missing-source"), + || { + let result = MicrophoneCapture::new(); + match result { + Ok(_mic) => {} + Err(err) => { + assert!( + !err.to_string().trim().is_empty(), + "microphone constructor returned an empty error" + ); + } + } + }, + ); +} + +#[test] +#[serial] +fn audio_out_constructor_and_push_are_stable() { + with_var("LESAVKA_AUDIO_SINK", Some("autoaudiosink"), || { + with_var("LESAVKA_TAP_AUDIO", Some("0"), || match AudioOut::new() { + Ok(out) => { + out.push(AudioPacket { + id: 0, + pts: 0, + data: Vec::new(), + }); + } + Err(err) => { + assert!( + !err.to_string().trim().is_empty(), + "audio output constructor returned an empty error" + ); + } + }); + }); +} + +#[test] +fn input_aggregator_constructor_is_stable() { + let (kbd_tx, _) = broadcast::channel::(8); + let (mou_tx, _) = broadcast::channel::(8); + let (paste_tx, _paste_rx) = mpsc::unbounded_channel::(); + let _agg = InputAggregator::new(false, kbd_tx, mou_tx, Some(paste_tx)); +} + +#[test] +#[serial] +fn monitor_window_constructor_and_push_are_stable() { + match MonitorWindow::new(0) { + Ok(win) => { + win.push_packet(VideoPacket { + id: 0, + pts: 0, + data: vec![0, 0, 0, 1, 0x65], + }); + } + Err(err) => { + assert!( + !err.to_string().trim().is_empty(), + "monitor window constructor returned an empty error" + ); + } + } +} diff --git a/testing/tests/server_runtime_smoke_contract.rs b/testing/tests/server_runtime_smoke_contract.rs new file mode 100644 index 0000000..c5b2fc2 --- /dev/null +++ b/testing/tests/server_runtime_smoke_contract.rs @@ -0,0 +1,149 @@ +//! 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::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 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_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(kb)); + let ms = Arc::new(Mutex::new(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, + did_cycle.clone(), + ) + .await; + + assert!( + !did_cycle.load(Ordering::SeqCst), + "non-transport errors should not trigger gadget recovery" + ); + }); +} diff --git a/testing/tests/server_video_sink_smoke_contract.rs b/testing/tests/server_video_sink_smoke_contract.rs new file mode 100644 index 0000000..8dce68f --- /dev/null +++ b/testing/tests/server_video_sink_smoke_contract.rs @@ -0,0 +1,84 @@ +//! Integration smoke coverage for server camera sink constructors. +//! +//! Scope: exercise public sink and relay constructors with resilient +//! assertions that tolerate host-specific media capabilities. +//! Targets: `server/src/video_sinks.rs`. +//! Why: sink setup contains substantial branch logic that should be executed +//! in CI even when real HDMI/UVC hardware is unavailable. + +use lesavka_common::lesavka::VideoPacket; +use lesavka_server::camera::{CameraCodec, CameraConfig, CameraOutput}; +use lesavka_server::video::{CameraRelay, HdmiSink, WebcamSink}; +use serial_test::serial; +use temp_env::with_var; + +fn hdmi_config(codec: CameraCodec) -> CameraConfig { + CameraConfig { + output: CameraOutput::Hdmi, + codec, + width: 640, + height: 360, + fps: 30, + hdmi: None, + } +} + +#[test] +#[serial] +fn webcam_sink_constructor_is_stable_for_missing_uvc_device() { + let cfg = hdmi_config(CameraCodec::Mjpeg); + match WebcamSink::new("/dev/video-definitely-missing", &cfg) { + Ok(sink) => sink.push(VideoPacket { + id: 2, + pts: 0, + data: vec![0xFF, 0xD8, 0xFF, 0xD9], + }), + Err(err) => assert!(!err.to_string().trim().is_empty()), + } +} + +#[test] +#[serial] +fn hdmi_sink_constructor_and_push_are_stable_with_override() { + with_var("LESAVKA_HDMI_SINK", Some("autovideosink"), || { + let cfg = hdmi_config(CameraCodec::H264); + match HdmiSink::new(&cfg) { + Ok(sink) => sink.push(VideoPacket { + id: 2, + pts: 0, + data: vec![0, 0, 0, 1, 0x65], + }), + Err(err) => assert!(!err.to_string().trim().is_empty()), + } + }); +} + +#[test] +#[serial] +fn camera_relay_hdmi_constructor_and_feed_are_stable() { + with_var("LESAVKA_HDMI_SINK", Some("autovideosink"), || { + let cfg = hdmi_config(CameraCodec::Mjpeg); + match CameraRelay::new_hdmi(7, &cfg) { + Ok(relay) => relay.feed(VideoPacket { + id: 2, + pts: 123, + data: vec![0xFF, 0xD8, 0xFF, 0xD9], + }), + Err(err) => assert!(!err.to_string().trim().is_empty()), + } + }); +} + +#[test] +#[serial] +fn camera_relay_uvc_constructor_is_stable_for_missing_device() { + let cfg = hdmi_config(CameraCodec::Mjpeg); + match CameraRelay::new_uvc(9, "/dev/video-definitely-missing", &cfg) { + Ok(relay) => relay.feed(VideoPacket { + id: 2, + pts: 321, + data: vec![0xFF, 0xD8, 0xFF, 0xD9], + }), + Err(err) => assert!(!err.to_string().trim().is_empty()), + } +}