testing: add runtime smoke contracts for media paths
This commit is contained in:
parent
7a4bea63c0
commit
7c1f441387
142
testing/tests/client_runtime_smoke_contract.rs
Normal file
142
testing/tests/client_runtime_smoke_contract.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
149
testing/tests/server_runtime_smoke_contract.rs
Normal file
149
testing/tests/server_runtime_smoke_contract.rs
Normal 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"
|
||||
);
|
||||
});
|
||||
}
|
||||
84
testing/tests/server_video_sink_smoke_contract.rs
Normal file
84
testing/tests/server_video_sink_smoke_contract.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user