fix(ci): stabilize hygiene and test gates
This commit is contained in:
parent
a2e9496071
commit
3b685415ed
47
Jenkinsfile
vendored
47
Jenkinsfile
vendored
@ -101,15 +101,54 @@ spec:
|
||||
stage('Testing') {
|
||||
steps {
|
||||
container('rust-ci') {
|
||||
sh 'cargo test -p lesavka_testing'
|
||||
sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/test_gate.sh'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Quality Gate') {
|
||||
stage('Run quality gate') {
|
||||
steps {
|
||||
container('rust-ci') {
|
||||
sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/quality_gate.sh'
|
||||
sh '''
|
||||
set -eu
|
||||
mkdir -p target/quality-gate
|
||||
set +e
|
||||
QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/quality_gate.sh
|
||||
gate_rc=$?
|
||||
set -e
|
||||
printf '%s\n' "${gate_rc}" > target/quality-gate/quality-gate.rc
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Publish test metrics') {
|
||||
steps {
|
||||
container('rust-ci') {
|
||||
sh '''
|
||||
set -eu
|
||||
if [ -f target/test-gate/metrics.prom ]; then
|
||||
echo "test metrics published via scripts/ci/test_gate.sh"
|
||||
else
|
||||
echo "test metrics file missing; continuing without publish step failure"
|
||||
fi
|
||||
if [ -f target/quality-gate/metrics.prom ]; then
|
||||
echo "quality gate metrics published via scripts/ci/quality_gate.sh"
|
||||
else
|
||||
echo "quality gate metrics file missing; continuing without publish step failure"
|
||||
fi
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Enforce quality gate') {
|
||||
steps {
|
||||
container('rust-ci') {
|
||||
sh '''
|
||||
set -eu
|
||||
exit "$(cat target/quality-gate/quality-gate.rc 2>/dev/null || echo 1)"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -153,7 +192,7 @@ spec:
|
||||
always {
|
||||
script {
|
||||
try {
|
||||
archiveArtifacts artifacts: 'dist/*.tar.gz', fingerprint: true, allowEmptyArchive: true
|
||||
archiveArtifacts artifacts: 'dist/*.tar.gz,target/test-gate/**,target/quality-gate/**,target/hygiene-gate/**', fingerprint: true, allowEmptyArchive: true
|
||||
} catch (Throwable err) {
|
||||
echo "archive step unavailable: ${err.class.simpleName}"
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.11.44"
|
||||
version = "0.11.45"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -27,9 +27,92 @@ pub const LAUNCHER_FOCUS_SIGNAL_ENV: &str = "LESAVKA_LAUNCHER_FOCUS_SIGNAL";
|
||||
pub const DEFAULT_LAUNCHER_FOCUS_SIGNAL_PATH: &str = "/tmp/lesavka-launcher-focus.signal";
|
||||
pub const LAUNCHER_CLIPBOARD_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_CLIPBOARD_CONTROL";
|
||||
pub const DEFAULT_LAUNCHER_CLIPBOARD_CONTROL_PATH: &str = "/tmp/lesavka-launcher-clipboard.control";
|
||||
pub const LAUNCHER_PARENT_PID_ENV: &str = "LESAVKA_LAUNCHER_PARENT_PID";
|
||||
pub const LAUNCHER_PARENT_START_TICKS_ENV: &str = "LESAVKA_LAUNCHER_PARENT_START_TICKS";
|
||||
pub const REMOTE_INPUT_FAILSAFE_SECONDS_ENV: &str = "LESAVKA_INPUT_REMOTE_FAILSAFE_SECS";
|
||||
pub const DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS: &str = "0";
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct LauncherParentProcess {
|
||||
pid: u32,
|
||||
start_ticks: Option<String>,
|
||||
}
|
||||
|
||||
/// Start a safe watchdog in relay children so launcher crashes do not leak streams.
|
||||
pub fn start_launcher_child_parent_watchdog_from_env() {
|
||||
let Some(parent) = launcher_parent_process_from_env() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !launcher_parent_process_matches(&parent) {
|
||||
eprintln!("[launcher-watchdog] launcher parent is already gone; exiting relay child");
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
let spawn_result = std::thread::Builder::new()
|
||||
.name("launcher-parent-watchdog".to_string())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
if !launcher_parent_process_matches(&parent) {
|
||||
eprintln!(
|
||||
"[launcher-watchdog] launcher parent disappeared; exiting relay child"
|
||||
);
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if let Err(err) = spawn_result {
|
||||
eprintln!("[launcher-watchdog] failed to start parent watchdog: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn launcher_parent_start_ticks() -> Option<String> {
|
||||
process_start_ticks(std::process::id())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn launcher_parent_process_from_env() -> Option<LauncherParentProcess> {
|
||||
let pid = std::env::var(LAUNCHER_PARENT_PID_ENV)
|
||||
.ok()?
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.ok()?;
|
||||
let start_ticks = std::env::var(LAUNCHER_PARENT_START_TICKS_ENV)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
Some(LauncherParentProcess { pid, start_ticks })
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn launcher_parent_process_matches(parent: &LauncherParentProcess) -> bool {
|
||||
let Some(current_start_ticks) = process_start_ticks(parent.pid) else {
|
||||
return false;
|
||||
};
|
||||
parent
|
||||
.start_ticks
|
||||
.as_deref()
|
||||
.is_none_or(|expected| expected == current_start_ticks)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn process_start_ticks(pid: u32) -> Option<String> {
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
proc_stat_start_ticks(&stat)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn proc_stat_start_ticks(stat: &str) -> Option<String> {
|
||||
let after_name = stat.rsplit_once(") ")?.1;
|
||||
after_name
|
||||
.split_whitespace()
|
||||
.nth(19)
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
pub fn maybe_run_launcher(args: &[String]) -> Result<bool> {
|
||||
if should_run_launcher(args) {
|
||||
let server_addr = resolve_server_addr(args);
|
||||
@ -340,4 +423,16 @@ mod tests {
|
||||
let args = vec!["--server".to_string(), "http://server:50051".to_string()];
|
||||
assert!(should_run_launcher(&args));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proc_stat_start_ticks_handles_process_names_with_spaces() {
|
||||
let stat = "1234 (lesavka client) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 424242 21";
|
||||
|
||||
assert_eq!(proc_stat_start_ticks(stat).as_deref(), Some("424242"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launcher_parent_start_ticks_is_available_for_current_process() {
|
||||
assert!(launcher_parent_start_ticks().is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@ -1106,7 +1106,6 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
"Remote audio gain set to {label} for the next relay launch."
|
||||
));
|
||||
}
|
||||
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -64,6 +64,7 @@ pub struct LauncherWidgets {
|
||||
pub audio_check_detail: gtk::Label,
|
||||
pub audio_check_meter: gtk::ProgressBar,
|
||||
pub display_panes: [DisplayPaneWidgets; 2],
|
||||
pub server_entry: gtk::Entry,
|
||||
pub start_button: gtk::Button,
|
||||
pub power_auto_button: gtk::Button,
|
||||
pub power_on_button: gtk::Button,
|
||||
@ -411,7 +412,7 @@ pub fn build_launcher_view(
|
||||
preview_body.append(&testing_row);
|
||||
staging_row.append(&preview_panel);
|
||||
|
||||
let (connection_panel, connection_body) = build_panel("Session");
|
||||
let (connection_panel, connection_body) = build_panel("Relay Controls");
|
||||
let server_entry = gtk::Entry::new();
|
||||
server_entry.add_css_class("server-entry");
|
||||
server_entry.set_hexpand(true);
|
||||
@ -420,16 +421,15 @@ pub fn build_launcher_view(
|
||||
server_entry.set_tooltip_text(Some(
|
||||
"Relay host address for previews, power control, and the live session.",
|
||||
));
|
||||
connection_body.append(&server_entry);
|
||||
|
||||
let relay_actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
relay_actions_row.set_homogeneous(true);
|
||||
let start_button = gtk::Button::with_label("Connect Relay");
|
||||
let relay_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
relay_row.set_halign(gtk::Align::Fill);
|
||||
relay_row.set_hexpand(true);
|
||||
relay_row.append(&server_entry);
|
||||
let start_button = gtk::Button::with_label("Connect");
|
||||
start_button.add_css_class("suggested-action");
|
||||
start_button.set_hexpand(true);
|
||||
stabilize_button(&start_button, 180);
|
||||
relay_actions_row.append(&start_button);
|
||||
connection_body.append(&relay_actions_row);
|
||||
stabilize_button(&start_button, 92);
|
||||
relay_row.append(&start_button);
|
||||
connection_body.append(&relay_row);
|
||||
|
||||
let live_actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
live_actions_row.set_homogeneous(true);
|
||||
@ -457,22 +457,24 @@ pub fn build_launcher_view(
|
||||
connection_body.append(&live_actions_row);
|
||||
|
||||
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
||||
let power_heading = gtk::Label::new(Some("GPIO Power"));
|
||||
let power_heading = gtk::Label::new(Some("Power"));
|
||||
power_heading.add_css_class("subgroup-title");
|
||||
power_heading.set_halign(gtk::Align::Start);
|
||||
connection_body.append(&power_heading);
|
||||
|
||||
let power_shell = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||
power_shell.set_halign(gtk::Align::Center);
|
||||
let power_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
||||
power_shell.set_halign(gtk::Align::Fill);
|
||||
let power_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
power_row.set_hexpand(true);
|
||||
power_heading.set_width_chars(5);
|
||||
power_row.append(&power_heading);
|
||||
let power_on_button = gtk::Button::with_label("On");
|
||||
stabilize_button(&power_on_button, 64);
|
||||
stabilize_button(&power_on_button, 52);
|
||||
power_on_button.add_css_class("pill-toggle");
|
||||
let power_auto_button = gtk::Button::with_label("Auto");
|
||||
stabilize_button(&power_auto_button, 64);
|
||||
stabilize_button(&power_auto_button, 52);
|
||||
power_auto_button.add_css_class("pill-toggle");
|
||||
let power_off_button = gtk::Button::with_label("Off");
|
||||
stabilize_button(&power_off_button, 64);
|
||||
stabilize_button(&power_off_button, 52);
|
||||
power_off_button.add_css_class("pill-toggle");
|
||||
let power_detail = gtk::Label::new(Some("Capture power status is loading..."));
|
||||
power_detail.add_css_class("dim-label");
|
||||
@ -481,18 +483,13 @@ pub fn build_launcher_view(
|
||||
power_row.append(&power_on_button);
|
||||
power_row.append(&power_auto_button);
|
||||
power_row.append(&power_off_button);
|
||||
power_shell.append(&power_row);
|
||||
connection_body.append(&power_shell);
|
||||
let audio_heading = gtk::Label::new(Some("Remote Audio"));
|
||||
audio_heading.add_css_class("subgroup-title");
|
||||
audio_heading.set_halign(gtk::Align::Start);
|
||||
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
||||
connection_body.append(&audio_heading);
|
||||
let audio_gain_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
audio_gain_row.set_size_request(220, -1);
|
||||
audio_gain_row.set_hexpand(true);
|
||||
let audio_gain_label = gtk::Label::new(Some("Gain"));
|
||||
let audio_gain_label = gtk::Label::new(Some("Audio"));
|
||||
audio_gain_label.add_css_class("dim-label");
|
||||
audio_gain_label.set_halign(gtk::Align::Start);
|
||||
audio_gain_label.set_width_chars(5);
|
||||
let audio_gain_adjustment = gtk::Adjustment::new(
|
||||
state.audio_gain_percent as f64,
|
||||
0.0,
|
||||
@ -515,23 +512,26 @@ pub fn build_launcher_view(
|
||||
audio_gain_row.append(&audio_gain_label);
|
||||
audio_gain_row.append(&audio_gain_scale);
|
||||
audio_gain_row.append(&audio_gain_value);
|
||||
connection_body.append(&audio_gain_row);
|
||||
let routing_heading = gtk::Label::new(Some("Input Routing"));
|
||||
power_shell.append(&power_row);
|
||||
power_shell.append(&audio_gain_row);
|
||||
connection_body.append(&power_shell);
|
||||
let routing_heading = gtk::Label::new(Some("Input"));
|
||||
routing_heading.add_css_class("subgroup-title");
|
||||
routing_heading.set_halign(gtk::Align::Start);
|
||||
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
||||
connection_body.append(&routing_heading);
|
||||
|
||||
let routing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
routing_row.set_homogeneous(true);
|
||||
let input_toggle_button = gtk::Button::with_label("Change Routing");
|
||||
routing_row.set_hexpand(true);
|
||||
routing_heading.set_width_chars(5);
|
||||
routing_row.append(&routing_heading);
|
||||
let input_toggle_button = gtk::Button::with_label("Route");
|
||||
input_toggle_button.set_hexpand(true);
|
||||
stabilize_button(&input_toggle_button, 128);
|
||||
stabilize_button(&input_toggle_button, 106);
|
||||
input_toggle_button.set_tooltip_text(Some(
|
||||
"Change live keyboard and mouse ownership between this machine and the remote target.",
|
||||
));
|
||||
let swap_key_button = gtk::Button::with_label("Set Swap Key");
|
||||
stabilize_button(&swap_key_button, 128);
|
||||
stabilize_button(&swap_key_button, 106);
|
||||
routing_row.append(&input_toggle_button);
|
||||
routing_row.append(&swap_key_button);
|
||||
connection_body.append(&routing_row);
|
||||
@ -744,6 +744,7 @@ pub fn build_launcher_view(
|
||||
audio_check_detail,
|
||||
audio_check_meter,
|
||||
display_panes: [left_pane.clone(), right_pane.clone()],
|
||||
server_entry: server_entry.clone(),
|
||||
start_button: start_button.clone(),
|
||||
power_auto_button: power_auto_button.clone(),
|
||||
power_on_button: power_on_button.clone(),
|
||||
|
||||
@ -84,12 +84,11 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
|
||||
.set_value(state.audio_gain_percent as f64);
|
||||
}
|
||||
widgets.audio_gain_value.set_text(&state.audio_gain_label());
|
||||
widgets.start_button.set_label(if relay_live {
|
||||
"Disconnect Relay"
|
||||
} else {
|
||||
"Connect Relay"
|
||||
});
|
||||
widgets
|
||||
.start_button
|
||||
.set_label(if relay_live { "Disconnect" } else { "Connect" });
|
||||
widgets.start_button.set_sensitive(true);
|
||||
widgets.server_entry.set_sensitive(!relay_live);
|
||||
widgets.start_button.set_tooltip_text(Some(if relay_live {
|
||||
"Disconnect from the relay host, stop the live session, and let capture fall back to grace/standby."
|
||||
} else {
|
||||
@ -933,6 +932,13 @@ pub fn spawn_client_process(
|
||||
command.stdout(Stdio::piped());
|
||||
command.stderr(Stdio::piped());
|
||||
command.env("LESAVKA_LAUNCHER_CHILD", "1");
|
||||
command.env(
|
||||
"LESAVKA_LAUNCHER_PARENT_PID",
|
||||
std::process::id().to_string(),
|
||||
);
|
||||
if let Some(start_ticks) = super::launcher_parent_start_ticks() {
|
||||
command.env("LESAVKA_LAUNCHER_PARENT_START_TICKS", start_ticks);
|
||||
}
|
||||
command.env("LESAVKA_SERVER_ADDR", server_addr);
|
||||
command.env("LESAVKA_INPUT_TOGGLE_KEY", input_toggle_key);
|
||||
command.env("LESAVKA_LAUNCHER_WINDOW_TITLE", "Lesavka");
|
||||
|
||||
@ -86,6 +86,10 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
if env::var("LESAVKA_LAUNCHER_CHILD").is_ok() {
|
||||
launcher::start_launcher_child_parent_watchdog_from_env();
|
||||
}
|
||||
|
||||
if env::var("LESAVKA_LAUNCHER_CHILD").is_err() && launcher::maybe_run_launcher(&args)? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.11.44"
|
||||
version = "0.11.45"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -1,34 +1,39 @@
|
||||
{
|
||||
"files": {
|
||||
"client/src/app.rs": {
|
||||
"clippy_warnings": 42,
|
||||
"doc_debt": 12,
|
||||
"loc": 590
|
||||
"clippy_warnings": 40,
|
||||
"doc_debt": 13,
|
||||
"loc": 808
|
||||
},
|
||||
"client/src/app_support.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 3,
|
||||
"loc": 131
|
||||
"loc": 132
|
||||
},
|
||||
"client/src/bin/lesavka-relayctl.rs": {
|
||||
"clippy_warnings": 2,
|
||||
"doc_debt": 3,
|
||||
"loc": 140
|
||||
},
|
||||
"client/src/handshake.rs": {
|
||||
"clippy_warnings": 2,
|
||||
"doc_debt": 3,
|
||||
"loc": 215
|
||||
"doc_debt": 5,
|
||||
"loc": 381
|
||||
},
|
||||
"client/src/input/camera.rs": {
|
||||
"clippy_warnings": 38,
|
||||
"doc_debt": 7,
|
||||
"loc": 372
|
||||
"clippy_warnings": 30,
|
||||
"doc_debt": 8,
|
||||
"loc": 407
|
||||
},
|
||||
"client/src/input/inputs.rs": {
|
||||
"clippy_warnings": 42,
|
||||
"doc_debt": 20,
|
||||
"loc": 871
|
||||
"clippy_warnings": 40,
|
||||
"doc_debt": 27,
|
||||
"loc": 1166
|
||||
},
|
||||
"client/src/input/keyboard.rs": {
|
||||
"clippy_warnings": 26,
|
||||
"doc_debt": 22,
|
||||
"loc": 676
|
||||
"doc_debt": 24,
|
||||
"loc": 705
|
||||
},
|
||||
"client/src/input/keymap.rs": {
|
||||
"clippy_warnings": 8,
|
||||
@ -37,8 +42,8 @@
|
||||
},
|
||||
"client/src/input/microphone.rs": {
|
||||
"clippy_warnings": 17,
|
||||
"doc_debt": 2,
|
||||
"loc": 166
|
||||
"doc_debt": 7,
|
||||
"loc": 210
|
||||
},
|
||||
"client/src/input/mod.rs": {
|
||||
"clippy_warnings": 0,
|
||||
@ -58,52 +63,52 @@
|
||||
"client/src/launcher/device_test.rs": {
|
||||
"clippy_warnings": 43,
|
||||
"doc_debt": 29,
|
||||
"loc": 793
|
||||
"loc": 799
|
||||
},
|
||||
"client/src/launcher/devices.rs": {
|
||||
"clippy_warnings": 6,
|
||||
"doc_debt": 6,
|
||||
"loc": 234
|
||||
"doc_debt": 11,
|
||||
"loc": 348
|
||||
},
|
||||
"client/src/launcher/diagnostics.rs": {
|
||||
"clippy_warnings": 17,
|
||||
"doc_debt": 3,
|
||||
"loc": 177
|
||||
"clippy_warnings": 92,
|
||||
"doc_debt": 12,
|
||||
"loc": 1021
|
||||
},
|
||||
"client/src/launcher/mod.rs": {
|
||||
"clippy_warnings": 8,
|
||||
"doc_debt": 5,
|
||||
"loc": 268
|
||||
"doc_debt": 7,
|
||||
"loc": 438
|
||||
},
|
||||
"client/src/launcher/power.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 1,
|
||||
"loc": 69
|
||||
"doc_debt": 2,
|
||||
"loc": 86
|
||||
},
|
||||
"client/src/launcher/preview.rs": {
|
||||
"clippy_warnings": 36,
|
||||
"doc_debt": 26,
|
||||
"loc": 1030
|
||||
"clippy_warnings": 93,
|
||||
"doc_debt": 56,
|
||||
"loc": 2216
|
||||
},
|
||||
"client/src/launcher/state.rs": {
|
||||
"clippy_warnings": 64,
|
||||
"doc_debt": 36,
|
||||
"loc": 951
|
||||
"clippy_warnings": 154,
|
||||
"doc_debt": 54,
|
||||
"loc": 1377
|
||||
},
|
||||
"client/src/launcher/ui.rs": {
|
||||
"clippy_warnings": 42,
|
||||
"doc_debt": 12,
|
||||
"loc": 1501
|
||||
"clippy_warnings": 62,
|
||||
"doc_debt": 23,
|
||||
"loc": 2341
|
||||
},
|
||||
"client/src/launcher/ui_components.rs": {
|
||||
"clippy_warnings": 6,
|
||||
"doc_debt": 10,
|
||||
"loc": 973
|
||||
"clippy_warnings": 16,
|
||||
"doc_debt": 15,
|
||||
"loc": 1349
|
||||
},
|
||||
"client/src/launcher/ui_runtime.rs": {
|
||||
"clippy_warnings": 36,
|
||||
"doc_debt": 35,
|
||||
"loc": 1177
|
||||
"clippy_warnings": 62,
|
||||
"doc_debt": 44,
|
||||
"loc": 1698
|
||||
},
|
||||
"client/src/layout.rs": {
|
||||
"clippy_warnings": 6,
|
||||
@ -113,17 +118,17 @@
|
||||
"client/src/lib.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 0,
|
||||
"loc": 14
|
||||
"loc": 19
|
||||
},
|
||||
"client/src/main.rs": {
|
||||
"clippy_warnings": 2,
|
||||
"doc_debt": 2,
|
||||
"loc": 96
|
||||
"loc": 100
|
||||
},
|
||||
"client/src/output/audio.rs": {
|
||||
"clippy_warnings": 37,
|
||||
"doc_debt": 5,
|
||||
"loc": 195
|
||||
"clippy_warnings": 11,
|
||||
"doc_debt": 12,
|
||||
"loc": 371
|
||||
},
|
||||
"client/src/output/display.rs": {
|
||||
"clippy_warnings": 0,
|
||||
@ -142,14 +147,19 @@
|
||||
},
|
||||
"client/src/output/video.rs": {
|
||||
"clippy_warnings": 36,
|
||||
"doc_debt": 4,
|
||||
"loc": 547
|
||||
"doc_debt": 5,
|
||||
"loc": 585
|
||||
},
|
||||
"client/src/paste.rs": {
|
||||
"clippy_warnings": 2,
|
||||
"doc_debt": 1,
|
||||
"loc": 82
|
||||
},
|
||||
"client/src/video_support.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 1,
|
||||
"loc": 45
|
||||
},
|
||||
"common/src/bin/cli.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 0,
|
||||
@ -160,6 +170,11 @@
|
||||
"doc_debt": 0,
|
||||
"loc": 22
|
||||
},
|
||||
"common/src/eye_source.rs": {
|
||||
"clippy_warnings": 10,
|
||||
"doc_debt": 4,
|
||||
"loc": 114
|
||||
},
|
||||
"common/src/hid.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 2,
|
||||
@ -168,26 +183,32 @@
|
||||
"common/src/lib.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 0,
|
||||
"loc": 22
|
||||
"loc": 24
|
||||
},
|
||||
"common/src/paste.rs": {
|
||||
"clippy_warnings": 2,
|
||||
"doc_debt": 2,
|
||||
"loc": 132
|
||||
},
|
||||
"common/src/process_metrics.rs": {
|
||||
"clippy_warnings": 12,
|
||||
"doc_debt": 4,
|
||||
"loc": 105
|
||||
},
|
||||
"server/src/audio.rs": {
|
||||
"clippy_warnings": 37,
|
||||
"doc_debt": 7,
|
||||
"loc": 397
|
||||
"clippy_warnings": 43,
|
||||
"doc_debt": 13,
|
||||
"loc": 680
|
||||
},
|
||||
"server/src/bin/lesavka-uvc.real.inc": {
|
||||
"clippy_warnings": 31,
|
||||
"doc_debt": 0
|
||||
"clippy_warnings": 33,
|
||||
"doc_debt": 0,
|
||||
"loc": 0
|
||||
},
|
||||
"server/src/bin/lesavka-uvc.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 17,
|
||||
"loc": 710
|
||||
"loc": 712
|
||||
},
|
||||
"server/src/camera.rs": {
|
||||
"clippy_warnings": 12,
|
||||
@ -202,37 +223,37 @@
|
||||
"server/src/capture_power.rs": {
|
||||
"clippy_warnings": 12,
|
||||
"doc_debt": 10,
|
||||
"loc": 513
|
||||
"loc": 537
|
||||
},
|
||||
"server/src/gadget.rs": {
|
||||
"clippy_warnings": 30,
|
||||
"doc_debt": 7,
|
||||
"loc": 327
|
||||
"clippy_warnings": 52,
|
||||
"doc_debt": 12,
|
||||
"loc": 513
|
||||
},
|
||||
"server/src/handshake.rs": {
|
||||
"clippy_warnings": 2,
|
||||
"doc_debt": 1,
|
||||
"loc": 44
|
||||
"loc": 45
|
||||
},
|
||||
"server/src/lib.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 0,
|
||||
"loc": 14
|
||||
"loc": 18
|
||||
},
|
||||
"server/src/main.rs": {
|
||||
"clippy_warnings": 10,
|
||||
"doc_debt": 13,
|
||||
"loc": 586
|
||||
"clippy_warnings": 23,
|
||||
"doc_debt": 21,
|
||||
"loc": 952
|
||||
},
|
||||
"server/src/paste.rs": {
|
||||
"clippy_warnings": 8,
|
||||
"doc_debt": 4,
|
||||
"loc": 255
|
||||
"loc": 260
|
||||
},
|
||||
"server/src/runtime_support.rs": {
|
||||
"clippy_warnings": 14,
|
||||
"doc_debt": 8,
|
||||
"loc": 397
|
||||
"clippy_warnings": 22,
|
||||
"doc_debt": 20,
|
||||
"loc": 729
|
||||
},
|
||||
"server/src/uvc_control/model.rs": {
|
||||
"clippy_warnings": 0,
|
||||
@ -250,9 +271,9 @@
|
||||
"loc": 241
|
||||
},
|
||||
"server/src/video.rs": {
|
||||
"clippy_warnings": 33,
|
||||
"doc_debt": 8,
|
||||
"loc": 589
|
||||
"clippy_warnings": 53,
|
||||
"doc_debt": 12,
|
||||
"loc": 840
|
||||
},
|
||||
"server/src/video_sinks.rs": {
|
||||
"clippy_warnings": 78,
|
||||
|
||||
@ -82,7 +82,10 @@ publish_metrics() {
|
||||
}
|
||||
|
||||
status=0
|
||||
if cargo llvm-cov --workspace --all-targets --summary-only --json --output-path "${COVERAGE_JSON}"; then
|
||||
# Several integration contracts intentionally mutate process environment and
|
||||
# probe singleton runtime state. Keep coverage collection serial so per-file
|
||||
# percentages stay stable enough to serve as a baseline gate.
|
||||
if RUST_TEST_THREADS="${RUST_TEST_THREADS:-1}" cargo llvm-cov --workspace --all-targets --summary-only --json --output-path "${COVERAGE_JSON}"; then
|
||||
if python3 - "${COVERAGE_JSON}" "${BASELINE_JSON}" "${METRICS_FILE}" "${SUMMARY_TXT}" "${ROOT_DIR}" "${COVERAGE_CONTRACT_JSON}" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
@ -1,93 +1,101 @@
|
||||
{
|
||||
"files": {
|
||||
"client/src/app.rs": {
|
||||
"line_percent": 96.61016949152543,
|
||||
"loc": 590
|
||||
"line_percent": 97.4,
|
||||
"loc": 808
|
||||
},
|
||||
"client/src/app_support.rs": {
|
||||
"line_percent": 100.0,
|
||||
"loc": 131
|
||||
"loc": 132
|
||||
},
|
||||
"client/src/bin/lesavka-relayctl.rs": {
|
||||
"line_percent": 0.0,
|
||||
"loc": 140
|
||||
},
|
||||
"client/src/handshake.rs": {
|
||||
"line_percent": 96.36363636363636,
|
||||
"loc": 215
|
||||
"line_percent": 57.39,
|
||||
"loc": 381
|
||||
},
|
||||
"client/src/input/camera.rs": {
|
||||
"line_percent": 98.42931937172776,
|
||||
"loc": 372
|
||||
"line_percent": 97.99,
|
||||
"loc": 407
|
||||
},
|
||||
"client/src/input/inputs.rs": {
|
||||
"line_percent": 97.12793733681463,
|
||||
"loc": 871
|
||||
"line_percent": 96.39,
|
||||
"loc": 1166
|
||||
},
|
||||
"client/src/input/keyboard.rs": {
|
||||
"line_percent": 91.76136363636364,
|
||||
"loc": 676
|
||||
"line_percent": 91.5,
|
||||
"loc": 705
|
||||
},
|
||||
"client/src/input/keymap.rs": {
|
||||
"line_percent": 100.0,
|
||||
"loc": 196
|
||||
},
|
||||
"client/src/input/microphone.rs": {
|
||||
"line_percent": 95.94594594594594,
|
||||
"loc": 166
|
||||
"line_percent": 89.81,
|
||||
"loc": 210
|
||||
},
|
||||
"client/src/input/mouse.rs": {
|
||||
"line_percent": 97.32142857142857,
|
||||
"line_percent": 97.32,
|
||||
"loc": 317
|
||||
},
|
||||
"client/src/launcher/clipboard.rs": {
|
||||
"line_percent": 96.22641509433963,
|
||||
"line_percent": 96.23,
|
||||
"loc": 178
|
||||
},
|
||||
"client/src/launcher/devices.rs": {
|
||||
"line_percent": 96.25,
|
||||
"loc": 234
|
||||
"line_percent": 95.7,
|
||||
"loc": 348
|
||||
},
|
||||
"client/src/launcher/diagnostics.rs": {
|
||||
"line_percent": 97.19626168224299,
|
||||
"loc": 177
|
||||
"line_percent": 84.3,
|
||||
"loc": 1021
|
||||
},
|
||||
"client/src/launcher/mod.rs": {
|
||||
"line_percent": 93.61702127659576,
|
||||
"loc": 268
|
||||
"line_percent": 82.32,
|
||||
"loc": 438
|
||||
},
|
||||
"client/src/launcher/state.rs": {
|
||||
"line_percent": 90.42553191489363,
|
||||
"loc": 951
|
||||
"line_percent": 84.01,
|
||||
"loc": 1377
|
||||
},
|
||||
"client/src/launcher/ui.rs": {
|
||||
"line_percent": 100.0,
|
||||
"loc": 1501
|
||||
"loc": 2341
|
||||
},
|
||||
"client/src/layout.rs": {
|
||||
"line_percent": 97.72727272727273,
|
||||
"line_percent": 97.73,
|
||||
"loc": 78
|
||||
},
|
||||
"client/src/main.rs": {
|
||||
"line_percent": 97.0873786407767,
|
||||
"loc": 96
|
||||
"line_percent": 97.2,
|
||||
"loc": 100
|
||||
},
|
||||
"client/src/output/audio.rs": {
|
||||
"line_percent": 98.78048780487805,
|
||||
"loc": 195
|
||||
"line_percent": 76.92,
|
||||
"loc": 371
|
||||
},
|
||||
"client/src/output/display.rs": {
|
||||
"line_percent": 97.61904761904762,
|
||||
"line_percent": 97.62,
|
||||
"loc": 81
|
||||
},
|
||||
"client/src/output/layout.rs": {
|
||||
"line_percent": 98.9795918367347,
|
||||
"line_percent": 98.98,
|
||||
"loc": 155
|
||||
},
|
||||
"client/src/output/video.rs": {
|
||||
"line_percent": 96.22641509433963,
|
||||
"loc": 547
|
||||
"line_percent": 95.52,
|
||||
"loc": 585
|
||||
},
|
||||
"client/src/paste.rs": {
|
||||
"line_percent": 98.27586206896551,
|
||||
"line_percent": 98.28,
|
||||
"loc": 82
|
||||
},
|
||||
"client/src/video_support.rs": {
|
||||
"line_percent": 0.0,
|
||||
"loc": 45
|
||||
},
|
||||
"common/src/bin/cli.rs": {
|
||||
"line_percent": 100.0,
|
||||
"loc": 3
|
||||
@ -96,28 +104,36 @@
|
||||
"line_percent": 100.0,
|
||||
"loc": 22
|
||||
},
|
||||
"common/src/eye_source.rs": {
|
||||
"line_percent": 100.0,
|
||||
"loc": 114
|
||||
},
|
||||
"common/src/hid.rs": {
|
||||
"line_percent": 100.0,
|
||||
"loc": 134
|
||||
},
|
||||
"common/src/lib.rs": {
|
||||
"line_percent": 100.0,
|
||||
"loc": 22
|
||||
"loc": 24
|
||||
},
|
||||
"common/src/paste.rs": {
|
||||
"line_percent": 97.05882352941178,
|
||||
"line_percent": 97.06,
|
||||
"loc": 132
|
||||
},
|
||||
"common/src/process_metrics.rs": {
|
||||
"line_percent": 89.55,
|
||||
"loc": 105
|
||||
},
|
||||
"server/src/audio.rs": {
|
||||
"line_percent": 98.96907216494846,
|
||||
"loc": 397
|
||||
"line_percent": 98.97,
|
||||
"loc": 680
|
||||
},
|
||||
"server/src/bin/lesavka-uvc.rs": {
|
||||
"line_percent": 96.35535307517085,
|
||||
"loc": 710
|
||||
"line_percent": 95.92,
|
||||
"loc": 712
|
||||
},
|
||||
"server/src/camera.rs": {
|
||||
"line_percent": 99.09909909909909,
|
||||
"line_percent": 99.1,
|
||||
"loc": 392
|
||||
},
|
||||
"server/src/camera_runtime.rs": {
|
||||
@ -126,42 +142,42 @@
|
||||
},
|
||||
"server/src/capture_power.rs": {
|
||||
"line_percent": 100.0,
|
||||
"loc": 513
|
||||
"loc": 537
|
||||
},
|
||||
"server/src/gadget.rs": {
|
||||
"line_percent": 96.875,
|
||||
"loc": 327
|
||||
"line_percent": 91.12,
|
||||
"loc": 513
|
||||
},
|
||||
"server/src/handshake.rs": {
|
||||
"line_percent": 100.0,
|
||||
"loc": 44
|
||||
"loc": 45
|
||||
},
|
||||
"server/src/main.rs": {
|
||||
"line_percent": 95.54140127388536,
|
||||
"loc": 586
|
||||
"line_percent": 79.34,
|
||||
"loc": 952
|
||||
},
|
||||
"server/src/paste.rs": {
|
||||
"line_percent": 96.21621621621622,
|
||||
"loc": 255
|
||||
"line_percent": 96.32,
|
||||
"loc": 260
|
||||
},
|
||||
"server/src/runtime_support.rs": {
|
||||
"line_percent": 96.42857142857143,
|
||||
"loc": 397
|
||||
"line_percent": 90.99,
|
||||
"loc": 729
|
||||
},
|
||||
"server/src/uvc_runtime.rs": {
|
||||
"line_percent": 97.14285714285714,
|
||||
"line_percent": 97.14,
|
||||
"loc": 241
|
||||
},
|
||||
"server/src/video.rs": {
|
||||
"line_percent": 79.16666666666666,
|
||||
"loc": 589
|
||||
"line_percent": 96.55,
|
||||
"loc": 840
|
||||
},
|
||||
"server/src/video_sinks.rs": {
|
||||
"line_percent": 100.0,
|
||||
"loc": 559
|
||||
},
|
||||
"server/src/video_support.rs": {
|
||||
"line_percent": 96.03174603174604,
|
||||
"line_percent": 97.62,
|
||||
"loc": 236
|
||||
}
|
||||
}
|
||||
|
||||
155
scripts/ci/test_gate.sh
Executable file
155
scripts/ci/test_gate.sh
Executable file
@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the Rust test suite, publish CI test metrics when configured, and retain artifacts.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
REPORT_DIR="${ROOT_DIR}/target/test-gate"
|
||||
TEST_LOG="${REPORT_DIR}/cargo-test.log"
|
||||
SUMMARY_JSON="${REPORT_DIR}/summary.json"
|
||||
SUMMARY_TXT="${REPORT_DIR}/summary.txt"
|
||||
METRICS_FILE="${REPORT_DIR}/metrics.prom"
|
||||
PUSHGATEWAY_URL=${QUALITY_GATE_PUSHGATEWAY_URL:-}
|
||||
PUSHGATEWAY_JOB=${LESAVKA_TEST_GATE_PUSHGATEWAY_JOB:-lesavka-test-gate}
|
||||
|
||||
mkdir -p "${REPORT_DIR}"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
branch=${BRANCH_NAME:-${GIT_BRANCH:-}}
|
||||
if [[ -z "${branch}" ]]; then
|
||||
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)
|
||||
fi
|
||||
commit=${GIT_COMMIT:-}
|
||||
if [[ -z "${commit}" ]]; then
|
||||
commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
fi
|
||||
build_url=${BUILD_URL:-}
|
||||
|
||||
start_seconds=$(date +%s)
|
||||
status=0
|
||||
set +e
|
||||
cargo test --workspace --all-targets --color never 2>&1 | tee "${TEST_LOG}"
|
||||
status=${PIPESTATUS[0]}
|
||||
set -e
|
||||
end_seconds=$(date +%s)
|
||||
duration_seconds=$((end_seconds - start_seconds))
|
||||
|
||||
python3 - \
|
||||
"${TEST_LOG}" \
|
||||
"${SUMMARY_JSON}" \
|
||||
"${SUMMARY_TXT}" \
|
||||
"${METRICS_FILE}" \
|
||||
"${status}" \
|
||||
"${duration_seconds}" \
|
||||
"${branch}" \
|
||||
"${commit}" \
|
||||
"${build_url}" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
log_path = pathlib.Path(sys.argv[1])
|
||||
summary_json_path = pathlib.Path(sys.argv[2])
|
||||
summary_txt_path = pathlib.Path(sys.argv[3])
|
||||
metrics_path = pathlib.Path(sys.argv[4])
|
||||
status = int(sys.argv[5])
|
||||
duration_seconds = int(sys.argv[6])
|
||||
branch = sys.argv[7] or 'unknown'
|
||||
commit = sys.argv[8] or 'unknown'
|
||||
build_url = sys.argv[9]
|
||||
|
||||
result_re = re.compile(
|
||||
r'test result: (?:ok|FAILED)\. '
|
||||
r'(?P<passed>\d+) passed; '
|
||||
r'(?P<failed>\d+) failed; '
|
||||
r'(?P<ignored>\d+) ignored; '
|
||||
r'(?P<measured>\d+) measured; '
|
||||
r'(?P<filtered>\d+) filtered out;'
|
||||
)
|
||||
|
||||
counts = {'passed': 0, 'failed': 0, 'ignored': 0, 'measured': 0, 'filtered': 0}
|
||||
for raw in log_path.read_text(encoding='utf-8', errors='replace').splitlines():
|
||||
match = result_re.search(raw)
|
||||
if not match:
|
||||
continue
|
||||
for key in counts:
|
||||
counts[key] += int(match.group(key))
|
||||
|
||||
outcome = 'ok' if status == 0 else 'failed'
|
||||
summary = {
|
||||
'suite': 'lesavka',
|
||||
'branch': branch,
|
||||
'commit': commit,
|
||||
'build_url': build_url,
|
||||
'outcome': outcome,
|
||||
'exit_code': status,
|
||||
'duration_seconds': duration_seconds,
|
||||
'generated_at': datetime.now(timezone.utc).isoformat(),
|
||||
'tests': counts,
|
||||
}
|
||||
summary_json_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + '\n', encoding='utf-8')
|
||||
summary_txt_path.write_text(
|
||||
'\n'.join([
|
||||
f"lesavka test gate: {outcome}",
|
||||
f"branch: {branch}",
|
||||
f"commit: {commit}",
|
||||
f"duration: {duration_seconds}s",
|
||||
f"passed: {counts['passed']}",
|
||||
f"failed: {counts['failed']}",
|
||||
f"ignored: {counts['ignored']}",
|
||||
f"filtered: {counts['filtered']}",
|
||||
]) + '\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
|
||||
def label_value(value: str) -> str:
|
||||
return value.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n')
|
||||
|
||||
labels = f'suite="lesavka",branch="{label_value(branch)}"'
|
||||
success = 1 if outcome == 'ok' else 0
|
||||
failure = 1 - success
|
||||
lines = [
|
||||
'# HELP lesavka_test_gate_last_run_success Whether the latest lesavka cargo test gate run succeeded.',
|
||||
'# TYPE lesavka_test_gate_last_run_success gauge',
|
||||
f'lesavka_test_gate_last_run_success{{{labels}}} {success}',
|
||||
'# HELP lesavka_test_gate_duration_seconds Duration of the latest lesavka cargo test gate run.',
|
||||
'# TYPE lesavka_test_gate_duration_seconds gauge',
|
||||
f'lesavka_test_gate_duration_seconds{{{labels}}} {duration_seconds}',
|
||||
'# HELP lesavka_test_gate_tests Number of Rust tests reported by the latest lesavka test gate run.',
|
||||
'# TYPE lesavka_test_gate_tests gauge',
|
||||
]
|
||||
for result, value in counts.items():
|
||||
lines.append(f'lesavka_test_gate_tests{{{labels},result="{result}"}} {value}')
|
||||
lines.extend([
|
||||
'# HELP platform_quality_gate_tests_total Test result counts from the latest lesavka gate run.',
|
||||
'# TYPE platform_quality_gate_tests_total gauge',
|
||||
f'platform_quality_gate_tests_total{{{labels},result="passed"}} {counts["passed"]}',
|
||||
f'platform_quality_gate_tests_total{{{labels},result="failed"}} {counts["failed"]}',
|
||||
f'platform_quality_gate_tests_total{{{labels},result="ignored"}} {counts["ignored"]}',
|
||||
'# HELP platform_quality_gate_checks_total Check outcomes from the latest lesavka gate run.',
|
||||
'# TYPE platform_quality_gate_checks_total gauge',
|
||||
f'platform_quality_gate_checks_total{{{labels},check="tests",status="ok"}} {success}',
|
||||
f'platform_quality_gate_checks_total{{{labels},check="tests",status="failed"}} {failure}',
|
||||
])
|
||||
metrics_path.write_text('\n'.join(lines) + '\n', encoding='utf-8')
|
||||
PY
|
||||
|
||||
publish_metrics() {
|
||||
if [[ -z "${PUSHGATEWAY_URL}" ]]; then
|
||||
echo "Skipping test metrics publish: QUALITY_GATE_PUSHGATEWAY_URL is not set"
|
||||
return 0
|
||||
fi
|
||||
|
||||
curl --fail --silent --show-error \
|
||||
--data-binary @"${METRICS_FILE}" \
|
||||
"${PUSHGATEWAY_URL%/}/metrics/job/${PUSHGATEWAY_JOB}/suite/lesavka"
|
||||
}
|
||||
|
||||
publish_status=0
|
||||
publish_metrics || publish_status=$?
|
||||
|
||||
if [[ "${status}" -ne 0 ]]; then
|
||||
exit "${status}"
|
||||
fi
|
||||
exit "${publish_status}"
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.11.44"
|
||||
version = "0.11.45"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -408,260 +408,4 @@ mod inputs_contract {
|
||||
"pending-release flow should clear pending flag"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_toggle_key_parser_handles_supported_aliases_and_disable_switch() {
|
||||
assert_eq!(
|
||||
parse_quick_toggle_key("scrolllock"),
|
||||
Some(evdev::KeyCode::KEY_SCROLLLOCK)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_quick_toggle_key("pause"),
|
||||
Some(evdev::KeyCode::KEY_PAUSE)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_quick_toggle_key("sysrq"),
|
||||
Some(evdev::KeyCode::KEY_SYSRQ)
|
||||
);
|
||||
assert_eq!(parse_quick_toggle_key("f12"), Some(evdev::KeyCode::KEY_F12));
|
||||
assert_eq!(parse_quick_toggle_key("off"), None);
|
||||
assert_eq!(parse_quick_toggle_key("none"), None);
|
||||
assert_eq!(
|
||||
parse_quick_toggle_key("definitely-unknown"),
|
||||
Some(evdev::KeyCode::KEY_PAUSE)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn quick_toggle_key_env_defaults_and_respects_explicit_disable() {
|
||||
with_var("LESAVKA_INPUT_TOGGLE_KEY", None::<&str>, || {
|
||||
assert_eq!(quick_toggle_key_from_env(), Some(evdev::KeyCode::KEY_PAUSE));
|
||||
});
|
||||
with_var("LESAVKA_INPUT_TOGGLE_KEY", Some("off"), || {
|
||||
assert_eq!(quick_toggle_key_from_env(), None);
|
||||
});
|
||||
with_var("LESAVKA_INPUT_TOGGLE_KEY", Some("f11"), || {
|
||||
assert_eq!(quick_toggle_key_from_env(), Some(evdev::KeyCode::KEY_F11));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn quick_toggle_debounce_env_uses_defaults_and_applies_safety_floor() {
|
||||
with_var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS", None::<&str>, || {
|
||||
assert_eq!(quick_toggle_debounce_from_env(), Duration::from_millis(350));
|
||||
});
|
||||
with_var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS", Some("20"), || {
|
||||
assert_eq!(quick_toggle_debounce_from_env(), Duration::from_millis(50));
|
||||
});
|
||||
with_var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS", Some("900"), || {
|
||||
assert_eq!(quick_toggle_debounce_from_env(), Duration::from_millis(900));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn remote_failsafe_timeout_env_is_opt_in_and_allows_disable() {
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", None::<&str>, || {
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", None::<&str>, || {
|
||||
assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_millis(0));
|
||||
});
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("0"), || {
|
||||
assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_millis(0));
|
||||
});
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("60000"), || {
|
||||
assert_eq!(
|
||||
remote_failsafe_timeout_from_env(),
|
||||
Duration::from_millis(60_000)
|
||||
);
|
||||
});
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("1500"), || {
|
||||
assert_eq!(
|
||||
remote_failsafe_timeout_from_env(),
|
||||
Duration::from_millis(1_500)
|
||||
);
|
||||
});
|
||||
});
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("0"), || {
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("60000"), || {
|
||||
assert_eq!(remote_failsafe_timeout_from_env(), Duration::ZERO);
|
||||
});
|
||||
});
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("60"), || {
|
||||
assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_secs(60));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn boot_remote_capture_only_arms_failsafe_when_launch_option_is_nonzero() {
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("0"), || {
|
||||
let agg = new_aggregator();
|
||||
assert_eq!(agg.remote_failsafe_timeout, Duration::ZERO);
|
||||
assert!(agg.remote_failsafe_started_at.is_none());
|
||||
});
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("60"), || {
|
||||
let agg = new_aggregator();
|
||||
assert_eq!(agg.remote_failsafe_timeout, Duration::from_secs(60));
|
||||
assert!(agg.remote_failsafe_started_at.is_some());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_remote_capture_arms_failsafe_and_local_release_clears_it() {
|
||||
let mut agg = new_aggregator();
|
||||
agg.released = true;
|
||||
agg.pending_release = false;
|
||||
agg.remote_failsafe_timeout = Duration::from_millis(5_000);
|
||||
|
||||
agg.enable_remote_capture();
|
||||
assert!(
|
||||
agg.remote_failsafe_started_at.is_some(),
|
||||
"remote capture should arm the temporary failsafe window"
|
||||
);
|
||||
|
||||
agg.begin_local_release();
|
||||
assert!(
|
||||
agg.remote_failsafe_started_at.is_none(),
|
||||
"returning control locally should clear the failsafe timer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_remote_capture_does_not_auto_cutoff_when_failsafe_is_disabled() {
|
||||
let mut agg = new_aggregator();
|
||||
agg.released = true;
|
||||
agg.pending_release = false;
|
||||
agg.remote_failsafe_timeout = Duration::ZERO;
|
||||
|
||||
agg.enable_remote_capture();
|
||||
assert!(
|
||||
agg.remote_failsafe_started_at.is_none(),
|
||||
"normal remote input sessions should not silently flip back to local"
|
||||
);
|
||||
assert!(agg.remote_capture_enabled.load(Ordering::Relaxed));
|
||||
assert!(!agg.released);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn run_remote_failsafe_returns_control_to_local_machine() {
|
||||
let mut agg = new_aggregator();
|
||||
agg.remote_failsafe_timeout = Duration::from_millis(1);
|
||||
agg.remote_failsafe_started_at =
|
||||
Some(std::time::Instant::now() - Duration::from_millis(10));
|
||||
|
||||
let result = tokio::time::timeout(Duration::from_millis(120), agg.run()).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"run should keep looping after the failsafe returns control locally"
|
||||
);
|
||||
assert!(
|
||||
agg.released,
|
||||
"failsafe expiry should release devices back to the local machine"
|
||||
);
|
||||
assert!(
|
||||
!agg.pending_release,
|
||||
"failsafe expiry should complete the local-release handoff"
|
||||
);
|
||||
assert!(
|
||||
agg.remote_failsafe_started_at.is_none(),
|
||||
"failsafe timer should clear once local control is restored"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn quick_toggle_tap_flips_routing_when_processed_through_input_aggregator() {
|
||||
let Some((mut vdev, dev)) = build_keyboard_pair_with_keys(
|
||||
"lesavka-input-toggle-pause",
|
||||
&[
|
||||
evdev::KeyCode::KEY_A,
|
||||
evdev::KeyCode::KEY_ENTER,
|
||||
evdev::KeyCode::KEY_PAUSE,
|
||||
],
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let (kbd_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let (mou_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let keyboard = KeyboardAggregator::new(dev, false, kbd_tx.clone(), None);
|
||||
let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None);
|
||||
agg.quick_toggle_key = Some(evdev::KeyCode::KEY_PAUSE);
|
||||
agg.quick_toggle_debounce = Duration::from_millis(0);
|
||||
agg.keyboards.push(keyboard);
|
||||
|
||||
vdev.emit(&[
|
||||
evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_PAUSE.0, 1),
|
||||
evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_PAUSE.0, 0),
|
||||
])
|
||||
.expect("emit pause tap");
|
||||
thread::sleep(std::time::Duration::from_millis(20));
|
||||
|
||||
agg.process_keyboard_updates();
|
||||
let quick_toggle_now = agg.quick_toggle_active();
|
||||
agg.observe_quick_toggle(quick_toggle_now);
|
||||
|
||||
assert!(
|
||||
agg.pending_release,
|
||||
"a quick swap-key tap should start the local handoff path"
|
||||
);
|
||||
assert!(
|
||||
!agg.released,
|
||||
"the relay should still be in pending-release until the local handoff completes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_quick_toggle_uses_rising_edge_to_avoid_repeat_toggling() {
|
||||
let mut agg = new_aggregator();
|
||||
agg.quick_toggle_debounce = Duration::from_millis(0);
|
||||
|
||||
agg.observe_quick_toggle(true);
|
||||
assert!(
|
||||
agg.pending_release,
|
||||
"first quick-toggle should switch from remote to local pending-release mode"
|
||||
);
|
||||
assert!(!agg.released);
|
||||
|
||||
agg.observe_quick_toggle(true);
|
||||
assert!(
|
||||
agg.pending_release,
|
||||
"holding the quick-toggle key should not retrigger mode switching"
|
||||
);
|
||||
|
||||
agg.released = true;
|
||||
agg.pending_release = false;
|
||||
agg.observe_quick_toggle(false);
|
||||
agg.observe_quick_toggle(true);
|
||||
assert!(
|
||||
!agg.released,
|
||||
"second rising edge should return to remote mode"
|
||||
);
|
||||
assert!(
|
||||
!agg.pending_release,
|
||||
"remote-mode transition should clear pending release state"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_quick_toggle_honors_debounce_window() {
|
||||
let mut agg = new_aggregator();
|
||||
agg.quick_toggle_debounce = Duration::from_secs(60);
|
||||
|
||||
agg.released = true;
|
||||
agg.pending_release = false;
|
||||
agg.observe_quick_toggle(true);
|
||||
assert!(!agg.released, "first edge should switch to remote");
|
||||
|
||||
agg.released = true;
|
||||
agg.pending_release = false;
|
||||
agg.observe_quick_toggle(false);
|
||||
agg.observe_quick_toggle(true);
|
||||
assert!(
|
||||
agg.released,
|
||||
"second edge inside debounce window should be ignored"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
461
testing/tests/client_inputs_routing_contract.rs
Normal file
461
testing/tests/client_inputs_routing_contract.rs
Normal file
@ -0,0 +1,461 @@
|
||||
//! Routing, swap-key, and failsafe coverage for client input aggregation.
|
||||
//!
|
||||
//! Scope: include the input aggregator source and exercise local/remote routing
|
||||
//! behavior, quick-toggle handling, and opt-in remote failsafe behavior.
|
||||
//! Targets: `client/src/input/inputs.rs`.
|
||||
//! Why: swap-key and routing regressions can lock the operator out of local
|
||||
//! control, so these paths need dedicated contract coverage.
|
||||
|
||||
mod layout {
|
||||
pub use lesavka_client::layout::*;
|
||||
}
|
||||
|
||||
mod keyboard {
|
||||
pub use lesavka_client::input::keyboard::*;
|
||||
}
|
||||
|
||||
mod mouse {
|
||||
pub use lesavka_client::input::mouse::*;
|
||||
}
|
||||
|
||||
#[allow(warnings)]
|
||||
mod inputs_contract {
|
||||
include!(env!("LESAVKA_CLIENT_INPUTS_SRC"));
|
||||
|
||||
use evdev::AttributeSet;
|
||||
use evdev::uinput::VirtualDevice;
|
||||
use serial_test::serial;
|
||||
use std::thread;
|
||||
use temp_env::with_var;
|
||||
|
||||
fn open_virtual_device(vdev: &mut VirtualDevice) -> Option<evdev::Device> {
|
||||
for _ in 0..40 {
|
||||
if let Ok(mut nodes) = vdev.enumerate_dev_nodes_blocking() {
|
||||
if let Some(Ok(path)) = nodes.next() {
|
||||
if let Ok(dev) = evdev::Device::open(path) {
|
||||
let _ = dev.set_nonblocking(true);
|
||||
return Some(dev);
|
||||
}
|
||||
}
|
||||
}
|
||||
thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn build_keyboard() -> Option<evdev::Device> {
|
||||
let mut keys = AttributeSet::<evdev::KeyCode>::new();
|
||||
keys.insert(evdev::KeyCode::KEY_A);
|
||||
keys.insert(evdev::KeyCode::KEY_ENTER);
|
||||
|
||||
let mut vdev = VirtualDevice::builder()
|
||||
.ok()?
|
||||
.name("input-classify-kbd")
|
||||
.with_keys(&keys)
|
||||
.ok()?
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
open_virtual_device(&mut vdev)
|
||||
}
|
||||
|
||||
fn build_mouse() -> Option<evdev::Device> {
|
||||
let mut keys = AttributeSet::<evdev::KeyCode>::new();
|
||||
keys.insert(evdev::KeyCode::BTN_LEFT);
|
||||
let mut rel = AttributeSet::<evdev::RelativeAxisCode>::new();
|
||||
rel.insert(evdev::RelativeAxisCode::REL_X);
|
||||
rel.insert(evdev::RelativeAxisCode::REL_Y);
|
||||
|
||||
let mut vdev = VirtualDevice::builder()
|
||||
.ok()?
|
||||
.name("lesavka-input-classify-mouse")
|
||||
.with_keys(&keys)
|
||||
.ok()?
|
||||
.with_relative_axes(&rel)
|
||||
.ok()?
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
open_virtual_device(&mut vdev)
|
||||
}
|
||||
|
||||
fn build_touch_mouse() -> Option<evdev::Device> {
|
||||
let mut keys = AttributeSet::<evdev::KeyCode>::new();
|
||||
keys.insert(evdev::KeyCode::BTN_TOUCH);
|
||||
let abs = evdev::AbsInfo::new(0, 0, 1024, 0, 0, 0);
|
||||
|
||||
let mut vdev = VirtualDevice::builder()
|
||||
.ok()?
|
||||
.name("lesavka-input-classify-touch")
|
||||
.with_keys(&keys)
|
||||
.ok()?
|
||||
.with_absolute_axis(&evdev::UinputAbsSetup::new(
|
||||
evdev::AbsoluteAxisCode::ABS_MT_POSITION_X,
|
||||
abs,
|
||||
))
|
||||
.ok()?
|
||||
.with_absolute_axis(&evdev::UinputAbsSetup::new(
|
||||
evdev::AbsoluteAxisCode::ABS_MT_POSITION_Y,
|
||||
abs,
|
||||
))
|
||||
.ok()?
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
open_virtual_device(&mut vdev)
|
||||
}
|
||||
|
||||
fn build_misc_key_device() -> Option<evdev::Device> {
|
||||
let mut keys = AttributeSet::<evdev::KeyCode>::new();
|
||||
keys.insert(evdev::KeyCode::KEY_VOLUMEUP);
|
||||
|
||||
let mut vdev = VirtualDevice::builder()
|
||||
.ok()?
|
||||
.name("lesavka-input-classify-other")
|
||||
.with_keys(&keys)
|
||||
.ok()?
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
open_virtual_device(&mut vdev)
|
||||
}
|
||||
|
||||
fn build_named_keyboard(name: &str) -> Option<evdev::Device> {
|
||||
let mut keys = AttributeSet::<evdev::KeyCode>::new();
|
||||
keys.insert(evdev::KeyCode::KEY_A);
|
||||
keys.insert(evdev::KeyCode::KEY_ENTER);
|
||||
|
||||
let mut vdev = VirtualDevice::builder()
|
||||
.ok()?
|
||||
.name(name)
|
||||
.with_keys(&keys)
|
||||
.ok()?
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
open_virtual_device(&mut vdev)
|
||||
}
|
||||
|
||||
fn build_keyboard_pair(name: &str) -> Option<(VirtualDevice, evdev::Device)> {
|
||||
build_keyboard_pair_with_keys(name, &[evdev::KeyCode::KEY_A, evdev::KeyCode::KEY_ENTER])
|
||||
}
|
||||
|
||||
fn build_keyboard_pair_with_keys(
|
||||
name: &str,
|
||||
supported_keys: &[evdev::KeyCode],
|
||||
) -> Option<(VirtualDevice, evdev::Device)> {
|
||||
let mut keys = AttributeSet::<evdev::KeyCode>::new();
|
||||
for key in supported_keys {
|
||||
keys.insert(*key);
|
||||
}
|
||||
|
||||
let mut vdev = VirtualDevice::builder()
|
||||
.ok()?
|
||||
.name(name)
|
||||
.with_keys(&keys)
|
||||
.ok()?
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
let dev = open_virtual_device(&mut vdev)?;
|
||||
Some((vdev, dev))
|
||||
}
|
||||
|
||||
fn build_mouse_pair(name: &str) -> Option<(VirtualDevice, evdev::Device)> {
|
||||
let mut keys = AttributeSet::<evdev::KeyCode>::new();
|
||||
keys.insert(evdev::KeyCode::BTN_LEFT);
|
||||
let mut rel = AttributeSet::<evdev::RelativeAxisCode>::new();
|
||||
rel.insert(evdev::RelativeAxisCode::REL_X);
|
||||
rel.insert(evdev::RelativeAxisCode::REL_Y);
|
||||
|
||||
let mut vdev = VirtualDevice::builder()
|
||||
.ok()?
|
||||
.name(name)
|
||||
.with_keys(&keys)
|
||||
.ok()?
|
||||
.with_relative_axes(&rel)
|
||||
.ok()?
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
let dev = open_virtual_device(&mut vdev)?;
|
||||
Some((vdev, dev))
|
||||
}
|
||||
|
||||
fn new_aggregator() -> InputAggregator {
|
||||
let (kbd_tx, _) = tokio::sync::broadcast::channel(32);
|
||||
let (mou_tx, _) = tokio::sync::broadcast::channel(32);
|
||||
InputAggregator::new(false, kbd_tx, mou_tx, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_toggle_key_parser_handles_supported_aliases_and_disable_switch() {
|
||||
assert_eq!(
|
||||
parse_quick_toggle_key("scrolllock"),
|
||||
Some(evdev::KeyCode::KEY_SCROLLLOCK)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_quick_toggle_key("pause"),
|
||||
Some(evdev::KeyCode::KEY_PAUSE)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_quick_toggle_key("sysrq"),
|
||||
Some(evdev::KeyCode::KEY_SYSRQ)
|
||||
);
|
||||
assert_eq!(parse_quick_toggle_key("f12"), Some(evdev::KeyCode::KEY_F12));
|
||||
assert_eq!(parse_quick_toggle_key("off"), None);
|
||||
assert_eq!(parse_quick_toggle_key("none"), None);
|
||||
assert_eq!(
|
||||
parse_quick_toggle_key("definitely-unknown"),
|
||||
Some(evdev::KeyCode::KEY_PAUSE)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn quick_toggle_key_env_defaults_and_respects_explicit_disable() {
|
||||
with_var("LESAVKA_INPUT_TOGGLE_KEY", None::<&str>, || {
|
||||
assert_eq!(quick_toggle_key_from_env(), Some(evdev::KeyCode::KEY_PAUSE));
|
||||
});
|
||||
with_var("LESAVKA_INPUT_TOGGLE_KEY", Some("off"), || {
|
||||
assert_eq!(quick_toggle_key_from_env(), None);
|
||||
});
|
||||
with_var("LESAVKA_INPUT_TOGGLE_KEY", Some("f11"), || {
|
||||
assert_eq!(quick_toggle_key_from_env(), Some(evdev::KeyCode::KEY_F11));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn quick_toggle_debounce_env_uses_defaults_and_applies_safety_floor() {
|
||||
with_var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS", None::<&str>, || {
|
||||
assert_eq!(quick_toggle_debounce_from_env(), Duration::from_millis(350));
|
||||
});
|
||||
with_var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS", Some("20"), || {
|
||||
assert_eq!(quick_toggle_debounce_from_env(), Duration::from_millis(50));
|
||||
});
|
||||
with_var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS", Some("900"), || {
|
||||
assert_eq!(quick_toggle_debounce_from_env(), Duration::from_millis(900));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn remote_failsafe_timeout_env_is_opt_in_and_allows_disable() {
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", None::<&str>, || {
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", None::<&str>, || {
|
||||
assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_millis(0));
|
||||
});
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("0"), || {
|
||||
assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_millis(0));
|
||||
});
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("60000"), || {
|
||||
assert_eq!(
|
||||
remote_failsafe_timeout_from_env(),
|
||||
Duration::from_millis(60_000)
|
||||
);
|
||||
});
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("1500"), || {
|
||||
assert_eq!(
|
||||
remote_failsafe_timeout_from_env(),
|
||||
Duration::from_millis(1_500)
|
||||
);
|
||||
});
|
||||
});
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("0"), || {
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("60000"), || {
|
||||
assert_eq!(remote_failsafe_timeout_from_env(), Duration::ZERO);
|
||||
});
|
||||
});
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("60"), || {
|
||||
assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_secs(60));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn boot_remote_capture_only_arms_failsafe_when_launch_option_is_nonzero() {
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("0"), || {
|
||||
let agg = new_aggregator();
|
||||
assert_eq!(agg.remote_failsafe_timeout, Duration::ZERO);
|
||||
assert!(agg.remote_failsafe_started_at.is_none());
|
||||
});
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("60"), || {
|
||||
let agg = new_aggregator();
|
||||
assert_eq!(agg.remote_failsafe_timeout, Duration::from_secs(60));
|
||||
assert!(agg.remote_failsafe_started_at.is_some());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_remote_capture_arms_failsafe_and_local_release_clears_it() {
|
||||
let mut agg = new_aggregator();
|
||||
let remote_capture_enabled = agg.remote_capture_enabled_handle();
|
||||
agg.released = true;
|
||||
agg.pending_release = false;
|
||||
agg.remote_failsafe_timeout = Duration::from_millis(5_000);
|
||||
|
||||
agg.enable_remote_capture();
|
||||
assert!(remote_capture_enabled.load(Ordering::Relaxed));
|
||||
assert!(agg.remote_capture_active());
|
||||
assert!(
|
||||
agg.remote_failsafe_started_at.is_some(),
|
||||
"remote capture should arm the temporary failsafe window"
|
||||
);
|
||||
|
||||
agg.begin_local_release();
|
||||
assert!(!agg.remote_capture_active());
|
||||
assert!(
|
||||
agg.remote_failsafe_started_at.is_none(),
|
||||
"returning control locally should clear the failsafe timer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_release_timeout_helpers_are_stable() {
|
||||
let mut agg = new_aggregator();
|
||||
assert!(!agg.pending_release_timed_out());
|
||||
|
||||
agg.pending_release = true;
|
||||
agg.pending_release_timeout = Duration::from_millis(1);
|
||||
agg.pending_release_started_at = Some(Instant::now() - Duration::from_millis(5));
|
||||
assert!(agg.pending_release_timed_out());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_remote_capture_does_not_auto_cutoff_when_failsafe_is_disabled() {
|
||||
let mut agg = new_aggregator();
|
||||
agg.released = true;
|
||||
agg.pending_release = false;
|
||||
agg.remote_failsafe_timeout = Duration::ZERO;
|
||||
|
||||
agg.enable_remote_capture();
|
||||
assert!(
|
||||
agg.remote_failsafe_started_at.is_none(),
|
||||
"normal remote input sessions should not silently flip back to local"
|
||||
);
|
||||
assert!(agg.remote_capture_enabled.load(Ordering::Relaxed));
|
||||
assert!(!agg.released);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn run_remote_failsafe_returns_control_to_local_machine() {
|
||||
let mut agg = new_aggregator();
|
||||
agg.remote_failsafe_timeout = Duration::from_millis(1);
|
||||
agg.remote_failsafe_started_at =
|
||||
Some(std::time::Instant::now() - Duration::from_millis(10));
|
||||
|
||||
let result = tokio::time::timeout(Duration::from_millis(120), agg.run()).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"run should keep looping after the failsafe returns control locally"
|
||||
);
|
||||
assert!(
|
||||
agg.released,
|
||||
"failsafe expiry should release devices back to the local machine"
|
||||
);
|
||||
assert!(
|
||||
!agg.pending_release,
|
||||
"failsafe expiry should complete the local-release handoff"
|
||||
);
|
||||
assert!(
|
||||
agg.remote_failsafe_started_at.is_none(),
|
||||
"failsafe timer should clear once local control is restored"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn quick_toggle_tap_flips_routing_when_processed_through_input_aggregator() {
|
||||
let Some((mut vdev, dev)) = build_keyboard_pair_with_keys(
|
||||
"lesavka-input-toggle-pause",
|
||||
&[
|
||||
evdev::KeyCode::KEY_A,
|
||||
evdev::KeyCode::KEY_ENTER,
|
||||
evdev::KeyCode::KEY_PAUSE,
|
||||
],
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let (kbd_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let (mou_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let keyboard = KeyboardAggregator::new(dev, false, kbd_tx.clone(), None);
|
||||
let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None);
|
||||
agg.quick_toggle_key = Some(evdev::KeyCode::KEY_PAUSE);
|
||||
agg.quick_toggle_debounce = Duration::from_millis(0);
|
||||
agg.keyboards.push(keyboard);
|
||||
|
||||
vdev.emit(&[
|
||||
evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_PAUSE.0, 1),
|
||||
evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_PAUSE.0, 0),
|
||||
])
|
||||
.expect("emit pause tap");
|
||||
thread::sleep(std::time::Duration::from_millis(20));
|
||||
|
||||
agg.process_keyboard_updates();
|
||||
let quick_toggle_now = agg.quick_toggle_active();
|
||||
agg.observe_quick_toggle(quick_toggle_now);
|
||||
|
||||
assert!(
|
||||
agg.pending_release,
|
||||
"a quick swap-key tap should start the local handoff path"
|
||||
);
|
||||
assert!(
|
||||
!agg.released,
|
||||
"the relay should still be in pending-release until the local handoff completes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_quick_toggle_uses_rising_edge_to_avoid_repeat_toggling() {
|
||||
let mut agg = new_aggregator();
|
||||
agg.quick_toggle_debounce = Duration::from_millis(0);
|
||||
|
||||
agg.observe_quick_toggle(true);
|
||||
assert!(
|
||||
agg.pending_release,
|
||||
"first quick-toggle should switch from remote to local pending-release mode"
|
||||
);
|
||||
assert!(!agg.released);
|
||||
|
||||
agg.observe_quick_toggle(true);
|
||||
assert!(
|
||||
agg.pending_release,
|
||||
"holding the quick-toggle key should not retrigger mode switching"
|
||||
);
|
||||
|
||||
agg.released = true;
|
||||
agg.pending_release = false;
|
||||
agg.observe_quick_toggle(false);
|
||||
agg.observe_quick_toggle(true);
|
||||
assert!(
|
||||
!agg.released,
|
||||
"second rising edge should return to remote mode"
|
||||
);
|
||||
assert!(
|
||||
!agg.pending_release,
|
||||
"remote-mode transition should clear pending release state"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_quick_toggle_honors_debounce_window() {
|
||||
let mut agg = new_aggregator();
|
||||
agg.quick_toggle_debounce = Duration::from_secs(60);
|
||||
|
||||
agg.released = true;
|
||||
agg.pending_release = false;
|
||||
agg.observe_quick_toggle(true);
|
||||
assert!(!agg.released, "first edge should switch to remote");
|
||||
|
||||
agg.released = true;
|
||||
agg.pending_release = false;
|
||||
agg.observe_quick_toggle(false);
|
||||
agg.observe_quick_toggle(true);
|
||||
assert!(
|
||||
agg.released,
|
||||
"second edge inside debounce window should be ignored"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -21,6 +21,12 @@ fn const_i32(name: &str) -> i32 {
|
||||
.unwrap_or_else(|err| panic!("invalid {name} constant: {err}"))
|
||||
}
|
||||
|
||||
fn source_index(needle: &str) -> usize {
|
||||
UI_SRC
|
||||
.find(needle)
|
||||
.unwrap_or_else(|| panic!("missing source marker: {needle}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launcher_default_size_stays_inside_1080p() {
|
||||
assert_eq!(const_i32("LAUNCHER_DEFAULT_WIDTH"), 1360);
|
||||
@ -43,6 +49,7 @@ fn eye_panes_keep_the_locked_larger_preview_footprint() {
|
||||
#[test]
|
||||
fn device_staging_and_testing_bottoms_stay_locked_together() {
|
||||
assert!(UI_SRC.contains("staging_row.set_homogeneous(true);"));
|
||||
assert!(UI_SRC.contains("staging_row.set_vexpand(false);"));
|
||||
assert!(UI_SRC.contains("devices_panel.set_valign(gtk::Align::Fill);"));
|
||||
assert!(UI_SRC.contains("preview_panel.set_valign(gtk::Align::Fill);"));
|
||||
assert!(UI_SRC.contains(
|
||||
@ -58,6 +65,7 @@ fn device_testing_keeps_webcam_and_mic_playback_as_equal_bottom_columns() {
|
||||
assert_eq!(const_i32("CAMERA_PREVIEW_VIEWPORT_HEIGHT"), 158);
|
||||
assert!(UI_SRC.contains("webcam_group.set_valign(gtk::Align::Fill);"));
|
||||
assert!(UI_SRC.contains("playback_group.set_valign(gtk::Align::Fill);"));
|
||||
assert!(UI_SRC.contains("preview_body.set_vexpand(false);"));
|
||||
assert!(UI_SRC.contains("playback_body.set_valign(gtk::Align::Fill);"));
|
||||
assert!(UI_SRC.contains("audio_check_meter.set_vexpand(true);"));
|
||||
assert!(UI_SRC.contains("playback_body.append(&audio_check_meter);"));
|
||||
@ -83,11 +91,43 @@ fn operations_column_fills_height_and_splits_extra_space_between_logs() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_controls_keep_connect_inline_with_server_entry() {
|
||||
assert!(UI_SRC.contains("build_panel(\"Relay Controls\")"));
|
||||
assert!(UI_SRC.contains("let relay_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);"));
|
||||
assert!(UI_SRC.contains("relay_row.append(&server_entry);"));
|
||||
assert!(UI_SRC.contains("let start_button = gtk::Button::with_label(\"Connect\");"));
|
||||
assert!(UI_SRC.contains("relay_row.append(&start_button);"));
|
||||
assert!(
|
||||
source_index("relay_row.append(&server_entry);")
|
||||
< source_index("relay_row.append(&start_button);")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_audio_gain_control_stays_in_the_operations_rail() {
|
||||
assert!(UI_SRC.contains("let audio_heading = gtk::Label::new(Some(\"Remote Audio\"));"));
|
||||
assert!(!UI_SRC.contains("Remote Audio"));
|
||||
assert!(UI_SRC.contains("let power_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);"));
|
||||
assert!(UI_SRC.contains("let power_heading = gtk::Label::new(Some(\"Power\"));"));
|
||||
assert!(UI_SRC.contains("power_row.append(&power_heading);"));
|
||||
assert!(UI_SRC.contains("let audio_gain_scale ="));
|
||||
assert!(UI_SRC.contains("audio_gain_scale.set_draw_value(false);"));
|
||||
assert!(UI_SRC.contains("audio_gain_value.set_width_chars(5);"));
|
||||
assert!(UI_SRC.contains("connection_body.append(&audio_gain_row);"));
|
||||
assert!(UI_SRC.contains("audio_gain_row.set_size_request(220, -1);"));
|
||||
assert!(UI_SRC.contains("power_shell.append(&audio_gain_row);"));
|
||||
assert_eq!(
|
||||
UI_SRC
|
||||
.matches("connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));")
|
||||
.count(),
|
||||
2,
|
||||
"the operations rail should not gain extra vertical sections that stretch the lower layout"
|
||||
);
|
||||
assert!(
|
||||
source_index("let power_heading = gtk::Label::new(Some(\"Power\"));")
|
||||
< source_index("let audio_gain_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);")
|
||||
);
|
||||
assert!(
|
||||
source_index("power_shell.append(&audio_gain_row);")
|
||||
< source_index("let routing_heading = gtk::Label::new(Some(\"Input\"));")
|
||||
);
|
||||
}
|
||||
|
||||
34
testing/tests/client_launcher_runtime_contract.rs
Normal file
34
testing/tests/client_launcher_runtime_contract.rs
Normal file
@ -0,0 +1,34 @@
|
||||
//! Contract tests for launcher-owned relay process lifetime.
|
||||
//!
|
||||
//! Scope: static guardrails around launcher runtime process management.
|
||||
//! Targets: `client/src/launcher/ui_runtime.rs`.
|
||||
//! Why: the launcher is the owner of the live relay. If it crashes, audio,
|
||||
//! video, and input streams must not keep running as leaked child processes.
|
||||
|
||||
const UI_RUNTIME_SRC: &str = include_str!("../../client/src/launcher/ui_runtime.rs");
|
||||
const LAUNCHER_MOD_SRC: &str = include_str!("../../client/src/launcher/mod.rs");
|
||||
const MAIN_SRC: &str = include_str!("../../client/src/main.rs");
|
||||
|
||||
#[test]
|
||||
fn relay_child_gets_parent_identity_from_launcher() {
|
||||
assert!(UI_RUNTIME_SRC.contains("\"LESAVKA_LAUNCHER_PARENT_PID\""));
|
||||
assert!(UI_RUNTIME_SRC.contains("std::process::id().to_string()"));
|
||||
assert!(UI_RUNTIME_SRC.contains("LESAVKA_LAUNCHER_PARENT_START_TICKS"));
|
||||
assert!(UI_RUNTIME_SRC.contains("launcher_parent_start_ticks()"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_child_starts_safe_parent_watchdog_on_boot() {
|
||||
assert!(MAIN_SRC.contains("launcher::start_launcher_child_parent_watchdog_from_env();"));
|
||||
assert!(LAUNCHER_MOD_SRC.contains("start_launcher_child_parent_watchdog_from_env"));
|
||||
assert!(LAUNCHER_MOD_SRC.contains("launcher-parent-watchdog"));
|
||||
assert!(LAUNCHER_MOD_SRC.contains("std::process::exit(0);"));
|
||||
assert!(LAUNCHER_MOD_SRC.contains("proc_stat_start_ticks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_address_entry_is_locked_while_relay_is_live() {
|
||||
assert!(UI_RUNTIME_SRC.contains("widgets.server_entry.set_sensitive(!relay_live);"));
|
||||
assert!(UI_RUNTIME_SRC.contains("\"Connect\""));
|
||||
assert!(UI_RUNTIME_SRC.contains("\"Disconnect\""));
|
||||
}
|
||||
@ -459,238 +459,4 @@ mod server_main_binary_extra {
|
||||
server.abort();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn reset_usb_reports_host_not_attached_after_fake_cycle() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let hid_dir = dir.path().join("hid");
|
||||
std::fs::create_dir_all(&hid_dir).expect("create hid dir");
|
||||
std::fs::write(hid_dir.join("hidg0"), "").expect("create hidg0");
|
||||
std::fs::write(hid_dir.join("hidg1"), "").expect("create hidg1");
|
||||
build_fake_gadget_tree(dir.path(), "fake-ctrl.usb", "lesavka", "not attached");
|
||||
let helper = dir.path().join("noop-core.sh");
|
||||
write_helper(
|
||||
&helper,
|
||||
r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo noop core helper >&2
|
||||
"#,
|
||||
);
|
||||
|
||||
with_fake_gadget_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || {
|
||||
with_var(
|
||||
"LESAVKA_HID_DIR",
|
||||
Some(hid_dir.to_string_lossy().to_string()),
|
||||
|| {
|
||||
with_fast_usb_recovery(&helper, || {
|
||||
let kb = tokio::fs::File::from_std(
|
||||
std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(hid_dir.join("hidg0"))
|
||||
.expect("open hidg0"),
|
||||
);
|
||||
let ms = tokio::fs::File::from_std(
|
||||
std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(hid_dir.join("hidg1"))
|
||||
.expect("open hidg1"),
|
||||
);
|
||||
let handler = Handler {
|
||||
kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
|
||||
ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
|
||||
gadget: UsbGadget::new("lesavka"),
|
||||
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
|
||||
false,
|
||||
)),
|
||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||
capture_power: CapturePowerManager::new(),
|
||||
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
||||
std::collections::HashMap::new(),
|
||||
)),
|
||||
};
|
||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||
let err = rt
|
||||
.block_on(async {
|
||||
handler.reset_usb(tonic::Request::new(Empty {})).await
|
||||
})
|
||||
.expect_err("reset usb should report a host that never enumerates");
|
||||
assert_eq!(err.code(), tonic::Code::FailedPrecondition);
|
||||
assert!(
|
||||
err.message().contains("still not attached"),
|
||||
"unexpected reset error: {}",
|
||||
err.message()
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn reset_usb_forced_rebuild_can_recover_unattached_fake_udc() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let hid_dir = dir.path().join("hid");
|
||||
std::fs::create_dir_all(&hid_dir).expect("create hid dir");
|
||||
std::fs::write(hid_dir.join("hidg0"), "").expect("create hidg0");
|
||||
std::fs::write(hid_dir.join("hidg1"), "").expect("create hidg1");
|
||||
build_fake_gadget_tree(dir.path(), "fake-ctrl.usb", "lesavka", "not attached");
|
||||
let helper = dir.path().join("recover-core.sh");
|
||||
write_helper(
|
||||
&helper,
|
||||
r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo recover core helper >&2
|
||||
printf 'configured\n' > "$LESAVKA_GADGET_SYSFS_ROOT/class/udc/fake-ctrl.usb/state"
|
||||
"#,
|
||||
);
|
||||
|
||||
with_fake_gadget_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || {
|
||||
with_var(
|
||||
"LESAVKA_HID_DIR",
|
||||
Some(hid_dir.to_string_lossy().to_string()),
|
||||
|| {
|
||||
with_fast_usb_recovery(&helper, || {
|
||||
let kb = tokio::fs::File::from_std(
|
||||
std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(hid_dir.join("hidg0"))
|
||||
.expect("open hidg0"),
|
||||
);
|
||||
let ms = tokio::fs::File::from_std(
|
||||
std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(hid_dir.join("hidg1"))
|
||||
.expect("open hidg1"),
|
||||
);
|
||||
let handler = Handler {
|
||||
kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
|
||||
ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
|
||||
gadget: UsbGadget::new("lesavka"),
|
||||
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
|
||||
false,
|
||||
)),
|
||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||
capture_power: CapturePowerManager::new(),
|
||||
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
||||
std::collections::HashMap::new(),
|
||||
)),
|
||||
};
|
||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||
let reply = rt
|
||||
.block_on(async {
|
||||
handler.reset_usb(tonic::Request::new(Empty {})).await
|
||||
})
|
||||
.expect("reset usb should recover fake host")
|
||||
.into_inner();
|
||||
assert!(reply.ok);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_eye_hub_forwards_inner_packets() {
|
||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||
with_capture_power_disabled(|| {
|
||||
rt.block_on(async {
|
||||
let lease = CapturePowerManager::new().acquire().await;
|
||||
let packet = VideoPacket {
|
||||
id: 2,
|
||||
pts: 42,
|
||||
data: vec![9, 8, 7],
|
||||
..Default::default()
|
||||
};
|
||||
let hub = EyeHub::spawn(stream::iter(vec![Ok(packet.clone())]), lease);
|
||||
hub.subscribers
|
||||
.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||
let mut rx = hub.tx.subscribe();
|
||||
let observed = rx.recv().await.expect("hub packet");
|
||||
assert_eq!(observed.id, packet.id);
|
||||
assert_eq!(observed.pts, packet.pts);
|
||||
assert_eq!(observed.data, packet.data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicting_eye_hubs_for_the_same_source_are_pruned_before_reopen() {
|
||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||
with_capture_power_disabled(|| {
|
||||
rt.block_on(async {
|
||||
let requested_key = EyeHubKey {
|
||||
source_id: 1,
|
||||
requested_width: 1280,
|
||||
requested_height: 720,
|
||||
requested_fps: 60,
|
||||
};
|
||||
let stale_same_source_key = EyeHubKey {
|
||||
source_id: 1,
|
||||
requested_width: 1920,
|
||||
requested_height: 1080,
|
||||
requested_fps: 60,
|
||||
};
|
||||
let keep_other_source_key = EyeHubKey {
|
||||
source_id: 0,
|
||||
requested_width: 1920,
|
||||
requested_height: 1080,
|
||||
requested_fps: 60,
|
||||
};
|
||||
let stale_same_source = EyeHub::spawn(
|
||||
stream::pending::<Result<VideoPacket, tonic::Status>>(),
|
||||
CapturePowerManager::new().acquire().await,
|
||||
);
|
||||
let stopped_other_source = EyeHub::spawn(
|
||||
stream::pending::<Result<VideoPacket, tonic::Status>>(),
|
||||
CapturePowerManager::new().acquire().await,
|
||||
);
|
||||
stopped_other_source.shutdown();
|
||||
let keep_other_source = EyeHub::spawn(
|
||||
stream::pending::<Result<VideoPacket, tonic::Status>>(),
|
||||
CapturePowerManager::new().acquire().await,
|
||||
);
|
||||
|
||||
let mut hubs = std::collections::HashMap::new();
|
||||
hubs.insert(stale_same_source_key, stale_same_source.clone());
|
||||
hubs.insert(
|
||||
EyeHubKey {
|
||||
source_id: 0,
|
||||
requested_width: 1280,
|
||||
requested_height: 720,
|
||||
requested_fps: 60,
|
||||
},
|
||||
stopped_other_source,
|
||||
);
|
||||
hubs.insert(keep_other_source_key, keep_other_source.clone());
|
||||
|
||||
let removed = take_conflicting_eye_hubs(&mut hubs, requested_key);
|
||||
|
||||
assert_eq!(removed.len(), 2);
|
||||
assert!(!hubs.contains_key(&stale_same_source_key));
|
||||
assert!(hubs.contains_key(&keep_other_source_key));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eye_hub_shutdown_marks_the_hub_as_not_running() {
|
||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||
with_capture_power_disabled(|| {
|
||||
rt.block_on(async {
|
||||
let hub = EyeHub::spawn(
|
||||
stream::pending::<Result<VideoPacket, tonic::Status>>(),
|
||||
CapturePowerManager::new().acquire().await,
|
||||
);
|
||||
assert!(hub.running.load(std::sync::atomic::Ordering::Relaxed));
|
||||
hub.shutdown();
|
||||
assert!(!hub.running.load(std::sync::atomic::Ordering::Relaxed));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
366
testing/tests/server_main_usb_recovery_contract.rs
Normal file
366
testing/tests/server_main_usb_recovery_contract.rs
Normal file
@ -0,0 +1,366 @@
|
||||
//! USB reset and eye-hub coverage for server main relay branches.
|
||||
//!
|
||||
//! Scope: include `server/src/main.rs` and exercise USB recovery plus shared
|
||||
//! eye-feed hub behavior with synthetic endpoints.
|
||||
//! Targets: `server/src/main.rs`.
|
||||
//! Why: USB recovery and shared downstream video hubs are operational escape
|
||||
//! hatches; regressions here can leave HID or eye feeds unavailable.
|
||||
|
||||
#[allow(warnings)]
|
||||
mod server_main_binary_extra {
|
||||
include!(env!("LESAVKA_SERVER_MAIN_SRC"));
|
||||
|
||||
use futures_util::stream;
|
||||
use lesavka_common::lesavka::relay_client::RelayClient;
|
||||
use serial_test::serial;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
use temp_env::with_var;
|
||||
use tempfile::tempdir;
|
||||
|
||||
async fn connect_with_retry(addr: std::net::SocketAddr) -> tonic::transport::Channel {
|
||||
let endpoint = tonic::transport::Endpoint::from_shared(format!("http://{addr}"))
|
||||
.expect("endpoint")
|
||||
.tcp_nodelay(true);
|
||||
for _ in 0..40 {
|
||||
if let Ok(channel) = endpoint.clone().connect().await {
|
||||
return channel;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||
}
|
||||
panic!("failed to connect to local tonic server");
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, content: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("create parent");
|
||||
}
|
||||
std::fs::write(path, content).expect("write file");
|
||||
}
|
||||
|
||||
fn with_fake_gadget_roots(sys_root: &Path, cfg_root: &Path, f: impl FnOnce()) {
|
||||
let sys_root = sys_root.to_string_lossy().to_string();
|
||||
let cfg_root = cfg_root.to_string_lossy().to_string();
|
||||
with_var("LESAVKA_GADGET_SYSFS_ROOT", Some(sys_root), || {
|
||||
with_var("LESAVKA_GADGET_CONFIGFS_ROOT", Some(cfg_root), f);
|
||||
});
|
||||
}
|
||||
|
||||
fn with_capture_power_disabled(f: impl FnOnce()) {
|
||||
with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), f);
|
||||
}
|
||||
|
||||
fn build_fake_gadget_tree(base: &Path, ctrl: &str, gadget_name: &str, state: &str) {
|
||||
write_file(
|
||||
&base.join(format!("sys/class/udc/{ctrl}/state")),
|
||||
&format!("{state}\n"),
|
||||
);
|
||||
write_file(
|
||||
&base.join(format!("cfg/{gadget_name}/UDC")),
|
||||
&format!("{ctrl}\n"),
|
||||
);
|
||||
write_file(&base.join("sys/bus/platform/drivers/dwc3/unbind"), "");
|
||||
write_file(&base.join("sys/bus/platform/drivers/dwc3/bind"), "");
|
||||
}
|
||||
|
||||
fn write_helper(path: &Path, body: &str) {
|
||||
write_file(path, body);
|
||||
let mut perms = std::fs::metadata(path)
|
||||
.expect("helper metadata")
|
||||
.permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(path, perms).expect("chmod helper");
|
||||
}
|
||||
|
||||
fn with_fast_usb_recovery(helper: &Path, f: impl FnOnce()) {
|
||||
let helper = helper.to_string_lossy().to_string();
|
||||
with_var("LESAVKA_CORE_HELPER", Some(helper), || {
|
||||
with_var("LESAVKA_USB_RECOVERY_CYCLE_WAIT_MS", Some("0"), || {
|
||||
with_var("LESAVKA_USB_RECOVERY_REBUILD_WAIT_MS", Some("0"), || {
|
||||
with_var("LESAVKA_USB_RECOVERY_FINAL_WAIT_MS", Some("0"), f);
|
||||
})
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn build_handler_for_tests_with_modes(
|
||||
kb_writable: bool,
|
||||
ms_writable: bool,
|
||||
) -> (tempfile::TempDir, Handler) {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let kb_path = dir.path().join("hidg0.bin");
|
||||
let ms_path = dir.path().join("hidg1.bin");
|
||||
std::fs::write(&kb_path, []).expect("create kb file");
|
||||
std::fs::write(&ms_path, []).expect("create ms file");
|
||||
|
||||
let kb_std = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(kb_writable)
|
||||
.create(kb_writable)
|
||||
.truncate(kb_writable)
|
||||
.open(&kb_path)
|
||||
.expect("open kb");
|
||||
let ms_std = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(ms_writable)
|
||||
.create(ms_writable)
|
||||
.truncate(ms_writable)
|
||||
.open(&ms_path)
|
||||
.expect("open ms");
|
||||
let kb = tokio::fs::File::from_std(kb_std);
|
||||
let ms = tokio::fs::File::from_std(ms_std);
|
||||
|
||||
(
|
||||
dir,
|
||||
Handler {
|
||||
kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
|
||||
ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
|
||||
gadget: UsbGadget::new("lesavka"),
|
||||
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||
capture_power: CapturePowerManager::new(),
|
||||
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
||||
std::collections::HashMap::new(),
|
||||
)),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn build_handler_for_tests() -> (tempfile::TempDir, Handler) {
|
||||
build_handler_for_tests_with_modes(true, true)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn reset_usb_reports_host_not_attached_after_fake_cycle() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let hid_dir = dir.path().join("hid");
|
||||
std::fs::create_dir_all(&hid_dir).expect("create hid dir");
|
||||
std::fs::write(hid_dir.join("hidg0"), "").expect("create hidg0");
|
||||
std::fs::write(hid_dir.join("hidg1"), "").expect("create hidg1");
|
||||
build_fake_gadget_tree(dir.path(), "fake-ctrl.usb", "lesavka", "not attached");
|
||||
let helper = dir.path().join("noop-core.sh");
|
||||
write_helper(
|
||||
&helper,
|
||||
r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo noop core helper >&2
|
||||
"#,
|
||||
);
|
||||
|
||||
with_fake_gadget_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || {
|
||||
with_var(
|
||||
"LESAVKA_HID_DIR",
|
||||
Some(hid_dir.to_string_lossy().to_string()),
|
||||
|| {
|
||||
with_fast_usb_recovery(&helper, || {
|
||||
let kb = tokio::fs::File::from_std(
|
||||
std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(hid_dir.join("hidg0"))
|
||||
.expect("open hidg0"),
|
||||
);
|
||||
let ms = tokio::fs::File::from_std(
|
||||
std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(hid_dir.join("hidg1"))
|
||||
.expect("open hidg1"),
|
||||
);
|
||||
let handler = Handler {
|
||||
kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
|
||||
ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
|
||||
gadget: UsbGadget::new("lesavka"),
|
||||
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
|
||||
false,
|
||||
)),
|
||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||
capture_power: CapturePowerManager::new(),
|
||||
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
||||
std::collections::HashMap::new(),
|
||||
)),
|
||||
};
|
||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||
let err = rt
|
||||
.block_on(async {
|
||||
handler.reset_usb(tonic::Request::new(Empty {})).await
|
||||
})
|
||||
.expect_err("reset usb should report a host that never enumerates");
|
||||
assert_eq!(err.code(), tonic::Code::FailedPrecondition);
|
||||
assert!(
|
||||
err.message().contains("still not attached"),
|
||||
"unexpected reset error: {}",
|
||||
err.message()
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn reset_usb_forced_rebuild_can_recover_unattached_fake_udc() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let hid_dir = dir.path().join("hid");
|
||||
std::fs::create_dir_all(&hid_dir).expect("create hid dir");
|
||||
std::fs::write(hid_dir.join("hidg0"), "").expect("create hidg0");
|
||||
std::fs::write(hid_dir.join("hidg1"), "").expect("create hidg1");
|
||||
build_fake_gadget_tree(dir.path(), "fake-ctrl.usb", "lesavka", "not attached");
|
||||
let helper = dir.path().join("recover-core.sh");
|
||||
write_helper(
|
||||
&helper,
|
||||
r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo recover core helper >&2
|
||||
printf 'configured\n' > "$LESAVKA_GADGET_SYSFS_ROOT/class/udc/fake-ctrl.usb/state"
|
||||
"#,
|
||||
);
|
||||
|
||||
with_fake_gadget_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || {
|
||||
with_var(
|
||||
"LESAVKA_HID_DIR",
|
||||
Some(hid_dir.to_string_lossy().to_string()),
|
||||
|| {
|
||||
with_fast_usb_recovery(&helper, || {
|
||||
let kb = tokio::fs::File::from_std(
|
||||
std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(hid_dir.join("hidg0"))
|
||||
.expect("open hidg0"),
|
||||
);
|
||||
let ms = tokio::fs::File::from_std(
|
||||
std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(hid_dir.join("hidg1"))
|
||||
.expect("open hidg1"),
|
||||
);
|
||||
let handler = Handler {
|
||||
kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
|
||||
ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
|
||||
gadget: UsbGadget::new("lesavka"),
|
||||
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
|
||||
false,
|
||||
)),
|
||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||
capture_power: CapturePowerManager::new(),
|
||||
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
||||
std::collections::HashMap::new(),
|
||||
)),
|
||||
};
|
||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||
let reply = rt
|
||||
.block_on(async {
|
||||
handler.reset_usb(tonic::Request::new(Empty {})).await
|
||||
})
|
||||
.expect("reset usb should recover fake host")
|
||||
.into_inner();
|
||||
assert!(reply.ok);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_eye_hub_forwards_inner_packets() {
|
||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||
with_capture_power_disabled(|| {
|
||||
rt.block_on(async {
|
||||
let lease = CapturePowerManager::new().acquire().await;
|
||||
let packet = VideoPacket {
|
||||
id: 2,
|
||||
pts: 42,
|
||||
data: vec![9, 8, 7],
|
||||
..Default::default()
|
||||
};
|
||||
let hub = EyeHub::spawn(stream::iter(vec![Ok(packet.clone())]), lease);
|
||||
hub.subscribers
|
||||
.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||
let mut rx = hub.tx.subscribe();
|
||||
let observed = rx.recv().await.expect("hub packet");
|
||||
assert_eq!(observed.id, packet.id);
|
||||
assert_eq!(observed.pts, packet.pts);
|
||||
assert_eq!(observed.data, packet.data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicting_eye_hubs_for_the_same_source_are_pruned_before_reopen() {
|
||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||
with_capture_power_disabled(|| {
|
||||
rt.block_on(async {
|
||||
let requested_key = EyeHubKey {
|
||||
source_id: 1,
|
||||
requested_width: 1280,
|
||||
requested_height: 720,
|
||||
requested_fps: 60,
|
||||
};
|
||||
let stale_same_source_key = EyeHubKey {
|
||||
source_id: 1,
|
||||
requested_width: 1920,
|
||||
requested_height: 1080,
|
||||
requested_fps: 60,
|
||||
};
|
||||
let keep_other_source_key = EyeHubKey {
|
||||
source_id: 0,
|
||||
requested_width: 1920,
|
||||
requested_height: 1080,
|
||||
requested_fps: 60,
|
||||
};
|
||||
let stale_same_source = EyeHub::spawn(
|
||||
stream::pending::<Result<VideoPacket, tonic::Status>>(),
|
||||
CapturePowerManager::new().acquire().await,
|
||||
);
|
||||
let stopped_other_source = EyeHub::spawn(
|
||||
stream::pending::<Result<VideoPacket, tonic::Status>>(),
|
||||
CapturePowerManager::new().acquire().await,
|
||||
);
|
||||
stopped_other_source.shutdown();
|
||||
let keep_other_source = EyeHub::spawn(
|
||||
stream::pending::<Result<VideoPacket, tonic::Status>>(),
|
||||
CapturePowerManager::new().acquire().await,
|
||||
);
|
||||
|
||||
let mut hubs = std::collections::HashMap::new();
|
||||
hubs.insert(stale_same_source_key, stale_same_source.clone());
|
||||
hubs.insert(
|
||||
EyeHubKey {
|
||||
source_id: 0,
|
||||
requested_width: 1280,
|
||||
requested_height: 720,
|
||||
requested_fps: 60,
|
||||
},
|
||||
stopped_other_source,
|
||||
);
|
||||
hubs.insert(keep_other_source_key, keep_other_source.clone());
|
||||
|
||||
let removed = take_conflicting_eye_hubs(&mut hubs, requested_key);
|
||||
|
||||
assert_eq!(removed.len(), 2);
|
||||
assert!(!hubs.contains_key(&stale_same_source_key));
|
||||
assert!(hubs.contains_key(&keep_other_source_key));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eye_hub_shutdown_marks_the_hub_as_not_running() {
|
||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||
with_capture_power_disabled(|| {
|
||||
rt.block_on(async {
|
||||
let hub = EyeHub::spawn(
|
||||
stream::pending::<Result<VideoPacket, tonic::Status>>(),
|
||||
CapturePowerManager::new().acquire().await,
|
||||
);
|
||||
assert!(hub.running.load(std::sync::atomic::Ordering::Relaxed));
|
||||
hub.shutdown();
|
||||
assert!(!hub.running.load(std::sync::atomic::Ordering::Relaxed));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user