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') {
|
stage('Testing') {
|
||||||
steps {
|
steps {
|
||||||
container('rust-ci') {
|
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 {
|
steps {
|
||||||
container('rust-ci') {
|
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 {
|
always {
|
||||||
script {
|
script {
|
||||||
try {
|
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) {
|
} catch (Throwable err) {
|
||||||
echo "archive step unavailable: ${err.class.simpleName}"
|
echo "archive step unavailable: ${err.class.simpleName}"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.11.44"
|
version = "0.11.45"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[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 DEFAULT_LAUNCHER_FOCUS_SIGNAL_PATH: &str = "/tmp/lesavka-launcher-focus.signal";
|
||||||
pub const LAUNCHER_CLIPBOARD_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_CLIPBOARD_CONTROL";
|
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 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 REMOTE_INPUT_FAILSAFE_SECONDS_ENV: &str = "LESAVKA_INPUT_REMOTE_FAILSAFE_SECS";
|
||||||
pub const DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS: &str = "0";
|
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> {
|
pub fn maybe_run_launcher(args: &[String]) -> Result<bool> {
|
||||||
if should_run_launcher(args) {
|
if should_run_launcher(args) {
|
||||||
let server_addr = resolve_server_addr(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()];
|
let args = vec!["--server".to_string(), "http://server:50051".to_string()];
|
||||||
assert!(should_run_launcher(&args));
|
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."
|
"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_detail: gtk::Label,
|
||||||
pub audio_check_meter: gtk::ProgressBar,
|
pub audio_check_meter: gtk::ProgressBar,
|
||||||
pub display_panes: [DisplayPaneWidgets; 2],
|
pub display_panes: [DisplayPaneWidgets; 2],
|
||||||
|
pub server_entry: gtk::Entry,
|
||||||
pub start_button: gtk::Button,
|
pub start_button: gtk::Button,
|
||||||
pub power_auto_button: gtk::Button,
|
pub power_auto_button: gtk::Button,
|
||||||
pub power_on_button: gtk::Button,
|
pub power_on_button: gtk::Button,
|
||||||
@ -411,7 +412,7 @@ pub fn build_launcher_view(
|
|||||||
preview_body.append(&testing_row);
|
preview_body.append(&testing_row);
|
||||||
staging_row.append(&preview_panel);
|
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();
|
let server_entry = gtk::Entry::new();
|
||||||
server_entry.add_css_class("server-entry");
|
server_entry.add_css_class("server-entry");
|
||||||
server_entry.set_hexpand(true);
|
server_entry.set_hexpand(true);
|
||||||
@ -420,16 +421,15 @@ pub fn build_launcher_view(
|
|||||||
server_entry.set_tooltip_text(Some(
|
server_entry.set_tooltip_text(Some(
|
||||||
"Relay host address for previews, power control, and the live session.",
|
"Relay host address for previews, power control, and the live session.",
|
||||||
));
|
));
|
||||||
connection_body.append(&server_entry);
|
let relay_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
|
relay_row.set_halign(gtk::Align::Fill);
|
||||||
let relay_actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
relay_row.set_hexpand(true);
|
||||||
relay_actions_row.set_homogeneous(true);
|
relay_row.append(&server_entry);
|
||||||
let start_button = gtk::Button::with_label("Connect Relay");
|
let start_button = gtk::Button::with_label("Connect");
|
||||||
start_button.add_css_class("suggested-action");
|
start_button.add_css_class("suggested-action");
|
||||||
start_button.set_hexpand(true);
|
stabilize_button(&start_button, 92);
|
||||||
stabilize_button(&start_button, 180);
|
relay_row.append(&start_button);
|
||||||
relay_actions_row.append(&start_button);
|
connection_body.append(&relay_row);
|
||||||
connection_body.append(&relay_actions_row);
|
|
||||||
|
|
||||||
let live_actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
let live_actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
live_actions_row.set_homogeneous(true);
|
live_actions_row.set_homogeneous(true);
|
||||||
@ -457,22 +457,24 @@ pub fn build_launcher_view(
|
|||||||
connection_body.append(&live_actions_row);
|
connection_body.append(&live_actions_row);
|
||||||
|
|
||||||
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
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.add_css_class("subgroup-title");
|
||||||
power_heading.set_halign(gtk::Align::Start);
|
power_heading.set_halign(gtk::Align::Start);
|
||||||
connection_body.append(&power_heading);
|
|
||||||
|
|
||||||
let power_shell = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
let power_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
||||||
power_shell.set_halign(gtk::Align::Center);
|
power_shell.set_halign(gtk::Align::Fill);
|
||||||
let power_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
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");
|
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");
|
power_on_button.add_css_class("pill-toggle");
|
||||||
let power_auto_button = gtk::Button::with_label("Auto");
|
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");
|
power_auto_button.add_css_class("pill-toggle");
|
||||||
let power_off_button = gtk::Button::with_label("Off");
|
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");
|
power_off_button.add_css_class("pill-toggle");
|
||||||
let power_detail = gtk::Label::new(Some("Capture power status is loading..."));
|
let power_detail = gtk::Label::new(Some("Capture power status is loading..."));
|
||||||
power_detail.add_css_class("dim-label");
|
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_on_button);
|
||||||
power_row.append(&power_auto_button);
|
power_row.append(&power_auto_button);
|
||||||
power_row.append(&power_off_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);
|
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);
|
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.add_css_class("dim-label");
|
||||||
audio_gain_label.set_halign(gtk::Align::Start);
|
audio_gain_label.set_halign(gtk::Align::Start);
|
||||||
|
audio_gain_label.set_width_chars(5);
|
||||||
let audio_gain_adjustment = gtk::Adjustment::new(
|
let audio_gain_adjustment = gtk::Adjustment::new(
|
||||||
state.audio_gain_percent as f64,
|
state.audio_gain_percent as f64,
|
||||||
0.0,
|
0.0,
|
||||||
@ -515,23 +512,26 @@ pub fn build_launcher_view(
|
|||||||
audio_gain_row.append(&audio_gain_label);
|
audio_gain_row.append(&audio_gain_label);
|
||||||
audio_gain_row.append(&audio_gain_scale);
|
audio_gain_row.append(&audio_gain_scale);
|
||||||
audio_gain_row.append(&audio_gain_value);
|
audio_gain_row.append(&audio_gain_value);
|
||||||
connection_body.append(&audio_gain_row);
|
power_shell.append(&power_row);
|
||||||
let routing_heading = gtk::Label::new(Some("Input Routing"));
|
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.add_css_class("subgroup-title");
|
||||||
routing_heading.set_halign(gtk::Align::Start);
|
routing_heading.set_halign(gtk::Align::Start);
|
||||||
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
||||||
connection_body.append(&routing_heading);
|
|
||||||
|
|
||||||
let routing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
let routing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
routing_row.set_homogeneous(true);
|
routing_row.set_hexpand(true);
|
||||||
let input_toggle_button = gtk::Button::with_label("Change Routing");
|
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);
|
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(
|
input_toggle_button.set_tooltip_text(Some(
|
||||||
"Change live keyboard and mouse ownership between this machine and the remote target.",
|
"Change live keyboard and mouse ownership between this machine and the remote target.",
|
||||||
));
|
));
|
||||||
let swap_key_button = gtk::Button::with_label("Set Swap Key");
|
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(&input_toggle_button);
|
||||||
routing_row.append(&swap_key_button);
|
routing_row.append(&swap_key_button);
|
||||||
connection_body.append(&routing_row);
|
connection_body.append(&routing_row);
|
||||||
@ -744,6 +744,7 @@ pub fn build_launcher_view(
|
|||||||
audio_check_detail,
|
audio_check_detail,
|
||||||
audio_check_meter,
|
audio_check_meter,
|
||||||
display_panes: [left_pane.clone(), right_pane.clone()],
|
display_panes: [left_pane.clone(), right_pane.clone()],
|
||||||
|
server_entry: server_entry.clone(),
|
||||||
start_button: start_button.clone(),
|
start_button: start_button.clone(),
|
||||||
power_auto_button: power_auto_button.clone(),
|
power_auto_button: power_auto_button.clone(),
|
||||||
power_on_button: power_on_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);
|
.set_value(state.audio_gain_percent as f64);
|
||||||
}
|
}
|
||||||
widgets.audio_gain_value.set_text(&state.audio_gain_label());
|
widgets.audio_gain_value.set_text(&state.audio_gain_label());
|
||||||
widgets.start_button.set_label(if relay_live {
|
widgets
|
||||||
"Disconnect Relay"
|
.start_button
|
||||||
} else {
|
.set_label(if relay_live { "Disconnect" } else { "Connect" });
|
||||||
"Connect Relay"
|
|
||||||
});
|
|
||||||
widgets.start_button.set_sensitive(true);
|
widgets.start_button.set_sensitive(true);
|
||||||
|
widgets.server_entry.set_sensitive(!relay_live);
|
||||||
widgets.start_button.set_tooltip_text(Some(if 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."
|
"Disconnect from the relay host, stop the live session, and let capture fall back to grace/standby."
|
||||||
} else {
|
} else {
|
||||||
@ -933,6 +932,13 @@ pub fn spawn_client_process(
|
|||||||
command.stdout(Stdio::piped());
|
command.stdout(Stdio::piped());
|
||||||
command.stderr(Stdio::piped());
|
command.stderr(Stdio::piped());
|
||||||
command.env("LESAVKA_LAUNCHER_CHILD", "1");
|
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_SERVER_ADDR", server_addr);
|
||||||
command.env("LESAVKA_INPUT_TOGGLE_KEY", input_toggle_key);
|
command.env("LESAVKA_INPUT_TOGGLE_KEY", input_toggle_key);
|
||||||
command.env("LESAVKA_LAUNCHER_WINDOW_TITLE", "Lesavka");
|
command.env("LESAVKA_LAUNCHER_WINDOW_TITLE", "Lesavka");
|
||||||
|
|||||||
@ -86,6 +86,10 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
#[cfg(not(test))]
|
#[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)? {
|
if env::var("LESAVKA_LAUNCHER_CHILD").is_err() && launcher::maybe_run_launcher(&args)? {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.11.44"
|
version = "0.11.45"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@ -1,34 +1,39 @@
|
|||||||
{
|
{
|
||||||
"files": {
|
"files": {
|
||||||
"client/src/app.rs": {
|
"client/src/app.rs": {
|
||||||
"clippy_warnings": 42,
|
"clippy_warnings": 40,
|
||||||
"doc_debt": 12,
|
"doc_debt": 13,
|
||||||
"loc": 590
|
"loc": 808
|
||||||
},
|
},
|
||||||
"client/src/app_support.rs": {
|
"client/src/app_support.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 3,
|
"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": {
|
"client/src/handshake.rs": {
|
||||||
"clippy_warnings": 2,
|
"clippy_warnings": 2,
|
||||||
"doc_debt": 3,
|
"doc_debt": 5,
|
||||||
"loc": 215
|
"loc": 381
|
||||||
},
|
},
|
||||||
"client/src/input/camera.rs": {
|
"client/src/input/camera.rs": {
|
||||||
"clippy_warnings": 38,
|
"clippy_warnings": 30,
|
||||||
"doc_debt": 7,
|
"doc_debt": 8,
|
||||||
"loc": 372
|
"loc": 407
|
||||||
},
|
},
|
||||||
"client/src/input/inputs.rs": {
|
"client/src/input/inputs.rs": {
|
||||||
"clippy_warnings": 42,
|
"clippy_warnings": 40,
|
||||||
"doc_debt": 20,
|
"doc_debt": 27,
|
||||||
"loc": 871
|
"loc": 1166
|
||||||
},
|
},
|
||||||
"client/src/input/keyboard.rs": {
|
"client/src/input/keyboard.rs": {
|
||||||
"clippy_warnings": 26,
|
"clippy_warnings": 26,
|
||||||
"doc_debt": 22,
|
"doc_debt": 24,
|
||||||
"loc": 676
|
"loc": 705
|
||||||
},
|
},
|
||||||
"client/src/input/keymap.rs": {
|
"client/src/input/keymap.rs": {
|
||||||
"clippy_warnings": 8,
|
"clippy_warnings": 8,
|
||||||
@ -37,8 +42,8 @@
|
|||||||
},
|
},
|
||||||
"client/src/input/microphone.rs": {
|
"client/src/input/microphone.rs": {
|
||||||
"clippy_warnings": 17,
|
"clippy_warnings": 17,
|
||||||
"doc_debt": 2,
|
"doc_debt": 7,
|
||||||
"loc": 166
|
"loc": 210
|
||||||
},
|
},
|
||||||
"client/src/input/mod.rs": {
|
"client/src/input/mod.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -58,52 +63,52 @@
|
|||||||
"client/src/launcher/device_test.rs": {
|
"client/src/launcher/device_test.rs": {
|
||||||
"clippy_warnings": 43,
|
"clippy_warnings": 43,
|
||||||
"doc_debt": 29,
|
"doc_debt": 29,
|
||||||
"loc": 793
|
"loc": 799
|
||||||
},
|
},
|
||||||
"client/src/launcher/devices.rs": {
|
"client/src/launcher/devices.rs": {
|
||||||
"clippy_warnings": 6,
|
"clippy_warnings": 6,
|
||||||
"doc_debt": 6,
|
"doc_debt": 11,
|
||||||
"loc": 234
|
"loc": 348
|
||||||
},
|
},
|
||||||
"client/src/launcher/diagnostics.rs": {
|
"client/src/launcher/diagnostics.rs": {
|
||||||
"clippy_warnings": 17,
|
"clippy_warnings": 92,
|
||||||
"doc_debt": 3,
|
"doc_debt": 12,
|
||||||
"loc": 177
|
"loc": 1021
|
||||||
},
|
},
|
||||||
"client/src/launcher/mod.rs": {
|
"client/src/launcher/mod.rs": {
|
||||||
"clippy_warnings": 8,
|
"clippy_warnings": 8,
|
||||||
"doc_debt": 5,
|
"doc_debt": 7,
|
||||||
"loc": 268
|
"loc": 438
|
||||||
},
|
},
|
||||||
"client/src/launcher/power.rs": {
|
"client/src/launcher/power.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 1,
|
"doc_debt": 2,
|
||||||
"loc": 69
|
"loc": 86
|
||||||
},
|
},
|
||||||
"client/src/launcher/preview.rs": {
|
"client/src/launcher/preview.rs": {
|
||||||
"clippy_warnings": 36,
|
"clippy_warnings": 93,
|
||||||
"doc_debt": 26,
|
"doc_debt": 56,
|
||||||
"loc": 1030
|
"loc": 2216
|
||||||
},
|
},
|
||||||
"client/src/launcher/state.rs": {
|
"client/src/launcher/state.rs": {
|
||||||
"clippy_warnings": 64,
|
"clippy_warnings": 154,
|
||||||
"doc_debt": 36,
|
"doc_debt": 54,
|
||||||
"loc": 951
|
"loc": 1377
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui.rs": {
|
"client/src/launcher/ui.rs": {
|
||||||
"clippy_warnings": 42,
|
"clippy_warnings": 62,
|
||||||
"doc_debt": 12,
|
"doc_debt": 23,
|
||||||
"loc": 1501
|
"loc": 2341
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_components.rs": {
|
"client/src/launcher/ui_components.rs": {
|
||||||
"clippy_warnings": 6,
|
"clippy_warnings": 16,
|
||||||
"doc_debt": 10,
|
"doc_debt": 15,
|
||||||
"loc": 973
|
"loc": 1349
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_runtime.rs": {
|
"client/src/launcher/ui_runtime.rs": {
|
||||||
"clippy_warnings": 36,
|
"clippy_warnings": 62,
|
||||||
"doc_debt": 35,
|
"doc_debt": 44,
|
||||||
"loc": 1177
|
"loc": 1698
|
||||||
},
|
},
|
||||||
"client/src/layout.rs": {
|
"client/src/layout.rs": {
|
||||||
"clippy_warnings": 6,
|
"clippy_warnings": 6,
|
||||||
@ -113,17 +118,17 @@
|
|||||||
"client/src/lib.rs": {
|
"client/src/lib.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 14
|
"loc": 19
|
||||||
},
|
},
|
||||||
"client/src/main.rs": {
|
"client/src/main.rs": {
|
||||||
"clippy_warnings": 2,
|
"clippy_warnings": 2,
|
||||||
"doc_debt": 2,
|
"doc_debt": 2,
|
||||||
"loc": 96
|
"loc": 100
|
||||||
},
|
},
|
||||||
"client/src/output/audio.rs": {
|
"client/src/output/audio.rs": {
|
||||||
"clippy_warnings": 37,
|
"clippy_warnings": 11,
|
||||||
"doc_debt": 5,
|
"doc_debt": 12,
|
||||||
"loc": 195
|
"loc": 371
|
||||||
},
|
},
|
||||||
"client/src/output/display.rs": {
|
"client/src/output/display.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -142,14 +147,19 @@
|
|||||||
},
|
},
|
||||||
"client/src/output/video.rs": {
|
"client/src/output/video.rs": {
|
||||||
"clippy_warnings": 36,
|
"clippy_warnings": 36,
|
||||||
"doc_debt": 4,
|
"doc_debt": 5,
|
||||||
"loc": 547
|
"loc": 585
|
||||||
},
|
},
|
||||||
"client/src/paste.rs": {
|
"client/src/paste.rs": {
|
||||||
"clippy_warnings": 2,
|
"clippy_warnings": 2,
|
||||||
"doc_debt": 1,
|
"doc_debt": 1,
|
||||||
"loc": 82
|
"loc": 82
|
||||||
},
|
},
|
||||||
|
"client/src/video_support.rs": {
|
||||||
|
"clippy_warnings": 0,
|
||||||
|
"doc_debt": 1,
|
||||||
|
"loc": 45
|
||||||
|
},
|
||||||
"common/src/bin/cli.rs": {
|
"common/src/bin/cli.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
@ -160,6 +170,11 @@
|
|||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 22
|
"loc": 22
|
||||||
},
|
},
|
||||||
|
"common/src/eye_source.rs": {
|
||||||
|
"clippy_warnings": 10,
|
||||||
|
"doc_debt": 4,
|
||||||
|
"loc": 114
|
||||||
|
},
|
||||||
"common/src/hid.rs": {
|
"common/src/hid.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 2,
|
"doc_debt": 2,
|
||||||
@ -168,26 +183,32 @@
|
|||||||
"common/src/lib.rs": {
|
"common/src/lib.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 22
|
"loc": 24
|
||||||
},
|
},
|
||||||
"common/src/paste.rs": {
|
"common/src/paste.rs": {
|
||||||
"clippy_warnings": 2,
|
"clippy_warnings": 2,
|
||||||
"doc_debt": 2,
|
"doc_debt": 2,
|
||||||
"loc": 132
|
"loc": 132
|
||||||
},
|
},
|
||||||
|
"common/src/process_metrics.rs": {
|
||||||
|
"clippy_warnings": 12,
|
||||||
|
"doc_debt": 4,
|
||||||
|
"loc": 105
|
||||||
|
},
|
||||||
"server/src/audio.rs": {
|
"server/src/audio.rs": {
|
||||||
"clippy_warnings": 37,
|
"clippy_warnings": 43,
|
||||||
"doc_debt": 7,
|
"doc_debt": 13,
|
||||||
"loc": 397
|
"loc": 680
|
||||||
},
|
},
|
||||||
"server/src/bin/lesavka-uvc.real.inc": {
|
"server/src/bin/lesavka-uvc.real.inc": {
|
||||||
"clippy_warnings": 31,
|
"clippy_warnings": 33,
|
||||||
"doc_debt": 0
|
"doc_debt": 0,
|
||||||
|
"loc": 0
|
||||||
},
|
},
|
||||||
"server/src/bin/lesavka-uvc.rs": {
|
"server/src/bin/lesavka-uvc.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 17,
|
"doc_debt": 17,
|
||||||
"loc": 710
|
"loc": 712
|
||||||
},
|
},
|
||||||
"server/src/camera.rs": {
|
"server/src/camera.rs": {
|
||||||
"clippy_warnings": 12,
|
"clippy_warnings": 12,
|
||||||
@ -202,37 +223,37 @@
|
|||||||
"server/src/capture_power.rs": {
|
"server/src/capture_power.rs": {
|
||||||
"clippy_warnings": 12,
|
"clippy_warnings": 12,
|
||||||
"doc_debt": 10,
|
"doc_debt": 10,
|
||||||
"loc": 513
|
"loc": 537
|
||||||
},
|
},
|
||||||
"server/src/gadget.rs": {
|
"server/src/gadget.rs": {
|
||||||
"clippy_warnings": 30,
|
"clippy_warnings": 52,
|
||||||
"doc_debt": 7,
|
"doc_debt": 12,
|
||||||
"loc": 327
|
"loc": 513
|
||||||
},
|
},
|
||||||
"server/src/handshake.rs": {
|
"server/src/handshake.rs": {
|
||||||
"clippy_warnings": 2,
|
"clippy_warnings": 2,
|
||||||
"doc_debt": 1,
|
"doc_debt": 1,
|
||||||
"loc": 44
|
"loc": 45
|
||||||
},
|
},
|
||||||
"server/src/lib.rs": {
|
"server/src/lib.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 14
|
"loc": 18
|
||||||
},
|
},
|
||||||
"server/src/main.rs": {
|
"server/src/main.rs": {
|
||||||
"clippy_warnings": 10,
|
"clippy_warnings": 23,
|
||||||
"doc_debt": 13,
|
"doc_debt": 21,
|
||||||
"loc": 586
|
"loc": 952
|
||||||
},
|
},
|
||||||
"server/src/paste.rs": {
|
"server/src/paste.rs": {
|
||||||
"clippy_warnings": 8,
|
"clippy_warnings": 8,
|
||||||
"doc_debt": 4,
|
"doc_debt": 4,
|
||||||
"loc": 255
|
"loc": 260
|
||||||
},
|
},
|
||||||
"server/src/runtime_support.rs": {
|
"server/src/runtime_support.rs": {
|
||||||
"clippy_warnings": 14,
|
"clippy_warnings": 22,
|
||||||
"doc_debt": 8,
|
"doc_debt": 20,
|
||||||
"loc": 397
|
"loc": 729
|
||||||
},
|
},
|
||||||
"server/src/uvc_control/model.rs": {
|
"server/src/uvc_control/model.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -250,9 +271,9 @@
|
|||||||
"loc": 241
|
"loc": 241
|
||||||
},
|
},
|
||||||
"server/src/video.rs": {
|
"server/src/video.rs": {
|
||||||
"clippy_warnings": 33,
|
"clippy_warnings": 53,
|
||||||
"doc_debt": 8,
|
"doc_debt": 12,
|
||||||
"loc": 589
|
"loc": 840
|
||||||
},
|
},
|
||||||
"server/src/video_sinks.rs": {
|
"server/src/video_sinks.rs": {
|
||||||
"clippy_warnings": 78,
|
"clippy_warnings": 78,
|
||||||
|
|||||||
@ -82,7 +82,10 @@ publish_metrics() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
status=0
|
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'
|
if python3 - "${COVERAGE_JSON}" "${BASELINE_JSON}" "${METRICS_FILE}" "${SUMMARY_TXT}" "${ROOT_DIR}" "${COVERAGE_CONTRACT_JSON}" <<'PY'
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|||||||
@ -1,93 +1,101 @@
|
|||||||
{
|
{
|
||||||
"files": {
|
"files": {
|
||||||
"client/src/app.rs": {
|
"client/src/app.rs": {
|
||||||
"line_percent": 96.61016949152543,
|
"line_percent": 97.4,
|
||||||
"loc": 590
|
"loc": 808
|
||||||
},
|
},
|
||||||
"client/src/app_support.rs": {
|
"client/src/app_support.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 131
|
"loc": 132
|
||||||
|
},
|
||||||
|
"client/src/bin/lesavka-relayctl.rs": {
|
||||||
|
"line_percent": 0.0,
|
||||||
|
"loc": 140
|
||||||
},
|
},
|
||||||
"client/src/handshake.rs": {
|
"client/src/handshake.rs": {
|
||||||
"line_percent": 96.36363636363636,
|
"line_percent": 57.39,
|
||||||
"loc": 215
|
"loc": 381
|
||||||
},
|
},
|
||||||
"client/src/input/camera.rs": {
|
"client/src/input/camera.rs": {
|
||||||
"line_percent": 98.42931937172776,
|
"line_percent": 97.99,
|
||||||
"loc": 372
|
"loc": 407
|
||||||
},
|
},
|
||||||
"client/src/input/inputs.rs": {
|
"client/src/input/inputs.rs": {
|
||||||
"line_percent": 97.12793733681463,
|
"line_percent": 96.39,
|
||||||
"loc": 871
|
"loc": 1166
|
||||||
},
|
},
|
||||||
"client/src/input/keyboard.rs": {
|
"client/src/input/keyboard.rs": {
|
||||||
"line_percent": 91.76136363636364,
|
"line_percent": 91.5,
|
||||||
"loc": 676
|
"loc": 705
|
||||||
},
|
},
|
||||||
"client/src/input/keymap.rs": {
|
"client/src/input/keymap.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 196
|
"loc": 196
|
||||||
},
|
},
|
||||||
"client/src/input/microphone.rs": {
|
"client/src/input/microphone.rs": {
|
||||||
"line_percent": 95.94594594594594,
|
"line_percent": 89.81,
|
||||||
"loc": 166
|
"loc": 210
|
||||||
},
|
},
|
||||||
"client/src/input/mouse.rs": {
|
"client/src/input/mouse.rs": {
|
||||||
"line_percent": 97.32142857142857,
|
"line_percent": 97.32,
|
||||||
"loc": 317
|
"loc": 317
|
||||||
},
|
},
|
||||||
"client/src/launcher/clipboard.rs": {
|
"client/src/launcher/clipboard.rs": {
|
||||||
"line_percent": 96.22641509433963,
|
"line_percent": 96.23,
|
||||||
"loc": 178
|
"loc": 178
|
||||||
},
|
},
|
||||||
"client/src/launcher/devices.rs": {
|
"client/src/launcher/devices.rs": {
|
||||||
"line_percent": 96.25,
|
"line_percent": 95.7,
|
||||||
"loc": 234
|
"loc": 348
|
||||||
},
|
},
|
||||||
"client/src/launcher/diagnostics.rs": {
|
"client/src/launcher/diagnostics.rs": {
|
||||||
"line_percent": 97.19626168224299,
|
"line_percent": 84.3,
|
||||||
"loc": 177
|
"loc": 1021
|
||||||
},
|
},
|
||||||
"client/src/launcher/mod.rs": {
|
"client/src/launcher/mod.rs": {
|
||||||
"line_percent": 93.61702127659576,
|
"line_percent": 82.32,
|
||||||
"loc": 268
|
"loc": 438
|
||||||
},
|
},
|
||||||
"client/src/launcher/state.rs": {
|
"client/src/launcher/state.rs": {
|
||||||
"line_percent": 90.42553191489363,
|
"line_percent": 84.01,
|
||||||
"loc": 951
|
"loc": 1377
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui.rs": {
|
"client/src/launcher/ui.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 1501
|
"loc": 2341
|
||||||
},
|
},
|
||||||
"client/src/layout.rs": {
|
"client/src/layout.rs": {
|
||||||
"line_percent": 97.72727272727273,
|
"line_percent": 97.73,
|
||||||
"loc": 78
|
"loc": 78
|
||||||
},
|
},
|
||||||
"client/src/main.rs": {
|
"client/src/main.rs": {
|
||||||
"line_percent": 97.0873786407767,
|
"line_percent": 97.2,
|
||||||
"loc": 96
|
"loc": 100
|
||||||
},
|
},
|
||||||
"client/src/output/audio.rs": {
|
"client/src/output/audio.rs": {
|
||||||
"line_percent": 98.78048780487805,
|
"line_percent": 76.92,
|
||||||
"loc": 195
|
"loc": 371
|
||||||
},
|
},
|
||||||
"client/src/output/display.rs": {
|
"client/src/output/display.rs": {
|
||||||
"line_percent": 97.61904761904762,
|
"line_percent": 97.62,
|
||||||
"loc": 81
|
"loc": 81
|
||||||
},
|
},
|
||||||
"client/src/output/layout.rs": {
|
"client/src/output/layout.rs": {
|
||||||
"line_percent": 98.9795918367347,
|
"line_percent": 98.98,
|
||||||
"loc": 155
|
"loc": 155
|
||||||
},
|
},
|
||||||
"client/src/output/video.rs": {
|
"client/src/output/video.rs": {
|
||||||
"line_percent": 96.22641509433963,
|
"line_percent": 95.52,
|
||||||
"loc": 547
|
"loc": 585
|
||||||
},
|
},
|
||||||
"client/src/paste.rs": {
|
"client/src/paste.rs": {
|
||||||
"line_percent": 98.27586206896551,
|
"line_percent": 98.28,
|
||||||
"loc": 82
|
"loc": 82
|
||||||
},
|
},
|
||||||
|
"client/src/video_support.rs": {
|
||||||
|
"line_percent": 0.0,
|
||||||
|
"loc": 45
|
||||||
|
},
|
||||||
"common/src/bin/cli.rs": {
|
"common/src/bin/cli.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 3
|
"loc": 3
|
||||||
@ -96,28 +104,36 @@
|
|||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 22
|
"loc": 22
|
||||||
},
|
},
|
||||||
|
"common/src/eye_source.rs": {
|
||||||
|
"line_percent": 100.0,
|
||||||
|
"loc": 114
|
||||||
|
},
|
||||||
"common/src/hid.rs": {
|
"common/src/hid.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 134
|
"loc": 134
|
||||||
},
|
},
|
||||||
"common/src/lib.rs": {
|
"common/src/lib.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 22
|
"loc": 24
|
||||||
},
|
},
|
||||||
"common/src/paste.rs": {
|
"common/src/paste.rs": {
|
||||||
"line_percent": 97.05882352941178,
|
"line_percent": 97.06,
|
||||||
"loc": 132
|
"loc": 132
|
||||||
},
|
},
|
||||||
|
"common/src/process_metrics.rs": {
|
||||||
|
"line_percent": 89.55,
|
||||||
|
"loc": 105
|
||||||
|
},
|
||||||
"server/src/audio.rs": {
|
"server/src/audio.rs": {
|
||||||
"line_percent": 98.96907216494846,
|
"line_percent": 98.97,
|
||||||
"loc": 397
|
"loc": 680
|
||||||
},
|
},
|
||||||
"server/src/bin/lesavka-uvc.rs": {
|
"server/src/bin/lesavka-uvc.rs": {
|
||||||
"line_percent": 96.35535307517085,
|
"line_percent": 95.92,
|
||||||
"loc": 710
|
"loc": 712
|
||||||
},
|
},
|
||||||
"server/src/camera.rs": {
|
"server/src/camera.rs": {
|
||||||
"line_percent": 99.09909909909909,
|
"line_percent": 99.1,
|
||||||
"loc": 392
|
"loc": 392
|
||||||
},
|
},
|
||||||
"server/src/camera_runtime.rs": {
|
"server/src/camera_runtime.rs": {
|
||||||
@ -126,42 +142,42 @@
|
|||||||
},
|
},
|
||||||
"server/src/capture_power.rs": {
|
"server/src/capture_power.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 513
|
"loc": 537
|
||||||
},
|
},
|
||||||
"server/src/gadget.rs": {
|
"server/src/gadget.rs": {
|
||||||
"line_percent": 96.875,
|
"line_percent": 91.12,
|
||||||
"loc": 327
|
"loc": 513
|
||||||
},
|
},
|
||||||
"server/src/handshake.rs": {
|
"server/src/handshake.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 44
|
"loc": 45
|
||||||
},
|
},
|
||||||
"server/src/main.rs": {
|
"server/src/main.rs": {
|
||||||
"line_percent": 95.54140127388536,
|
"line_percent": 79.34,
|
||||||
"loc": 586
|
"loc": 952
|
||||||
},
|
},
|
||||||
"server/src/paste.rs": {
|
"server/src/paste.rs": {
|
||||||
"line_percent": 96.21621621621622,
|
"line_percent": 96.32,
|
||||||
"loc": 255
|
"loc": 260
|
||||||
},
|
},
|
||||||
"server/src/runtime_support.rs": {
|
"server/src/runtime_support.rs": {
|
||||||
"line_percent": 96.42857142857143,
|
"line_percent": 90.99,
|
||||||
"loc": 397
|
"loc": 729
|
||||||
},
|
},
|
||||||
"server/src/uvc_runtime.rs": {
|
"server/src/uvc_runtime.rs": {
|
||||||
"line_percent": 97.14285714285714,
|
"line_percent": 97.14,
|
||||||
"loc": 241
|
"loc": 241
|
||||||
},
|
},
|
||||||
"server/src/video.rs": {
|
"server/src/video.rs": {
|
||||||
"line_percent": 79.16666666666666,
|
"line_percent": 96.55,
|
||||||
"loc": 589
|
"loc": 840
|
||||||
},
|
},
|
||||||
"server/src/video_sinks.rs": {
|
"server/src/video_sinks.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 559
|
"loc": 559
|
||||||
},
|
},
|
||||||
"server/src/video_support.rs": {
|
"server/src/video_support.rs": {
|
||||||
"line_percent": 96.03174603174604,
|
"line_percent": 97.62,
|
||||||
"loc": 236
|
"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]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.11.44"
|
version = "0.11.45"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
@ -408,260 +408,4 @@ mod inputs_contract {
|
|||||||
"pending-release flow should clear pending flag"
|
"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}"))
|
.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]
|
#[test]
|
||||||
fn launcher_default_size_stays_inside_1080p() {
|
fn launcher_default_size_stays_inside_1080p() {
|
||||||
assert_eq!(const_i32("LAUNCHER_DEFAULT_WIDTH"), 1360);
|
assert_eq!(const_i32("LAUNCHER_DEFAULT_WIDTH"), 1360);
|
||||||
@ -43,6 +49,7 @@ fn eye_panes_keep_the_locked_larger_preview_footprint() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn device_staging_and_testing_bottoms_stay_locked_together() {
|
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_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("devices_panel.set_valign(gtk::Align::Fill);"));
|
||||||
assert!(UI_SRC.contains("preview_panel.set_valign(gtk::Align::Fill);"));
|
assert!(UI_SRC.contains("preview_panel.set_valign(gtk::Align::Fill);"));
|
||||||
assert!(UI_SRC.contains(
|
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_eq!(const_i32("CAMERA_PREVIEW_VIEWPORT_HEIGHT"), 158);
|
||||||
assert!(UI_SRC.contains("webcam_group.set_valign(gtk::Align::Fill);"));
|
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("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("playback_body.set_valign(gtk::Align::Fill);"));
|
||||||
assert!(UI_SRC.contains("audio_check_meter.set_vexpand(true);"));
|
assert!(UI_SRC.contains("audio_check_meter.set_vexpand(true);"));
|
||||||
assert!(UI_SRC.contains("playback_body.append(&audio_check_meter);"));
|
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]
|
#[test]
|
||||||
fn remote_audio_gain_control_stays_in_the_operations_rail() {
|
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("let audio_gain_scale ="));
|
||||||
assert!(UI_SRC.contains("audio_gain_scale.set_draw_value(false);"));
|
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("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();
|
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