testing: add runtime smoke contracts for media paths

This commit is contained in:
Brad Stein 2026-04-12 18:41:13 -03:00
parent 7a4bea63c0
commit 7c1f441387
3 changed files with 375 additions and 0 deletions

View File

@ -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::<KeyboardReport>(8);
let (mou_tx, _) = broadcast::channel::<MouseReport>(8);
let (paste_tx, _paste_rx) = mpsc::unbounded_channel::<String>();
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"
);
}
}
}

View File

@ -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<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 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"
);
});
}

View File

@ -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()),
}
}