diff --git a/Cargo.lock b/Cargo.lock index 43d867f..94c2bb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.21.17" +version = "0.22.1" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.21.17" +version = "0.22.1" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.21.17" +version = "0.22.1" dependencies = [ "anyhow", "base64", @@ -1728,7 +1728,7 @@ dependencies = [ ] [[package]] -name = "lesavka_testing" +name = "lesavka_tests" version = "0.1.0" dependencies = [ "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 5c9acf4..3c61d83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,20 @@ +[package] +name = "lesavka_tests" +version = "0.1.0" +edition = "2024" +publish = false +build = "build.rs" +autotests = false + +[lib] +name = "lesavka_tests" +path = "tests/lib.rs" + [workspace] members = [ "common", "client", "server", - "testing", ] resolver = "3" @@ -11,3 +22,653 @@ resolver = "3" serial_test = "3.2" tempfile = "3.15" temp-env = "0.3" + +[features] +# Virtual HID tests create uinput keyboards/mice and can leak events into the +# active desktop. Keep them opt-in so default local and CI gates stay safe. +disruptive-input-tests = [] + +[dev-dependencies] +anyhow = "1.0" +async-stream = "0.3" +base64 = "0.22" +chrono = "0.4" +evdev = "0.13" +futures-util = "0.3" +libc = "0.2" +lesavka_client = { path = "client" } +lesavka_common = { path = "common" } +lesavka_server = { path = "server" } +chacha20poly1305 = "0.10" +gstreamer = { version = "0.23", features = ["v1_22"] } +gstreamer-app = { version = "0.23", features = ["v1_22"] } +gstreamer-video = { version = "0.23", features = ["v1_22"] } +gtk = { version = "0.8", package = "gtk4", features = ["v4_6"] } +winit = "0.30" +serial_test = { workspace = true } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +shell-escape = "0.1" +temp-env = { workspace = true } +tempfile = { workspace = true } +tokio = { version = "1.45", features = ["full", "macros", "rt-multi-thread", "sync", "time"] } +tokio-stream = "0.1" +tonic = { version = "0.13", features = ["transport"] } +tonic-reflection = "0.13" +tracing = "0.1" +tracing-appender = "0.2" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "ansi"] } +udev = "0.8" +v4l = "0.14" + +[[test]] +name = "client_relayctl_binary_contract" +path = "tests/api/client/bin/lesavka_relayctl/client_relayctl_binary_contract.rs" + +[[test]] +name = "client_relayctl_process_contract" +path = "tests/api/client/bin/lesavka_relayctl/client_relayctl_process_contract.rs" + +[[test]] +name = "client_keyboard_paste_rpc_contract" +path = "tests/api/client/input/keyboard/client_keyboard_paste_rpc_contract.rs" +required-features = ["disruptive-input-tests"] + +[[test]] +name = "common_cli_binary_contract" +path = "tests/api/common/cli/common_cli_binary_contract.rs" + +[[test]] +name = "common_cli_contract" +path = "tests/api/common/cli/common_cli_contract.rs" + +[[test]] +name = "handshake_camera_contract" +path = "tests/api/common/handshake/handshake_camera_contract.rs" + +[[test]] +name = "server_main_rpc_contract" +path = "tests/api/server/main/server_main_rpc_contract.rs" + +[[test]] +name = "server_main_rpc_reset_contract" +path = "tests/api/server/main/server_main_rpc_reset_contract.rs" + +[[test]] +name = "server_main_state_rpc_contract" +path = "tests/api/server/main/server_main_state_rpc_contract.rs" + +[[test]] +name = "server_auth_rpc_contract" +path = "tests/api/server/auth/server_auth_rpc_contract.rs" + +[[test]] +name = "server_upstream_media_audio_contract" +path = "tests/api/server/upstream_media_runtime/server_upstream_media_audio_contract.rs" + +[[test]] +name = "server_upstream_media_bundle_contract" +path = "tests/api/server/upstream_media_runtime/server_upstream_media_bundle_contract.rs" + +[[test]] +name = "server_upstream_media_contract" +path = "tests/api/server/upstream_media_runtime/server_upstream_media_contract.rs" + +[[test]] +name = "server_upstream_media_pairing_contract" +path = "tests/api/server/upstream_media_runtime/server_upstream_media_pairing_contract.rs" + +[[test]] +name = "server_upstream_media_v2_handoff_contract" +path = "tests/api/server/upstream_media_runtime/server_upstream_media_v2_handoff_contract.rs" + +[[test]] +name = "server_upstream_media_video_contract" +path = "tests/api/server/upstream_media_runtime/server_upstream_media_video_contract.rs" + +[[test]] +name = "uplink_backpressure_chaos_contract" +path = "tests/chaos/client/uplink/uplink_backpressure_chaos_contract.rs" + +[[test]] +name = "hevc_mjpeg_guard_chaos_contract" +path = "tests/chaos/server/video_sinks/hevc_mjpeg_guard_chaos_contract.rs" + +[[test]] +name = "client_server_rct_route_chaos_contract" +path = "tests/chaos/client/server/rct/client_server_rct_route_chaos_contract.rs" + +[[test]] +name = "input_disconnect_chaos_contract" +path = "tests/chaos/input/input_disconnect_chaos_contract.rs" + +[[test]] +name = "input_packet_ordering_chaos_contract" +path = "tests/chaos/input/input_packet_ordering_chaos_contract.rs" + +[[test]] +name = "downstream_video_stall_chaos_contract" +path = "tests/chaos/downstream/video/downstream_video_stall_chaos_contract.rs" + +[[test]] +name = "interrupted_install_safe_state_contract" +path = "tests/chaos/system/interrupted_install_safe_state_contract.rs" + +[[test]] +name = "client_video_support_include_contract" +path = "tests/compatibility/client/video_support/client_video_support_include_contract.rs" + +[[test]] +name = "client_opus_transport_contract" +path = "tests/compatibility/client/audio/client_opus_transport_contract.rs" + +[[test]] +name = "server_camera_runtime_contract" +path = "tests/compatibility/server/camera/server_camera_runtime_contract.rs" + +[[test]] +name = "server_uvc_runtime_contract" +path = "tests/compatibility/server/uvc/server_uvc_runtime_contract.rs" + +[[test]] +name = "hevc_mjpeg_profile_matrix_contract" +path = "tests/compatibility/server/video/hevc_mjpeg_profile_matrix_contract.rs" + +[[test]] +name = "video_support_contract" +path = "tests/compatibility/video/video_support_contract.rs" + +[[test]] +name = "client_uplink_component_contract" +path = "tests/component/client/uplink/client_uplink_component_contract.rs" + +[[test]] +name = "client_app_include_contract" +path = "tests/contract/client/app/client_app_include_contract.rs" + +[[test]] +name = "client_app_process_contract" +path = "tests/contract/client/app/client_app_process_contract.rs" + +[[test]] +name = "client_camera_include_contract" +path = "tests/contract/client/input/camera/client_camera_include_contract.rs" + +[[test]] +name = "client_camera_timing_contract" +path = "tests/contract/client/input/camera/client_camera_timing_contract.rs" + +[[test]] +name = "client_inputs_contract" +path = "tests/contract/client/input/inputs/client_inputs_contract.rs" +required-features = ["disruptive-input-tests"] + +[[test]] +name = "client_inputs_extra_contract" +path = "tests/contract/client/input/inputs/client_inputs_extra_contract.rs" +required-features = ["disruptive-input-tests"] + +[[test]] +name = "client_inputs_routing_contract" +path = "tests/contract/client/input/inputs/client_inputs_routing_contract.rs" +required-features = ["disruptive-input-tests"] + +[[test]] +name = "client_keyboard_clipboard_contract" +path = "tests/contract/client/input/keyboard/client_keyboard_clipboard_contract.rs" + +[[test]] +name = "client_keyboard_include_contract" +path = "tests/contract/client/input/keyboard/client_keyboard_include_contract.rs" +required-features = ["disruptive-input-tests"] + +[[test]] +name = "client_keyboard_include_extra_contract" +path = "tests/contract/client/input/keyboard/client_keyboard_include_extra_contract.rs" +required-features = ["disruptive-input-tests"] + +[[test]] +name = "client_keyboard_process_contract" +path = "tests/contract/client/input/keyboard/client_keyboard_process_contract.rs" +required-features = ["disruptive-input-tests"] + +[[test]] +name = "client_keyboard_shift_contract" +path = "tests/contract/client/input/keyboard/client_keyboard_shift_contract.rs" +required-features = ["disruptive-input-tests"] + +[[test]] +name = "client_keymap_contract" +path = "tests/contract/client/input/keyboard/client_keymap_contract.rs" + +[[test]] +name = "client_microphone_gain_control_contract" +path = "tests/contract/client/input/microphone/client_microphone_gain_control_contract.rs" + +[[test]] +name = "client_microphone_include_contract" +path = "tests/contract/client/input/microphone/client_microphone_include_contract.rs" + +[[test]] +name = "client_microphone_requested_source_contract" +path = "tests/contract/client/input/microphone/client_microphone_requested_source_contract.rs" + +[[test]] +name = "client_microphone_source_contract" +path = "tests/contract/client/input/microphone/client_microphone_source_contract.rs" + +[[test]] +name = "client_microphone_startup_contract" +path = "tests/contract/client/input/microphone/client_microphone_startup_contract.rs" + +[[test]] +name = "client_microphone_tap_contract" +path = "tests/contract/client/input/microphone/client_microphone_tap_contract.rs" + +[[test]] +name = "client_mouse_include_contract" +path = "tests/contract/client/input/mouse/client_mouse_include_contract.rs" +required-features = ["disruptive-input-tests"] + +[[test]] +name = "client_mouse_include_extra_contract" +path = "tests/contract/client/input/mouse/client_mouse_include_extra_contract.rs" +required-features = ["disruptive-input-tests"] + +[[test]] +name = "client_mouse_uinput_contract" +path = "tests/contract/client/input/mouse/client_mouse_uinput_contract.rs" +required-features = ["disruptive-input-tests"] + +[[test]] +name = "client_main_binary_contract" +path = "tests/contract/client/main/client_main_binary_contract.rs" + +[[test]] +name = "client_main_process_contract" +path = "tests/contract/client/main/client_main_process_contract.rs" + +[[test]] +name = "client_output_audio_include_contract" +path = "tests/contract/client/output/audio/client_output_audio_include_contract.rs" + +[[test]] +name = "client_output_display_include_contract" +path = "tests/contract/client/output/display/client_output_display_include_contract.rs" + +[[test]] +name = "client_output_video_include_contract" +path = "tests/contract/client/output/video/client_output_video_include_contract.rs" + +[[test]] +name = "client_paste_contract" +path = "tests/contract/client/paste/client_paste_contract.rs" + +[[test]] +name = "client_browser_sync_script_contract" +path = "tests/contract/client/sync_probe/client_browser_sync_script_contract.rs" + +[[test]] +name = "client_hevc_bundle_audit_contract" +path = "tests/contract/client/sync_probe/client_hevc_bundle_audit_contract.rs" + +[[test]] +name = "shared_hid_contract" +path = "tests/contract/common/hid/shared_hid_contract.rs" + +[[test]] +name = "server_core_script_contract" +path = "tests/contract/scripts/daemon/server_core_script_contract.rs" + +[[test]] +name = "server_audio_include_contract" +path = "tests/contract/server/audio/server_audio_include_contract.rs" + +[[test]] +name = "server_opus_uac_contract" +path = "tests/contract/server/audio/server_opus_uac_contract.rs" + +[[test]] +name = "server_camera_contract" +path = "tests/contract/server/camera/server_camera_contract.rs" + +[[test]] +name = "server_gadget_include_contract" +path = "tests/contract/server/gadget/server_gadget_include_contract.rs" + +[[test]] +name = "server_main_binary_contract" +path = "tests/contract/server/main/server_main_binary_contract.rs" + +[[test]] +name = "server_main_binary_extra_contract" +path = "tests/contract/server/main/server_main_binary_extra_contract.rs" + +[[test]] +name = "server_main_eye_hub_contract" +path = "tests/contract/server/main/server_main_eye_hub_contract.rs" + +[[test]] +name = "server_main_media_extra_contract" +path = "tests/contract/server/main/server_main_media_extra_contract.rs" + +[[test]] +name = "server_main_process_contract" +path = "tests/contract/server/main/server_main_process_contract.rs" + +[[test]] +name = "server_runtime_contract" +path = "tests/contract/server/runtime_support/server_runtime_contract.rs" + +[[test]] +name = "server_uvc_binary_contract" +path = "tests/contract/server/uvc/server_uvc_binary_contract.rs" + +[[test]] +name = "server_uvc_binary_extra_contract" +path = "tests/contract/server/uvc/server_uvc_binary_extra_contract.rs" + +[[test]] +name = "server_uvc_process_contract" +path = "tests/contract/server/uvc/server_uvc_process_contract.rs" + +[[test]] +name = "server_uvc_script_contract" +path = "tests/contract/server/uvc/server_uvc_script_contract.rs" + +[[test]] +name = "server_video_include_contract" +path = "tests/contract/server/video/server_video_include_contract.rs" + +[[test]] +name = "downstream_video_mode_decoder_matrix_contract" +path = "tests/compatibility/downstream/video/downstream_video_mode_decoder_matrix_contract.rs" + +[[test]] +name = "server_video_sinks_include_contract" +path = "tests/contract/server/video_sinks/server_video_sinks_include_contract.rs" + +[[test]] +name = "report_schema_contract" +path = "tests/contract/diagnostics/report_schema_contract.rs" + +[[test]] +name = "quality_ratchet_evidence_contract" +path = "tests/contract/testing/quality_ratchet_evidence_contract.rs" + +[[test]] +name = "upstream_media_e2e_contract" +path = "tests/e2e/scripts/manual/upstream_media_e2e_contract.rs" + +[[test]] +name = "client_rct_transport_summary_golden_contract" +path = "tests/golden/diagnostics/client_rct_transport_summary_golden_contract.rs" + +[[test]] +name = "client_server_rct_blind_route_e2e_contract" +path = "tests/e2e/client/server/rct/client_server_rct_blind_route_e2e_contract.rs" + +[[test]] +name = "server_rct_output_probe_e2e_contract" +path = "tests/e2e/server/rct/server_rct_output_probe_e2e_contract.rs" + +[[test]] +name = "client_install_script_contract" +path = "tests/installer/scripts/install/client_install_script_contract.rs" + +[[test]] +name = "server_install_script_contract" +path = "tests/installer/scripts/install/server_install_script_contract.rs" + +[[test]] +name = "install_version_path_contract" +path = "tests/installer/scripts/install/install_version_path_contract.rs" + +[[test]] +name = "relay_proto_integration_contract" +path = "tests/integration/common/proto/relay_proto_integration_contract.rs" + +[[test]] +name = "relay_opus_proto_integration_contract" +path = "tests/integration/common/proto/relay_opus_proto_integration_contract.rs" + +[[test]] +name = "client_server_upstream_bundle_integration" +path = "tests/integration/client/server/upstream/client_server_upstream_bundle_integration.rs" + +[[test]] +name = "client_server_audio_epoch_integration" +path = "tests/integration/client/server/audio/client_server_audio_epoch_integration.rs" + +[[test]] +name = "client_live_media_control_integration" +path = "tests/integration/client/runtime_controls/client_live_media_control_integration.rs" + +[[test]] +name = "client_server_input_stream_integration" +path = "tests/integration/client/server/input/client_server_input_stream_integration.rs" + +[[test]] +name = "hevc_mjpeg_spool_integration" +path = "tests/integration/server/video_sinks/hevc_mjpeg_spool_integration.rs" + +[[test]] +name = "client_manual_sync_script_contract" +path = "tests/manual/client/sync_probe/client_manual_sync_script_contract.rs" + +[[test]] +name = "client_rct_transport_probe_contract" +path = "tests/manual/client/sync_probe/client_rct_transport_probe_contract.rs" + +[[test]] +name = "client_server_rc_matrix_script_contract" +path = "tests/manual/client/sync_probe/client_server_rc_matrix_script_contract.rs" + +[[test]] +name = "server_rct_mode_matrix_manual_contract" +path = "tests/manual/server/rct/server_rct_mode_matrix_manual_contract.rs" + +[[test]] +name = "google_meet_observer_manual_contract" +path = "tests/manual/google_meet/google_meet_observer_manual_contract.rs" + +[[test]] +name = "uvc_frame_meta_log_contract" +path = "tests/manual/scripts/manual/uvc_frame_meta_log_contract.rs" + +[[test]] +name = "probe_artifact_contract" +path = "tests/manual/artifacts/probe_artifact_contract.rs" + +[[test]] +name = "client_uplink_performance_contract" +path = "tests/performance/client/uplink/client_uplink_performance_contract.rs" + +[[test]] +name = "opus_transport_budget_contract" +path = "tests/performance/client/uplink/opus_transport_budget_contract.rs" + +[[test]] +name = "hevc_mjpeg_handoff_performance_contract" +path = "tests/performance/server/video_sinks/hevc_mjpeg_handoff_performance_contract.rs" + +[[test]] +name = "server_rct_quality_budget_contract" +path = "tests/performance/server/rct/server_rct_quality_budget_contract.rs" + +[[test]] +name = "client_server_rct_timing_budget_contract" +path = "tests/performance/client/server/rct/client_server_rct_timing_budget_contract.rs" + +[[test]] +name = "input_latency_budget_contract" +path = "tests/performance/input/input_latency_budget_contract.rs" + +[[test]] +name = "downstream_video_latency_budget_contract" +path = "tests/performance/downstream/video/downstream_video_latency_budget_contract.rs" + +[[test]] +name = "performance_gate_script_contract" +path = "tests/performance/scripts/ci/performance_gate_script_contract.rs" + +[[test]] +name = "stage_timing_contract" +path = "tests/performance/diagnostics/stage_timing_contract.rs" + +[[test]] +name = "client_inputs_toggle_contract" +path = "tests/regression/client/input/inputs/client_inputs_toggle_contract.rs" + +[[test]] +name = "client_keyboard_activation_contract" +path = "tests/regression/client/input/keyboard/client_keyboard_activation_contract.rs" +required-features = ["disruptive-input-tests"] + +[[test]] +name = "client_live_controls_regression_contract" +path = "tests/regression/client/ui/client_live_controls_regression_contract.rs" + +[[test]] +name = "install_preserves_calibration_contract" +path = "tests/regression/install/install_preserves_calibration_contract.rs" + +[[test]] +name = "install_preserves_codec_settings_contract" +path = "tests/regression/install/install_preserves_codec_settings_contract.rs" + +[[test]] +name = "server_gadget_recovery_contract" +path = "tests/regression/server/gadget/server_gadget_recovery_contract.rs" + +[[test]] +name = "server_main_usb_recovery_contract" +path = "tests/regression/server/main/server_main_usb_recovery_contract.rs" + +[[test]] +name = "client_log_noise_contract" +path = "tests/reliability/client/diagnostics/client_log_noise_contract.rs" + +[[test]] +name = "input_transport_gate_safety_contract" +path = "tests/reliability/scripts/ci/input_transport_gate_safety_contract.rs" + +[[test]] +name = "jenkins_cadence_contract" +path = "tests/reliability/scripts/ci/jenkins_cadence_contract.rs" + +[[test]] +name = "log_spam_prevention_contract" +path = "tests/reliability/diagnostics/log_spam_prevention_contract.rs" + +[[test]] +name = "client_uplink_freshness_contract" +path = "tests/reliability/client/uplink/client_uplink_freshness_contract.rs" + +[[test]] +name = "server_upstream_media_pairing_freshness_contract" +path = "tests/reliability/server/upstream_media_runtime/server_upstream_media_pairing_freshness_contract.rs" + +[[test]] +name = "audio_epoch_recovery_reliability_contract" +path = "tests/reliability/audio/audio_epoch_recovery_reliability_contract.rs" + +[[test]] +name = "server_rct_profile_switch_recovery_contract" +path = "tests/reliability/server/rct/server_rct_profile_switch_recovery_contract.rs" + +[[test]] +name = "video_downstream_feed_contract" +path = "tests/reliability/video/video_downstream_feed_contract.rs" + +[[test]] +name = "downstream_blackout_recovery_contract" +path = "tests/reliability/downstream/video/downstream_blackout_recovery_contract.rs" + +[[test]] +name = "tls_security_contract" +path = "tests/security/scripts/install/tls_security_contract.rs" + +[[test]] +name = "client_paste_security_contract" +path = "tests/security/client/paste/client_paste_security_contract.rs" + +[[test]] +name = "cert_key_permissions_contract" +path = "tests/security/install/cert_key_permissions_contract.rs" + +[[test]] +name = "server_tls_security_contract" +path = "tests/security/server/tls/server_tls_security_contract.rs" + +[[test]] +name = "upstream_media_payload_security_contract" +path = "tests/security/server/upstream_media/upstream_media_payload_security_contract.rs" + +[[test]] +name = "client_runtime_smoke_contract" +path = "tests/smoke/client/runtime/client_runtime_smoke_contract.rs" + +[[test]] +name = "server_runtime_smoke_contract" +path = "tests/smoke/server/runtime_support/server_runtime_smoke_contract.rs" + +[[test]] +name = "server_video_sink_smoke_contract" +path = "tests/smoke/server/video_sinks/server_video_sink_smoke_contract.rs" + +[[test]] +name = "system_installation_contract" +path = "tests/system/scripts/install/system_installation_contract.rs" + +[[test]] +name = "systemd_unit_env_contract" +path = "tests/system/scripts/install/systemd_unit_env_contract.rs" + +[[test]] +name = "client_launcher_layout_contract" +path = "tests/ui/client/launcher/client_launcher_layout_contract.rs" + +[[test]] +name = "client_launcher_runtime_contract" +path = "tests/ui/client/launcher/client_launcher_runtime_contract.rs" + +[[test]] +name = "client_audio_recovery_ui_contract" +path = "tests/ui/client/launcher/client_audio_recovery_ui_contract.rs" + +[[test]] +name = "client_codec_transport_ui_contract" +path = "tests/ui/client/launcher/client_codec_transport_ui_contract.rs" + +[[test]] +name = "client_layout_contract" +path = "tests/ui/client/launcher/client_layout_contract.rs" + +[[test]] +name = "common_hid_unit" +path = "tests/unit/common/hid/common_hid_unit.rs" + +[[test]] +name = "common_audio_transport_unit" +path = "tests/unit/common/audio/common_audio_transport_unit.rs" + +[[test]] +name = "common_hid_edge_cases_unit" +path = "tests/unit/common/hid/common_hid_edge_cases_unit.rs" + +[[test]] +name = "client_upstream_bundle_queue_unit" +path = "tests/unit/client/uplink/client_upstream_bundle_queue_unit.rs" + +[[test]] +name = "client_upstream_keyframe_state_unit" +path = "tests/unit/client/uplink/client_upstream_keyframe_state_unit.rs" + +[[test]] +name = "client_audio_recovery_config_unit" +path = "tests/unit/client/app/client_audio_recovery_config_unit.rs" + +[[test]] +name = "hevc_mjpeg_guard_unit" +path = "tests/unit/server/video_sinks/hevc_mjpeg_guard_unit.rs" diff --git a/Jenkinsfile b/Jenkinsfile index e315aa5..97d1488 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -32,6 +32,9 @@ spec: parameters { booleanParam(name: 'PUSH_IMAGES', defaultValue: false, description: 'Push images to registry (enable for release runs)') + choice(name: 'LESAVKA_CI_PROFILE', choices: ['safe', 'daily', 'lab'], description: 'Safe is the normal non-disruptive gate; daily is intended for scheduled master/main runs; lab enables explicitly configured bare-metal probes.') + booleanParam(name: 'RUN_DISRUPTIVE_INPUT_TESTS', defaultValue: false, description: 'Run virtual HID tests only on an isolated worker/session; these can emit keyboard/mouse events.') + booleanParam(name: 'RUN_LAB_HARDWARE_GATES', defaultValue: false, description: 'Run opt-in bare-metal lab gates for Theia/Tethys/RCT probes when the Jenkins worker is prepared for them.') string(name: 'QUALITY_GATE_PUSHGATEWAY_URL', defaultValue: 'http://platform-quality-gateway.monitoring.svc.cluster.local:9091', description: 'Pushgateway base URL for quality gate metrics') string(name: 'REGISTRY_CREDENTIALS_ID', defaultValue: 'registry-bstein-dev', description: 'Jenkins credentials id for registry.bstein.dev') } @@ -41,6 +44,7 @@ spec: IMAGE_PREFIX = "${REGISTRY}/lesavka" CARGO_TERM_COLOR = 'always' DOCKER_BUILDKIT = '1' + LESAVKA_CI_PROFILE = "${params.LESAVKA_CI_PROFILE}" } stages { @@ -83,7 +87,21 @@ spec: } } + stage('Daily Master Gate') { + when { + expression { return params.LESAVKA_CI_PROFILE == 'daily' } + } + steps { + container('rust-ci') { + sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/daily_master_gate.sh' + } + } + } + stage('Style Docs LOC Naming') { + when { + expression { return params.LESAVKA_CI_PROFILE != 'daily' } + } steps { container('rust-ci') { sh 'scripts/ci/hygiene_gate.sh' @@ -92,6 +110,9 @@ spec: } stage('Coverage') { + when { + expression { return params.LESAVKA_CI_PROFILE != 'daily' } + } steps { container('rust-ci') { sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/quality_gate.sh' @@ -100,6 +121,9 @@ spec: } stage('Tests') { + when { + expression { return params.LESAVKA_CI_PROFILE != 'daily' } + } steps { container('rust-ci') { sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/test_gate.sh' @@ -107,7 +131,21 @@ spec: } } + stage('Performance') { + when { + expression { return params.LESAVKA_CI_PROFILE != 'daily' } + } + steps { + container('rust-ci') { + sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/performance_gate.sh' + } + } + } + stage('Media Reliability') { + when { + expression { return params.LESAVKA_CI_PROFILE != 'daily' } + } steps { container('rust-ci') { sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/media_reliability_gate.sh' @@ -115,7 +153,32 @@ spec: } } + stage('Input Transport (Isolated Opt-In)') { + when { + expression { return params.RUN_DISRUPTIVE_INPUT_TESTS } + } + steps { + container('rust-ci') { + sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" LESAVKA_ALLOW_DISRUPTIVE_INPUT_TESTS=1 scripts/ci/input_transport_gate.sh' + } + } + } + + stage('Bare-Metal Lab Gates (Opt-In)') { + when { + expression { return params.RUN_LAB_HARDWARE_GATES || params.LESAVKA_CI_PROFILE == 'lab' } + } + steps { + container('rust-ci') { + sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" LESAVKA_ALLOW_LAB_HARDWARE_TESTS=1 scripts/ci/baremetal_lab_gate.sh' + } + } + } + stage('Gate Glue') { + when { + expression { return params.LESAVKA_CI_PROFILE != 'daily' } + } steps { container('rust-ci') { sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/gate_glue_gate.sh' @@ -124,6 +187,9 @@ spec: } stage('SonarQube') { + when { + expression { return params.LESAVKA_CI_PROFILE != 'daily' } + } steps { container('rust-ci') { sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/sonarqube_gate.sh' @@ -132,6 +198,9 @@ spec: } stage('Build Dist') { + when { + expression { return params.LESAVKA_CI_PROFILE != 'daily' } + } steps { container('rust-ci') { sh 'scripts/ci/build-dist.sh' @@ -140,6 +209,9 @@ spec: } stage('Supply Chain Artifact Security') { + when { + expression { return params.LESAVKA_CI_PROFILE != 'daily' } + } steps { container('rust-ci') { sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/supply_chain_gate.sh' @@ -149,7 +221,7 @@ spec: stage('Docker Login') { when { - expression { return params.PUSH_IMAGES } + expression { return params.LESAVKA_CI_PROFILE != 'daily' && params.PUSH_IMAGES } } steps { withCredentials([ @@ -166,7 +238,7 @@ spec: stage('Build Images') { when { - expression { return params.PUSH_IMAGES } + expression { return params.LESAVKA_CI_PROFILE != 'daily' && params.PUSH_IMAGES } } steps { sh 'PUSH_IMAGES=${PUSH_IMAGES} scripts/ci/build-images.sh' @@ -178,7 +250,7 @@ spec: always { script { try { - archiveArtifacts artifacts: 'dist/**,target/test-gate/**,target/quality-gate/**,target/hygiene-gate/**,target/media-reliability-gate/**,target/gate-glue-gate/**,target/sonarqube-gate/**,target/supply-chain-gate/**', fingerprint: true, allowEmptyArchive: true + archiveArtifacts artifacts: 'dist/**,target/test-gate/**,target/quality-gate/**,target/hygiene-gate/**,target/daily-master-gate/**,target/performance-gate/**,target/media-reliability-gate/**,target/input-transport-gate/**,target/baremetal-lab-gate/**,target/video-downstream-gate/**,target/gate-glue-gate/**,target/sonarqube-gate/**,target/supply-chain-gate/**', fingerprint: true, allowEmptyArchive: true } catch (Throwable err) { echo "archive step unavailable: ${err.class.simpleName}" } diff --git a/testing/build.rs b/build.rs similarity index 98% rename from testing/build.rs rename to build.rs index 76935c8..c6a6d13 100644 --- a/testing/build.rs +++ b/build.rs @@ -4,7 +4,7 @@ fn main() { println!("cargo:rustc-check-cfg=cfg(coverage)"); let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("manifest dir")); - let workspace_dir = manifest_dir.parent().expect("workspace dir"); + let workspace_dir = manifest_dir; let server_uvc = workspace_dir .join("server/src/bin/lesavka-uvc.rs") diff --git a/client/Cargo.toml b/client/Cargo.toml index b7bf6fe..8e6aa3a 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.21.17" +version = "0.22.1" edition = "2024" [dependencies] diff --git a/client/src/app/audio_recovery_config.rs b/client/src/app/audio_recovery_config.rs index e5f69a1..27bfbe5 100644 --- a/client/src/app/audio_recovery_config.rs +++ b/client/src/app/audio_recovery_config.rs @@ -1,4 +1,3 @@ -#[cfg(not(coverage))] fn audio_usb_auto_recover_enabled() -> bool { std::env::var("LESAVKA_AUDIO_AUTO_RECOVER_USB") .map(|raw| { @@ -10,7 +9,6 @@ fn audio_usb_auto_recover_enabled() -> bool { .unwrap_or(false) } -#[cfg(not(coverage))] fn audio_usb_recover_after() -> u32 { std::env::var("LESAVKA_AUDIO_AUTO_RECOVER_AFTER") .ok() @@ -19,7 +17,6 @@ fn audio_usb_recover_after() -> u32 { .unwrap_or(3) } -#[cfg(not(coverage))] fn audio_usb_recover_cooldown() -> Duration { let millis = std::env::var("LESAVKA_AUDIO_AUTO_RECOVER_COOLDOWN_MS") .ok() @@ -28,7 +25,6 @@ fn audio_usb_recover_cooldown() -> Duration { Duration::from_millis(millis) } -#[cfg(not(coverage))] fn is_recoverable_remote_audio_error(message: &str) -> bool { message.contains("remote speaker capture produced no audio samples") || message.contains("remote speaker capture stalled") diff --git a/client/src/app/session_lifecycle.rs b/client/src/app/session_lifecycle.rs index 4906aa5..0d41dcf 100644 --- a/client/src/app/session_lifecycle.rs +++ b/client/src/app/session_lifecycle.rs @@ -62,12 +62,15 @@ impl LesavkaClientApp { let initial_cam_profile = initial_camera_profile_id_from_env(); let initial_mic_source = std::env::var("LESAVKA_MIC_SOURCE").ok(); let initial_audio_sink = std::env::var("LESAVKA_AUDIO_SINK").ok(); + let initial_audio_codec = crate::input::audio_codec::requested_upstream_audio_codec_from_env(); + let initial_noise_suppression = + env_flag_enabled("LESAVKA_MIC_NOISE_SUPPRESSION").unwrap_or(false); let camera_enabled = caps.camera && std::env::var("LESAVKA_CAM_DISABLE").is_err(); let microphone_available = caps.microphone && std::env::var("LESAVKA_MIC_DISABLE").is_err(); let bundled_webcam_media = camera_enabled && microphone_available && caps.bundled_webcam_media; let media_controls = crate::live_media_control::LiveMediaControls::from_env( - crate::live_media_control::MediaControlState::with_devices( + crate::live_media_control::MediaControlState::with_devices_and_audio( camera_enabled, microphone_available || bundled_webcam_media, std::env::var("LESAVKA_AUDIO_DISABLE").is_err(), @@ -75,6 +78,8 @@ impl LesavkaClientApp { initial_cam_profile.clone(), initial_mic_source.clone(), initial_audio_sink.clone(), + initial_audio_codec, + initial_noise_suppression, ), ); let media_state = media_controls.refresh(); @@ -344,3 +349,12 @@ impl LesavkaClientApp { } } + +fn env_flag_enabled(name: &str) -> Option { + let raw = std::env::var(name).ok()?; + match raw.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, + } +} diff --git a/client/src/app/uplink_media/uplink_queue_metadata.rs b/client/src/app/uplink_media/uplink_queue_metadata.rs index ed503a0..15052ee 100644 --- a/client/src/app/uplink_media/uplink_queue_metadata.rs +++ b/client/src/app/uplink_media/uplink_queue_metadata.rs @@ -42,6 +42,12 @@ fn emit_bundled_media( audio, ..UpstreamMediaBundle::default() }; + if has_audio { + let profile = lesavka_common::audio_transport::packet_audio_profile( + bundle.audio.first().expect("audio was checked as present"), + ); + lesavka_common::audio_transport::mark_bundle_audio_profile(&mut bundle, profile); + } attach_bundle_queue_metadata(&mut bundle, 0, enqueue_age); let stats = queue.push(bundle, enqueue_age); if stats.dropped_queue_full > 0 { @@ -174,6 +180,9 @@ fn attach_audio_queue_metadata( queue_depth: usize, delivery_age: Duration, ) { + if packet.encoding == 0 { + lesavka_common::audio_transport::mark_packet_pcm_s16le(packet); + } if packet.seq == 0 { let _ = stamp_audio_timing_metadata_at_enqueue(packet); } diff --git a/client/src/app/uplink_media/video_keyframes.rs b/client/src/app/uplink_media/video_keyframes.rs index 4d69aef..bdbc2e9 100644 --- a/client/src/app/uplink_media/video_keyframes.rs +++ b/client/src/app/uplink_media/video_keyframes.rs @@ -1,4 +1,3 @@ -#[cfg(not(coverage))] /// Detect whether an Annex-B HEVC access unit contains an intra recovery point. /// /// Inputs: one encoded HEVC packet in byte-stream form. Output: `true` when the @@ -20,7 +19,6 @@ fn contains_hevc_irap(data: &[u8]) -> bool { false } -#[cfg(not(coverage))] /// Locate the next Annex-B start code in an encoded video packet. /// /// Inputs: encoded bytes plus the search offset. Output: the start index and @@ -42,7 +40,6 @@ fn find_annex_b_start_code(data: &[u8], from: usize) -> Option<(usize, usize)> { None } -#[cfg(not(coverage))] /// Decide whether a bundled HEVC packet is safe after a freshness drop. /// /// Inputs: the recovery state and the next outbound bundle. Output: `true` @@ -60,7 +57,6 @@ fn should_hold_hevc_bundle_for_keyframe_recovery( .is_some_and(|video| !contains_hevc_irap(&video.data)) } -#[cfg(not(coverage))] /// Report whether a bundle carries the HEVC recovery frame we were waiting for. /// /// Inputs: an outbound media bundle. Output: true when its video packet can @@ -73,7 +69,6 @@ fn bundle_has_hevc_recovery_keyframe(bundle: &UpstreamMediaBundle) -> bool { .is_some_and(|video| contains_hevc_irap(&video.data)) } -#[cfg(not(coverage))] /// Mark HEVC as needing recovery after capture produced a gap. /// /// Inputs: whether HEVC recovery applies and the mutable wait flag. Output: @@ -86,7 +81,6 @@ fn note_hevc_capture_gap(recover_hevc_after_drops: bool, waiting_for_keyframe: & } } -#[cfg(not(coverage))] /// Resolve whether the active upstream camera codec needs HEVC recovery. /// /// Inputs: the negotiated camera config plus optional env fallback. Output: diff --git a/client/src/app/uplink_media/voice_loop.rs b/client/src/app/uplink_media/voice_loop.rs index 2c6b71f..f384f36 100644 --- a/client/src/app/uplink_media/voice_loop.rs +++ b/client/src/app/uplink_media/voice_loop.rs @@ -26,6 +26,10 @@ impl LesavkaClientApp { } let microphone_source_choice = state.microphone_source.clone(); let active_source = microphone_source_choice.resolve(initial_source.as_deref()); + let active_audio_codec = state + .audio_codec + .resolve(lesavka_common::audio_transport::UpstreamAudioCodec::Opus); + let active_noise_suppression = state.noise_suppression.resolve(false); let use_default_source = matches!( microphone_source_choice, crate::live_media_control::MediaDeviceChoice::Auto @@ -33,9 +37,16 @@ impl LesavkaClientApp { let setup_source = active_source.clone(); let result = tokio::task::spawn_blocking(move || { if use_default_source { - MicrophoneCapture::new_default_source() + MicrophoneCapture::new_default_source_options( + active_audio_codec, + active_noise_suppression, + ) } else { - MicrophoneCapture::new_with_source(setup_source.as_deref()) + MicrophoneCapture::new_with_source_options( + setup_source.as_deref(), + active_audio_codec, + active_noise_suppression, + ) } }) .await; @@ -118,6 +129,8 @@ impl LesavkaClientApp { let media_controls_thread = media_controls.clone(); let initial_source_thread = initial_source.clone(); let active_source_thread = active_source.clone(); + let active_audio_codec_thread = active_audio_codec; + let active_noise_suppression_thread = active_noise_suppression; let mic_worker = std::thread::spawn(move || { let mut paused = false; while stop_rx.try_recv().is_err() { @@ -125,17 +138,29 @@ impl LesavkaClientApp { let desired_source = state .microphone_source .resolve(initial_source_thread.as_deref()); + let desired_audio_codec = state + .audio_codec + .resolve(lesavka_common::audio_transport::UpstreamAudioCodec::Opus); + let desired_noise_suppression = + state.noise_suppression.resolve(false); if pause_when_camera_active && state.camera { tracing::info!( "๐ŸŽค microphone-only uplink yielding to bundled webcam A/V" ); break; } - if desired_source != active_source_thread { + if desired_source != active_source_thread + || desired_audio_codec != active_audio_codec_thread + || desired_noise_suppression != active_noise_suppression_thread + { tracing::info!( from = active_source_thread.as_deref().unwrap_or("auto"), to = desired_source.as_deref().unwrap_or("auto"), - "๐ŸŽค microphone source changed; restarting live uplink pipeline" + from_codec = active_audio_codec_thread.as_id(), + to_codec = desired_audio_codec.as_id(), + from_noise_suppression = active_noise_suppression_thread, + to_noise_suppression = desired_noise_suppression, + "๐ŸŽค microphone route changed; restarting live uplink pipeline" ); break; } diff --git a/client/src/app/uplink_media/webcam_media_loop.rs b/client/src/app/uplink_media/webcam_media_loop.rs index f94fd1e..146b073 100644 --- a/client/src/app/uplink_media/webcam_media_loop.rs +++ b/client/src/app/uplink_media/webcam_media_loop.rs @@ -34,6 +34,10 @@ impl LesavkaClientApp { let active_microphone_source = state .microphone_source .resolve(initial_microphone_source.as_deref()); + let active_audio_codec = state + .audio_codec + .resolve(lesavka_common::audio_transport::UpstreamAudioCodec::Opus); + let active_noise_suppression = state.noise_suppression.resolve(false); let capture_profile = active_camera_profile .as_deref() .and_then(parse_camera_profile_id); @@ -46,9 +50,16 @@ impl LesavkaClientApp { let setup = tokio::task::spawn_blocking(move || { let microphone = if use_default_microphone { - MicrophoneCapture::new_default_source() + MicrophoneCapture::new_default_source_options( + active_audio_codec, + active_noise_suppression, + ) } else { - MicrophoneCapture::new_with_source(setup_microphone_source.as_deref()) + MicrophoneCapture::new_with_source_options( + setup_microphone_source.as_deref(), + active_audio_codec, + active_noise_suppression, + ) }?; let camera = if camera_requested { Some(CameraCapture::new_with_capture_profile( @@ -255,6 +266,8 @@ impl LesavkaClientApp { let media_controls = media_controls.clone(); let initial_microphone_source = initial_microphone_source.clone(); let active_microphone_source = active_microphone_source.clone(); + let active_audio_codec = active_audio_codec; + let active_noise_suppression = active_noise_suppression; let active_camera_requested = camera_requested; std::thread::spawn(move || { while !stop.load(Ordering::Relaxed) { @@ -262,9 +275,16 @@ impl LesavkaClientApp { let desired_source = state .microphone_source .resolve(initial_microphone_source.as_deref()); + let desired_audio_codec = state + .audio_codec + .resolve(lesavka_common::audio_transport::UpstreamAudioCodec::Opus); + let desired_noise_suppression = + state.noise_suppression.resolve(false); if state.camera != active_camera_requested || !(state.microphone || state.camera) || desired_source != active_microphone_source + || desired_audio_codec != active_audio_codec + || desired_noise_suppression != active_noise_suppression { stop.store(true, Ordering::Relaxed); let _ = event_tx.try_send(BundledCaptureEvent::Restart); @@ -370,7 +390,6 @@ fn upstream_epoch_auto_heal_delay() -> Option { /// Inputs are optional raw environment values. Output is a bounded delay, or /// `None` when the operator opted out. Why: the default needs to be safe enough /// for real calls while tests can pin the behavior without racing process env. -#[cfg(not(coverage))] fn parse_upstream_epoch_auto_heal_delay( enabled: Option<&str>, delay_ms: Option<&str>, @@ -385,7 +404,6 @@ fn parse_upstream_epoch_auto_heal_delay( } /// Return whether a text env value disables startup A/V epoch healing. -#[cfg(not(coverage))] fn disables_upstream_epoch_auto_heal(raw: &str) -> bool { matches!( raw.trim().to_ascii_lowercase().as_str(), diff --git a/client/src/input/audio_codec.rs b/client/src/input/audio_codec.rs new file mode 100644 index 0000000..2b28f09 --- /dev/null +++ b/client/src/input/audio_codec.rs @@ -0,0 +1,193 @@ +//! Client-side upstream audio codec helpers. +//! +//! Why: the capture path stays raw PCM so monitor/playback behavior remains +//! predictable, while this module optionally compresses packets just before +//! they enter the client-to-server transport bundle. + +use anyhow::{Context, Result, bail}; +use gst::prelude::*; +use gstreamer as gst; +use gstreamer_app as gst_app; +use lesavka_common::{ + audio_transport::{ + self, AudioTransportProfile, UpstreamAudioCodec, parse_upstream_audio_codec, + }, + lesavka::AudioPacket, +}; +use std::time::Duration; + +const AUDIO_CODEC_ENV: &str = "LESAVKA_UPLINK_AUDIO_CODEC"; +const AUDIO_CODEC_LEGACY_ENV: &str = "LESAVKA_AUDIO_CODEC"; +const OPUS_PULL_TIMEOUT: Duration = Duration::from_millis(25); + +/// Resolve the requested upstream audio codec from runtime environment. +/// +/// Inputs: `LESAVKA_UPLINK_AUDIO_CODEC` or legacy `LESAVKA_AUDIO_CODEC`. +/// Output: Opus by default, PCM when explicitly requested. Why: Opus should be +/// the optimized path, but operators need an immediate known-good fallback. +#[must_use] +pub fn requested_upstream_audio_codec_from_env() -> UpstreamAudioCodec { + std::env::var(AUDIO_CODEC_ENV) + .ok() + .or_else(|| std::env::var(AUDIO_CODEC_LEGACY_ENV).ok()) + .as_deref() + .and_then(parse_upstream_audio_codec) + .unwrap_or(UpstreamAudioCodec::Opus) +} + +/// Low-latency Opus packet encoder for already-framed 48 kHz stereo PCM. +pub struct OpusPacketEncoder { + _pipeline: gst::Pipeline, + appsrc: gst_app::AppSrc, + appsink: gst_app::AppSink, +} + +impl OpusPacketEncoder { + /// Build the GStreamer Opus encoder used by live upstream transport. + /// + /// Inputs: installed GStreamer plugins. Output: a running appsrc/appsink + /// encoder. Why: keeping the encoder persistent avoids per-packet pipeline + /// startup latency and lets PCM remain the fallback when plugins are absent. + pub fn new() -> Result { + gst::init().context("gst init")?; + if gst::ElementFactory::find("opusenc").is_none() { + bail!("GStreamer opusenc plugin is not available"); + } + + let desc = "\ + appsrc name=src is-live=true block=false format=time \ + caps=audio/x-raw,format=S16LE,layout=interleaved,channels=2,rate=48000 ! \ + queue max-size-buffers=4 max-size-time=80000000 leaky=downstream ! \ + opusenc audio-type=voice bitrate=64000 bitrate-type=constrained-vbr complexity=5 frame-size=20 ! \ + appsink name=sink emit-signals=false sync=false max-buffers=8 drop=true"; + let pipeline: gst::Pipeline = gst::parse::launch(desc)?.downcast().expect("pipeline"); + let appsrc = pipeline + .by_name("src") + .context("missing opus encoder appsrc")? + .downcast::() + .expect("opus encoder appsrc"); + let appsink = pipeline + .by_name("sink") + .context("missing opus encoder appsink")? + .downcast::() + .expect("opus encoder appsink"); + pipeline + .set_state(gst::State::Playing) + .context("start opus encoder pipeline")?; + + Ok(Self { + _pipeline: pipeline, + appsrc, + appsink, + }) + } + + /// Encode one PCM packet, preserving capture timing on the returned packet. + pub fn encode_packet(&mut self, packet: AudioPacket) -> Result> { + if !audio_transport::packet_is_raw_pcm_s16le(&packet) { + return Ok(Some(packet)); + } + + let mut buffer = gst::Buffer::from_slice(packet.data.clone()); + if let Some(meta) = buffer.get_mut() { + let pts = gst::ClockTime::from_useconds(packet.pts); + meta.set_pts(Some(pts)); + meta.set_dts(Some(pts)); + meta.set_duration(Some(gst::ClockTime::from_useconds(u64::from( + packet.frame_duration_us.max(1), + )))); + } + self.appsrc + .push_buffer(buffer) + .context("push pcm into opus encoder")?; + + let Some(sample) = self.appsink.try_pull_sample(gst::ClockTime::from_nseconds( + OPUS_PULL_TIMEOUT.as_nanos().min(u128::from(u64::MAX)) as u64, + )) else { + return Ok(None); + }; + let encoded = sample + .buffer() + .context("opus encoder sample missing buffer")? + .map_readable() + .context("map opus packet")? + .to_vec(); + if encoded.is_empty() { + return Ok(None); + } + + let mut output = AudioPacket { + data: encoded, + ..packet + }; + audio_transport::mark_packet_opus(&mut output); + Ok(Some(output)) + } + + /// Return the compressed profile this encoder produces. + #[must_use] + pub const fn profile(&self) -> AudioTransportProfile { + AudioTransportProfile::opus_voice() + } +} + +impl Drop for OpusPacketEncoder { + fn drop(&mut self) { + let _ = self._pipeline.set_state(gst::State::Null); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lesavka_common::lesavka::AudioEncoding; + + #[test] + fn requested_audio_codec_defaults_to_opus_and_parses_pcm_fallback() { + temp_env::with_var(AUDIO_CODEC_ENV, None::<&str>, || { + temp_env::with_var(AUDIO_CODEC_LEGACY_ENV, None::<&str>, || { + assert_eq!( + requested_upstream_audio_codec_from_env(), + UpstreamAudioCodec::Opus + ); + }); + }); + temp_env::with_var(AUDIO_CODEC_ENV, Some("pcm"), || { + assert_eq!( + requested_upstream_audio_codec_from_env(), + UpstreamAudioCodec::PcmS16le + ); + }); + } + + #[test] + fn opus_encoder_preserves_packet_timing_when_plugin_is_available() { + let _ = gst::init(); + if gst::ElementFactory::find("opusenc").is_none() { + return; + } + let mut packet = AudioPacket { + pts: 123_000, + data: vec![0; AudioTransportProfile::pcm_s16le().expected_payload_bytes() as usize], + frame_duration_us: 20_000, + ..AudioPacket::default() + }; + let raw_len = packet.data.len(); + audio_transport::mark_packet_pcm_s16le(&mut packet); + + let mut encoder = OpusPacketEncoder::new().expect("opus encoder"); + let encoded = encoder + .encode_packet(packet.clone()) + .expect("encode") + .unwrap_or_else(|| packet.clone()); + assert_eq!(encoded.pts, 123_000); + assert_eq!(encoded.sample_rate, 48_000); + assert_eq!(encoded.channels, 2); + if encoded.encoding == AudioEncoding::Opus as i32 { + assert!( + encoded.data.len() < raw_len, + "Opus packet should be smaller than raw PCM" + ); + } + } +} diff --git a/client/src/input/camera/capture_pipeline.rs b/client/src/input/camera/capture_pipeline.rs index 135bf5b..66c20c3 100644 --- a/client/src/input/camera/capture_pipeline.rs +++ b/client/src/input/camera/capture_pipeline.rs @@ -106,12 +106,12 @@ impl CameraCapture { // Jetson (has nvvidconv) Desktop (falls back to videoconvert) // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ #[cfg(not(coverage))] - "nvh264enc" if have_nvvidconv => + "nvh264enc" | "nvh265enc" if have_nvvidconv => format!( "nvvidconv ! video/x-raw(memory:NVMM),format=NV12,width={width},height={height},framerate={fps}/1 !" ), #[cfg(not(coverage))] - "nvh264enc" /* else */ => + "nvh264enc" | "nvh265enc" /* else */ => format!( "videoconvert ! video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1 !" ), @@ -121,7 +121,7 @@ impl CameraCapture { "videoconvert ! video/x-raw,format=I420,width={width},height={height},framerate={fps}/1 !" ), #[cfg(not(coverage))] - "vaapih264enc" => + "vaapih264enc" | "vah265enc" | "vaapih265enc" | "v4l2h265enc" => format!( "videoconvert ! video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1 !" ), @@ -139,9 +139,9 @@ impl CameraCapture { // ); // tracing::debug!(%desc, "๐Ÿ“ธ pipeline-desc"); // Build a pipeline that works for any of the three encoders. - // * nvh264enc needs NVMM memory caps; - // * vaapih264enc wants system-memory caps; - // * x264enc needs the usual raw caps. + // * NVIDIA encoders prefer NV12, using NVMM when Jetson's converter is present. + // * VAAPI/V4L2 hardware encoders also get explicit NV12 system-memory caps. + // * x264enc/x265enc keep their software-friendly raw caps. let preview_tap_path = camera_preview_tap_path(); let preview_tap_branch = camera_preview_tap_branch(width, height, fps); let source_raw_caps = format!( diff --git a/client/src/input/microphone.rs b/client/src/input/microphone.rs index beffd9f..a5f1a2d 100644 --- a/client/src/input/microphone.rs +++ b/client/src/input/microphone.rs @@ -3,7 +3,10 @@ use anyhow::{Context, Result, bail}; use gst::prelude::*; use gstreamer as gst; use gstreamer_app as gst_app; -use lesavka_common::lesavka::AudioPacket; +use lesavka_common::{ + audio_transport::{self, UpstreamAudioCodec}, + lesavka::AudioPacket, +}; use shell_escape::unix::escape; #[cfg(not(coverage))] use std::sync::atomic::{AtomicU64, Ordering}; @@ -28,6 +31,7 @@ const MIC_PULSE_BUFFER_TIME_ENV: &str = "LESAVKA_MIC_PULSE_BUFFER_TIME_US"; const MIC_PULSE_LATENCY_TIME_ENV: &str = "LESAVKA_MIC_PULSE_LATENCY_TIME_US"; const MIC_PACKET_TARGET_DURATION_ENV: &str = "LESAVKA_MIC_PACKET_TARGET_US"; const REQUIRE_EXPLICIT_MEDIA_SOURCES_ENV: &str = "LESAVKA_REQUIRE_EXPLICIT_MEDIA_SOURCES"; +const MIC_NOISE_SUPPRESSION_ENV: &str = "LESAVKA_MIC_NOISE_SUPPRESSION"; const MIC_SAMPLE_RATE: u64 = 48_000; const MIC_CHANNELS: usize = 2; const MIC_SAMPLE_BYTES: usize = std::mem::size_of::(); @@ -45,6 +49,7 @@ pub struct MicrophoneCapture { level_tap_running: Option>, pts_rebaser: crate::live_capture_clock::DurationPacedSourcePtsRebaser, pending_packets: Mutex>, + audio_encoder: Mutex>, } include!("microphone/capture_runtime.rs"); @@ -58,13 +63,20 @@ fn mic_level_tap_path() -> Option { /// Keeps `microphone_pipeline_desc` explicit because it sits on microphone capture setup, where host audio stacks expose different source names and latency controls. /// Inputs are the typed parameters; output is the return value or side effect. -fn microphone_pipeline_desc(source_desc: &str, gain: f64, level_tap_enabled: bool) -> String { +fn microphone_pipeline_desc( + source_desc: &str, + gain: f64, + level_tap_enabled: bool, + noise_suppression: bool, +) -> String { let gain = format_mic_gain_for_gst(gain); + let noise_stage = microphone_noise_suppression_stage(noise_suppression); if level_tap_enabled { format!( "{source_desc} ! \ audioconvert ! audioresample ! \ audio/x-raw,format=S16LE,channels={MIC_CHANNELS},rate={MIC_SAMPLE_RATE} ! \ + {noise_stage}\ volume name=mic_input_gain volume={gain} ! \ tee name=t \ t. ! queue max-size-buffers={MIC_MAIN_QUEUE_MAX_BUFFERS} max-size-time={MIC_MAIN_QUEUE_MAX_TIME_NS} leaky=downstream ! \ @@ -79,6 +91,7 @@ fn microphone_pipeline_desc(source_desc: &str, gain: f64, level_tap_enabled: boo "{source_desc} ! \ audioconvert ! audioresample ! \ audio/x-raw,format=S16LE,channels={MIC_CHANNELS},rate={MIC_SAMPLE_RATE} ! \ + {noise_stage}\ volume name=mic_input_gain volume={gain} ! \ queue max-size-buffers={MIC_MAIN_QUEUE_MAX_BUFFERS} max-size-time={MIC_MAIN_QUEUE_MAX_TIME_NS} leaky=downstream ! \ appsink name=asink emit-signals=true max-buffers={MIC_APPSINK_MAX_BUFFERS} drop=true" @@ -86,6 +99,14 @@ fn microphone_pipeline_desc(source_desc: &str, gain: f64, level_tap_enabled: boo } } +fn microphone_noise_suppression_stage(enabled: bool) -> &'static str { + if enabled && gst::ElementFactory::find("webrtcdsp").is_some() { + "webrtcdsp echo-cancel=false noise-suppression=true noise-suppression-level=high high-pass-filter=true gain-control=false limiter=true ! " + } else { + "" + } +} + fn buffer_duration_us(buf: &gst::BufferRef, bytes: usize) -> u64 { let payload_duration_us = pcm_payload_duration_us(bytes); buf.duration() @@ -149,12 +170,16 @@ fn split_audio_sample(base_pts_us: u64, data: &[u8], target_bytes: usize) -> Vec if end == offset { break; } - packets.push_back(AudioPacket { + let duration_us = pcm_payload_duration_us(take); + let mut packet = AudioPacket { id: 0, pts: base_pts_us.saturating_add(pcm_payload_duration_us(offset)), data: data[offset..end].to_vec(), + frame_duration_us: duration_us.min(u64::from(u32::MAX)) as u32, ..Default::default() - }); + }; + audio_transport::mark_packet_pcm_s16le(&mut packet); + packets.push_back(packet); offset = end; } packets @@ -217,6 +242,10 @@ fn explicit_media_sources_required() -> bool { bool_env_enabled(REQUIRE_EXPLICIT_MEDIA_SOURCES_ENV) } +fn mic_noise_suppression_from_env() -> bool { + bool_env_enabled(MIC_NOISE_SUPPRESSION_ENV) +} + fn bool_env_enabled(name: &str) -> bool { std::env::var(name).ok().is_some_and(|value| { let value = value.trim(); diff --git a/client/src/input/microphone/capture_runtime.rs b/client/src/input/microphone/capture_runtime.rs index c517c6a..f071322 100644 --- a/client/src/input/microphone/capture_runtime.rs +++ b/client/src/input/microphone/capture_runtime.rs @@ -11,11 +11,45 @@ impl MicrophoneCapture { Self::new_with_source_and_env(None, false) } + pub fn new_with_source_options( + source_override: Option<&str>, + codec: UpstreamAudioCodec, + noise_suppression: bool, + ) -> Result { + Self::new_with_source_env_and_options(source_override, true, codec, noise_suppression) + } + + pub fn new_default_source_options( + codec: UpstreamAudioCodec, + noise_suppression: bool, + ) -> Result { + Self::new_with_source_env_and_options(None, false, codec, noise_suppression) + } + /// Keeps `new_with_source_and_env` explicit because it sits on microphone capture setup, where host audio stacks expose different source names and latency controls. /// Inputs are the typed parameters; output is the return value or side effect. fn new_with_source_and_env( source_override: Option<&str>, allow_env_source: bool, + ) -> Result { + Self::new_with_source_env_and_options( + source_override, + allow_env_source, + crate::input::audio_codec::requested_upstream_audio_codec_from_env(), + mic_noise_suppression_from_env(), + ) + } + + /// Build a microphone capture pipeline with explicit uplink codec/noise choices. + /// + /// Inputs: selected source, audio codec, and noise suppression flag. Output: + /// a live capture object. Why: live UI changes must rebuild only the mic + /// path while preserving the established camera/session transport. + fn new_with_source_env_and_options( + source_override: Option<&str>, + allow_env_source: bool, + codec: UpstreamAudioCodec, + noise_suppression: bool, ) -> Result { gst::init().ok(); // idempotent @@ -43,7 +77,8 @@ impl MicrophoneCapture { debug!("๐ŸŽค source: {source_desc}"); let gain = mic_gain_from_env(); let level_tap_path = mic_level_tap_path(); - let desc = microphone_pipeline_desc(&source_desc, gain, level_tap_path.is_some()); + let desc = + microphone_pipeline_desc(&source_desc, gain, level_tap_path.is_some(), noise_suppression); let pipeline: gst::Pipeline = gst::parse::launch(&desc)?.downcast().expect("pipeline"); let sink: gst_app::AppSink = pipeline.by_name("asink").unwrap().downcast().unwrap(); @@ -97,19 +132,38 @@ impl MicrophoneCapture { None }; + let audio_encoder = match codec { + UpstreamAudioCodec::PcmS16le => None, + UpstreamAudioCodec::Opus => { + match crate::input::audio_codec::OpusPacketEncoder::new() { + Ok(encoder) => { + info!("๐ŸŽค upstream microphone Opus encoder enabled"); + Some(encoder) + } + Err(err) => { + warn!( + "๐ŸŽคโš ๏ธ Opus requested but unavailable ({err:#}); falling back to PCM" + ); + None + } + } + } + }; + Ok(Self { pipeline, sink, level_tap_running, pts_rebaser: crate::live_capture_clock::DurationPacedSourcePtsRebaser::default(), pending_packets: Mutex::default(), + audio_encoder: Mutex::new(audio_encoder), }) } /// Blocking pull; call from an async wrapper pub fn pull(&self) -> Option { if let Some(packet) = self.pending_packets.lock().ok()?.pop_front() { - return Some(packet); + return Some(self.encode_for_transport(packet)); } match self.sink.pull_sample() { Ok(sample) => { @@ -170,12 +224,29 @@ impl MicrophoneCapture { { pending.extend(packets); } - first_packet + first_packet.map(|packet| self.encode_for_transport(packet)) } Err(_) => None, } } + fn encode_for_transport(&self, packet: AudioPacket) -> AudioPacket { + let Some(mut guard) = self.audio_encoder.lock().ok() else { + return packet; + }; + let Some(encoder) = guard.as_mut() else { + return packet; + }; + match encoder.encode_packet(packet.clone()) { + Ok(Some(encoded)) => encoded, + Ok(None) => packet, + Err(err) => { + warn!("๐ŸŽคโš ๏ธ Opus encode failed; sending PCM fallback for this packet: {err:#}"); + packet + } + } + } + /// Resolve launcher-selected mic names while preserving Pulse catalog routing. fn resolve_source_desc(fragment: &str) -> Option { if looks_like_pulse_source_name(fragment) @@ -294,4 +365,3 @@ impl MicrophoneCapture { Self::pulse_source_desc(None) } } - diff --git a/client/src/input/mod.rs b/client/src/input/mod.rs index cbe2cff..9f73141 100644 --- a/client/src/input/mod.rs +++ b/client/src/input/mod.rs @@ -1,5 +1,6 @@ // client/src/input/mod.rs +pub mod audio_codec; pub mod camera; // stub for camera pub mod inputs; // the aggregator that scans /dev/input and spawns sub-aggregators pub mod keyboard; // existing keyboard aggregator logic (minus scanning) diff --git a/client/src/input/mouse.rs b/client/src/input/mouse.rs index c9fe022..1f59223 100644 --- a/client/src/input/mouse.rs +++ b/client/src/input/mouse.rs @@ -401,7 +401,7 @@ impl Drop for MouseAggregator { fn drop(&mut self) { let _ = self.dev.ungrab(); let _ = self.tx.send(MouseReport { - data: [0; 8].into(), + data: [0; 4].into(), }); } } diff --git a/client/src/input/mouse_event_contract_tests.rs b/client/src/input/mouse_event_contract_tests.rs index 473f5fa..c9264f2 100644 --- a/client/src/input/mouse_event_contract_tests.rs +++ b/client/src/input/mouse_event_contract_tests.rs @@ -55,6 +55,14 @@ fn build_relative_mouse(name: &str) -> Option<(evdev::uinput::VirtualDevice, evd Some((vdev, dev)) } +/// Returns whether this run may emit virtual mouse events into the OS input stack. +/// +/// The default is false because local test gates often share the user's active +/// desktop, where a uinput mouse can steal focus or move the pointer. +fn disruptive_input_tests_enabled() -> bool { + std::env::var("LESAVKA_ALLOW_DISRUPTIVE_INPUT_TESTS").as_deref() == Ok("1") +} + struct StateHarness { buttons: u8, dx: i8, @@ -397,7 +405,12 @@ fn log_event_batch_tolerates_empty_and_populated_batches() { #[test] #[serial] +/// Exercises the live uinput read path only when an isolated test worker opts in. fn process_events_emits_live_relative_packets_for_the_real_crate_path() { + if !disruptive_input_tests_enabled() { + return; + } + let Some((mut vdev, dev)) = build_relative_mouse("lesavka-unit-mouse-rel") else { return; }; @@ -425,7 +438,12 @@ fn process_events_emits_live_relative_packets_for_the_real_crate_path() { #[test] #[serial] +/// Verifies disconnect recovery only when virtual-device events are safe to emit. fn process_events_handles_disconnected_virtual_devices_without_panicking() { + if !disruptive_input_tests_enabled() { + return; + } + let Some((vdev, dev)) = build_relative_mouse("lesavka-unit-mouse-err") else { return; }; diff --git a/client/src/launcher/mod.rs b/client/src/launcher/mod.rs index 816a214..dd56f77 100644 --- a/client/src/launcher/mod.rs +++ b/client/src/launcher/mod.rs @@ -163,6 +163,19 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap { "LESAVKA_CAM_CODEC".to_string(), state.webcam_transport.env_value().to_string(), ); + envs.insert( + "LESAVKA_UPLINK_AUDIO_CODEC".to_string(), + state.upstream_audio_transport.env_value().to_string(), + ); + envs.insert( + "LESAVKA_MIC_NOISE_SUPPRESSION".to_string(), + if state.mic_noise_suppression { + "1" + } else { + "0" + } + .to_string(), + ); if matches!(state.view_mode, ViewMode::Unified) { envs.insert("LESAVKA_DISABLE_VIDEO_RENDER".to_string(), "1".to_string()); } diff --git a/client/src/launcher/state/launcher_state_impl.rs b/client/src/launcher/state/launcher_state_impl.rs index 88a8436..89b15c1 100644 --- a/client/src/launcher/state/launcher_state_impl.rs +++ b/client/src/launcher/state/launcher_state_impl.rs @@ -314,6 +314,14 @@ impl LauncherState { self.webcam_transport = transport; } + pub fn select_upstream_audio_transport(&mut self, transport: UpstreamAudioTransport) { + self.upstream_audio_transport = transport; + } + + pub fn set_mic_noise_suppression(&mut self, enabled: bool) { + self.mic_noise_suppression = enabled; + } + pub fn camera_quality_options(&self, catalog: &DeviceCatalog) -> Vec { self.devices .camera diff --git a/client/src/launcher/state/launcher_status_line.rs b/client/src/launcher/state/launcher_status_line.rs index f89ae65..1fddf49 100644 --- a/client/src/launcher/state/launcher_status_line.rs +++ b/client/src/launcher/state/launcher_status_line.rs @@ -1,7 +1,7 @@ impl LauncherState { pub fn status_line(&self) -> String { format!( - "server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} camera_quality={} camera_transport={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} remote_caps=cam:{:?}/mic:{:?}/output:{:?}/codec:{:?} cal={}:{:+.1}ms audio_gain={} mic_gain={} kbd={} mouse={} swap={}", + "server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} camera_quality={} camera_transport={} mic={} mic_transport={} noise_suppression={} speaker={} channels=cam:{}/mic:{}/audio:{} remote_caps=cam:{:?}/mic:{:?}/output:{:?}/codec:{:?} cal={}:{:+.1}ms audio_gain={} mic_gain={} kbd={} mouse={} swap={}", self.server_available, match self.routing { InputRouting::Local => "local", @@ -29,6 +29,8 @@ impl LauncherState { .unwrap_or_else(|| "default".to_string()), self.webcam_transport.label(), media_status_label(self.channels.microphone, self.devices.microphone.as_deref()), + self.upstream_audio_transport.label(), + self.mic_noise_suppression, media_status_label(self.channels.audio, self.devices.speaker.as_deref()), self.channels.camera, self.channels.microphone, diff --git a/client/src/launcher/state/selection_models.rs b/client/src/launcher/state/selection_models.rs index 9073566..dc1612f 100644 --- a/client/src/launcher/state/selection_models.rs +++ b/client/src/launcher/state/selection_models.rs @@ -82,6 +82,61 @@ impl WebcamTransport { } } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum UpstreamAudioTransport { + #[default] + Opus, + Pcm, +} + +impl UpstreamAudioTransport { + /// Return the stable GTK row id for upstream microphone transport. + #[must_use] + pub const fn as_id(self) -> &'static str { + match self { + Self::Opus => "opus", + Self::Pcm => "pcm", + } + } + + /// Parse a GTK row id back into an upstream microphone transport. + /// + /// Inputs: compact id or familiar operator alias. Output: a supported + /// transport. Why: Opus is now the optimized path, but PCM must remain + /// one click away as the known-good audio fallback. + pub fn from_id(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "opus" | "compressed" => Some(Self::Opus), + "pcm" | "raw" | "s16le" | "uncompressed" => Some(Self::Pcm), + _ => None, + } + } + + /// Return the environment/control value consumed by the relay child. + #[must_use] + pub const fn env_value(self) -> &'static str { + self.as_id() + } + + /// Return a short label displayed in the launcher dropdown. + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Opus => "Opus", + Self::Pcm => "PCM", + } + } + + /// Convert the UI enum into the shared transport enum. + #[must_use] + pub const fn as_common_codec(self) -> lesavka_common::audio_transport::UpstreamAudioCodec { + match self { + Self::Opus => lesavka_common::audio_transport::UpstreamAudioCodec::Opus, + Self::Pcm => lesavka_common::audio_transport::UpstreamAudioCodec::PcmS16le, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ViewMode { Unified, diff --git a/client/src/launcher/state/selection_models/sync_and_state_status.rs b/client/src/launcher/state/selection_models/sync_and_state_status.rs index a07c008..a7b619f 100644 --- a/client/src/launcher/state/selection_models/sync_and_state_status.rs +++ b/client/src/launcher/state/selection_models/sync_and_state_status.rs @@ -277,6 +277,8 @@ pub struct LauncherState { pub devices: DeviceSelection, pub camera_quality: Option, pub webcam_transport: WebcamTransport, + pub upstream_audio_transport: UpstreamAudioTransport, + pub mic_noise_suppression: bool, pub channels: ChannelSelection, pub audio_gain_percent: u32, pub mic_gain_percent: u32, @@ -315,6 +317,8 @@ impl Default for LauncherState { devices: DeviceSelection::default(), camera_quality: None, webcam_transport: WebcamTransport::default(), + upstream_audio_transport: UpstreamAudioTransport::default(), + mic_noise_suppression: false, channels: ChannelSelection::default(), audio_gain_percent: DEFAULT_AUDIO_GAIN_PERCENT, mic_gain_percent: DEFAULT_MIC_GAIN_PERCENT, diff --git a/client/src/launcher/tests/mod.rs b/client/src/launcher/tests/mod.rs index fa467c2..d61e578 100644 --- a/client/src/launcher/tests/mod.rs +++ b/client/src/launcher/tests/mod.rs @@ -1,5 +1,5 @@ use super::*; -use crate::launcher::state::WebcamTransport; +use crate::launcher::state::{UpstreamAudioTransport, WebcamTransport}; use serial_test::serial; #[cfg(not(coverage))] @@ -163,6 +163,14 @@ fn runtime_env_vars_emit_selected_controls() { assert_eq!(envs.get("LESAVKA_AUDIO_GAIN"), Some(&"2.000".to_string())); assert_eq!(envs.get("LESAVKA_MIC_GAIN"), Some(&"1.000".to_string())); assert_eq!(envs.get("LESAVKA_CAM_CODEC"), Some(&"hevc".to_string())); + assert_eq!( + envs.get("LESAVKA_UPLINK_AUDIO_CODEC"), + Some(&"opus".to_string()) + ); + assert_eq!( + envs.get("LESAVKA_MIC_NOISE_SUPPRESSION"), + Some(&"0".to_string()) + ); assert_eq!(envs.get("LESAVKA_CAM_WIDTH"), Some(&"1920".to_string())); assert_eq!(envs.get("LESAVKA_CAM_HEIGHT"), Some(&"1080".to_string())); assert_eq!(envs.get("LESAVKA_CAM_FPS"), Some(&"30".to_string())); @@ -269,6 +277,31 @@ fn runtime_env_vars_emit_selected_webcam_transport() { ); } +#[test] +fn runtime_env_vars_emit_selected_audio_transport_and_noise_mode() { + let mut state = LauncherState::new(); + assert_eq!( + runtime_env_vars(&state).get("LESAVKA_UPLINK_AUDIO_CODEC"), + Some(&"opus".to_string()) + ); + assert_eq!( + runtime_env_vars(&state).get("LESAVKA_MIC_NOISE_SUPPRESSION"), + Some(&"0".to_string()) + ); + + state.select_upstream_audio_transport(UpstreamAudioTransport::Pcm); + state.set_mic_noise_suppression(true); + let envs = runtime_env_vars(&state); + assert_eq!( + envs.get("LESAVKA_UPLINK_AUDIO_CODEC"), + Some(&"pcm".to_string()) + ); + assert_eq!( + envs.get("LESAVKA_MIC_NOISE_SUPPRESSION"), + Some(&"1".to_string()) + ); +} + #[test] fn runtime_env_vars_do_not_disable_breakout_video_windows() { let mut state = LauncherState::new(); diff --git a/client/src/launcher/tests/ui_runtime.rs b/client/src/launcher/tests/ui_runtime.rs index c3363b1..6f75060 100644 --- a/client/src/launcher/tests/ui_runtime.rs +++ b/client/src/launcher/tests/ui_runtime.rs @@ -2,7 +2,10 @@ use super::*; use crate::launcher::{ devices::{CameraMode, DeviceCatalog}, preview::PreviewBinding, - state::{BreakoutSizePreset, LauncherState, PreviewSourceSize, WebcamTransport}, + state::{ + BreakoutSizePreset, LauncherState, PreviewSourceSize, UpstreamAudioTransport, + WebcamTransport, + }, ui_components::build_launcher_view, }; use crate::uplink_telemetry::UpstreamStreamTelemetry; @@ -420,6 +423,26 @@ fn webcam_transport_combo_tracks_selected_upstream_codec() { ); } +#[test] +fn audio_transport_model_defaults_to_compressed_with_raw_fallback() { + assert_eq!( + UpstreamAudioTransport::default(), + UpstreamAudioTransport::Opus + ); + assert_eq!(UpstreamAudioTransport::Opus.as_id(), "opus"); + assert_eq!(UpstreamAudioTransport::Opus.label(), "Opus"); + assert_eq!(UpstreamAudioTransport::Pcm.as_id(), "pcm"); + assert_eq!(UpstreamAudioTransport::Pcm.label(), "PCM"); + assert_eq!( + UpstreamAudioTransport::from_id("raw"), + Some(UpstreamAudioTransport::Pcm) + ); + assert_eq!( + UpstreamAudioTransport::from_id("compressed"), + Some(UpstreamAudioTransport::Opus) + ); +} + #[gtk::test] #[serial] fn diagnostics_and_log_popouts_install_native_window_chrome() { @@ -571,7 +594,7 @@ fn uac_chip_uses_live_microphone_flow_not_only_server_caps() { assert_eq!( recovery_uac_health(&state, false, None), - (StatusLightState::Live, "Ready".to_string()) + (StatusLightState::Live, "Opus".to_string()) ); assert_eq!( recovery_uac_health(&state, true, None), @@ -589,7 +612,13 @@ fn uac_chip_uses_live_microphone_flow_not_only_server_caps() { }; assert_eq!( recovery_uac_health(&state, true, Some(&healthy)), - (StatusLightState::Live, "Flowing".to_string()) + (StatusLightState::Live, "Opus".to_string()) + ); + + state.select_upstream_audio_transport(UpstreamAudioTransport::Pcm); + assert_eq!( + recovery_uac_health(&state, true, Some(&healthy)), + (StatusLightState::Live, "PCM".to_string()) ); state.set_microphone_channel_enabled(false); diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index f5a7fe3..3dce814 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -20,7 +20,7 @@ use { super::state::{ BreakoutSizePreset, CalibrationStatus, CapturePowerStatus, CaptureSizePreset, DisplaySurface, FeedSourcePreset, InputRouting, LauncherState, MAX_AUDIO_GAIN_PERCENT, - MAX_MIC_GAIN_PERCENT, UpstreamSyncStatus, WebcamTransport, + MAX_MIC_GAIN_PERCENT, UpstreamAudioTransport, UpstreamSyncStatus, WebcamTransport, }, super::ui_components::{ ConsoleLogLevel, build_launcher_view, sync_camera_quality_combo, sync_input_device_combo, diff --git a/client/src/launcher/ui/media_device_bindings.rs b/client/src/launcher/ui/media_device_bindings.rs index ba39312..f118413 100644 --- a/client/src/launcher/ui/media_device_bindings.rs +++ b/client/src/launcher/ui/media_device_bindings.rs @@ -128,6 +128,43 @@ }); } + { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let toggle = widgets.noise_suppression_toggle.clone(); + toggle.connect_toggled(move |toggle| { + let enabled = toggle.is_active(); + if let Ok(mut state) = state.try_borrow_mut() { + state.set_mic_noise_suppression(enabled); + } + let label = if enabled { + "Noise cancellation enabled" + } else { + "Noise cancellation disabled" + }; + if let Ok(state_snapshot) = state.try_borrow().map(|state| state.clone()) { + if child_proc.borrow().is_some() { + apply_live_media_device_change( + &state_snapshot, + &widgets, + &child_proc, + "Microphone noise suppression", + ); + } else { + widgets.status_label.set_text(&format!( + "{label} for the next relay launch." + )); + } + refresh_launcher_ui( + &widgets, + &state_snapshot, + child_proc.borrow().is_some(), + ); + } + }); + } + { let state = Rc::clone(&state); let widgets = widgets.clone(); diff --git a/client/src/launcher/ui/stage_device_bindings.rs b/client/src/launcher/ui/stage_device_bindings.rs index 4c4a884..59d914f 100644 --- a/client/src/launcher/ui/stage_device_bindings.rs +++ b/client/src/launcher/ui/stage_device_bindings.rs @@ -80,6 +80,39 @@ }); } + { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let audio_combo = widgets.upstream_audio_transport_combo.clone(); + let audio_combo_read = audio_combo.clone(); + audio_combo.connect_changed(move |_| { + let selected = audio_combo_read + .active_id() + .as_deref() + .and_then(UpstreamAudioTransport::from_id) + .unwrap_or_default(); + state + .borrow_mut() + .select_upstream_audio_transport(selected); + let relay_live = child_proc.borrow().is_some(); + if relay_live { + apply_live_media_device_change( + &state.borrow(), + &widgets, + &child_proc, + "Microphone transport", + ); + } else { + widgets.status_label.set_text(&format!( + "Upstream microphone transport set to {} for the next relay launch.", + selected.label() + )); + } + refresh_launcher_ui(&widgets, &state.borrow(), relay_live); + }); + } + { let state = Rc::clone(&state); let widgets = widgets.clone(); diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index e44fe9f..7387cee 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -9,7 +9,7 @@ use super::{ preview::{LauncherPreview, PreviewBinding, PreviewSurface}, state::{ BreakoutSizeChoice, BreakoutSizePreset, CaptureSizeChoice, CaptureSizePreset, - FeedSourceChoice, FeedSourcePreset, LauncherState, WebcamTransport, + FeedSourceChoice, FeedSourcePreset, LauncherState, UpstreamAudioTransport, WebcamTransport, }, }; @@ -60,6 +60,7 @@ pub fn build_launcher_view( mouse_combo, camera_channel_toggle, microphone_channel_toggle, + noise_suppression_toggle, audio_channel_toggle, audio_gain_scale, audio_gain_value, @@ -73,6 +74,7 @@ pub fn build_launcher_view( camera_preview_frame, camera_preview, webcam_transport_combo, + upstream_audio_transport_combo, camera_mirror_button, camera_mirror_revealer, camera_status, diff --git a/client/src/launcher/ui_components/assemble_view.rs b/client/src/launcher/ui_components/assemble_view.rs index 821e7e4..8e49cc5 100644 --- a/client/src/launcher/ui_components/assemble_view.rs +++ b/client/src/launcher/ui_components/assemble_view.rs @@ -133,6 +133,7 @@ mouse_combo: mouse_combo.clone(), camera_channel_toggle: camera_channel_toggle.clone(), microphone_channel_toggle: microphone_channel_toggle.clone(), + noise_suppression_toggle: noise_suppression_toggle.clone(), audio_channel_toggle: audio_channel_toggle.clone(), power_auto_button: power_auto_button.clone(), power_on_button: power_on_button.clone(), @@ -157,6 +158,7 @@ camera_test_button: camera_test_button.clone(), camera_preview_stack: camera_preview_stack.clone(), webcam_transport_combo: webcam_transport_combo.clone(), + upstream_audio_transport_combo: upstream_audio_transport_combo.clone(), camera_mirror_button: camera_mirror_button.clone(), camera_mirror_revealer: camera_mirror_revealer.clone(), microphone_test_button: microphone_test_button.clone(), @@ -193,6 +195,7 @@ camera_preview_frame, camera_preview, webcam_transport_combo, + upstream_audio_transport_combo, camera_mirror_button, camera_status, }, diff --git a/client/src/launcher/ui_components/build_contexts.rs b/client/src/launcher/ui_components/build_contexts.rs index 1dd1f5a..462f81b 100644 --- a/client/src/launcher/ui_components/build_contexts.rs +++ b/client/src/launcher/ui_components/build_contexts.rs @@ -30,6 +30,7 @@ struct DeviceControlsContext { mouse_combo: gtk::ComboBoxText, camera_channel_toggle: gtk::ToggleButton, microphone_channel_toggle: gtk::ToggleButton, + noise_suppression_toggle: gtk::ToggleButton, audio_channel_toggle: gtk::ToggleButton, audio_gain_scale: gtk::Scale, audio_gain_value: gtk::Label, @@ -43,6 +44,7 @@ struct DeviceControlsContext { camera_preview_frame: gtk::AspectFrame, camera_preview: gtk::Picture, webcam_transport_combo: gtk::ComboBoxText, + upstream_audio_transport_combo: gtk::ComboBoxText, camera_mirror_button: gtk::ToggleButton, camera_mirror_revealer: gtk::Revealer, camera_status: gtk::Label, diff --git a/client/src/launcher/ui_components/build_device_controls.rs b/client/src/launcher/ui_components/build_device_controls.rs index e160be7..2955566 100644 --- a/client/src/launcher/ui_components/build_device_controls.rs +++ b/client/src/launcher/ui_components/build_device_controls.rs @@ -92,7 +92,17 @@ microphone_channel_toggle.set_tooltip_text(Some("Send mic during relay.")); microphone_channel_toggle.add_css_class("pill-toggle"); microphone_channel_toggle.add_css_class("media-toggle"); - stabilize_button(µphone_channel_toggle, 92); + stabilize_button(µphone_channel_toggle, 46); + let noise_suppression_toggle = gtk::ToggleButton::with_label("๐Ÿงน"); + noise_suppression_toggle.set_active(state.mic_noise_suppression); + noise_suppression_toggle.set_tooltip_text(Some(if state.mic_noise_suppression { + "Noise cancellation is on for the upstream microphone." + } else { + "Noise cancellation is off; upstream microphone is raw aside from gain." + })); + noise_suppression_toggle.add_css_class("pill-toggle"); + noise_suppression_toggle.add_css_class("media-toggle"); + stabilize_button(&noise_suppression_toggle, 42); let audio_gain_adjustment = gtk::Adjustment::new( f64::from(state.audio_gain_percent), @@ -176,10 +186,13 @@ microphone_combo.set_hexpand(true); microphone_selectors.append(µphone_combo); microphone_selectors.append(&mic_gain_scale); + let microphone_toggle_group = gtk::Box::new(gtk::Orientation::Horizontal, 4); + microphone_toggle_group.append(µphone_channel_toggle); + microphone_toggle_group.append(&noise_suppression_toggle); attach_device_control_row( &media_grid, 2, - µphone_channel_toggle, + µphone_toggle_group, µphone_selectors, µphone_test_button, ); @@ -196,7 +209,38 @@ devices_body.append(&media_group); staging_row.append(&devices_panel); - let (preview_panel, preview_body) = build_panel("Upstream Media"); + let upstream_transport_row = gtk::Box::new(gtk::Orientation::Horizontal, 6); + upstream_transport_row.set_halign(gtk::Align::End); + + let webcam_transport_combo = gtk::ComboBoxText::new(); + webcam_transport_combo.add_css_class("compact-combo"); + for transport in [WebcamTransport::Hevc, WebcamTransport::Mjpeg] { + webcam_transport_combo.append(Some(transport.as_id()), transport.label()); + } + webcam_transport_combo.set_active_id(Some(state.webcam_transport.as_id())); + webcam_transport_combo.set_sensitive(true); + webcam_transport_combo.set_size_request(98, -1); + webcam_transport_combo.set_tooltip_text(Some( + "Upstream webcam transport for the next relay connection. HEVC is the low-latency default; MJPEG is the calibrated fallback.", + )); + + let upstream_audio_transport_combo = gtk::ComboBoxText::new(); + upstream_audio_transport_combo.add_css_class("compact-combo"); + for transport in [UpstreamAudioTransport::Opus, UpstreamAudioTransport::Pcm] { + upstream_audio_transport_combo.append(Some(transport.as_id()), transport.label()); + } + upstream_audio_transport_combo.set_active_id(Some(state.upstream_audio_transport.as_id())); + upstream_audio_transport_combo.set_sensitive(true); + upstream_audio_transport_combo.set_size_request(88, -1); + upstream_audio_transport_combo.set_tooltip_text(Some( + "Upstream microphone transport for the live relay. Opus is compressed and low-bandwidth; PCM is the known-good fallback.", + )); + + upstream_transport_row.append(&webcam_transport_combo); + upstream_transport_row.append(&upstream_audio_transport_combo); + + let (preview_panel, preview_body) = + build_panel_with_action("Upstream Media", Some(upstream_transport_row.upcast_ref())); preview_panel.set_hexpand(true); preview_panel.set_vexpand(true); preview_panel.set_valign(gtk::Align::Fill); @@ -328,19 +372,7 @@ camera_preview_stack.add_named(&camera_preview_overlay, Some("live")); camera_preview_stack.set_visible_child_name("idle"); camera_preview_shell.append(&camera_preview_stack); - let webcam_transport_combo = gtk::ComboBoxText::new(); - webcam_transport_combo.add_css_class("compact-combo"); - for transport in [WebcamTransport::Hevc, WebcamTransport::Mjpeg] { - webcam_transport_combo.append(Some(transport.as_id()), transport.label()); - } - webcam_transport_combo.set_active_id(Some(state.webcam_transport.as_id())); - webcam_transport_combo.set_sensitive(true); - webcam_transport_combo.set_size_request(112, -1); - webcam_transport_combo.set_tooltip_text(Some( - "Upstream webcam transport for the next relay connection. HEVC is the low-latency default; MJPEG is the calibrated fallback.", - )); - let webcam_group = - build_subgroup_with_action("Webcam Preview", Some(webcam_transport_combo.upcast_ref())); + let webcam_group = build_subgroup("Webcam Preview"); webcam_group.set_hexpand(true); webcam_group.set_vexpand(true); webcam_group.set_valign(gtk::Align::Fill); @@ -383,6 +415,7 @@ mouse_combo, camera_channel_toggle, microphone_channel_toggle, + noise_suppression_toggle, audio_channel_toggle, audio_gain_scale, audio_gain_value, @@ -396,6 +429,7 @@ camera_preview_frame, camera_preview, webcam_transport_combo, + upstream_audio_transport_combo, camera_mirror_button, camera_mirror_revealer, camera_status, diff --git a/client/src/launcher/ui_components/build_shell.rs b/client/src/launcher/ui_components/build_shell.rs index 3ba1bae..4163de0 100644 --- a/client/src/launcher/ui_components/build_shell.rs +++ b/client/src/launcher/ui_components/build_shell.rs @@ -50,7 +50,7 @@ let (routing_chip, routing_light, routing_value) = build_status_chip_with_light("Inputs", "Local"); let (gpio_chip, gpio_light, gpio_value) = build_status_chip_with_light("GPIO", "Unknown"); - let (usb_chip, usb_light, usb_value) = build_status_chip_with_light("USB", "Unknown"); + let (usb_chip, usb_light, usb_value) = build_status_chip_with_light("HID", "Unknown"); let (uac_chip, uac_light, uac_value) = build_status_chip_with_light("UAC", "Unknown"); let (uvc_chip, uvc_light, uvc_value) = build_status_chip_with_light("UVC", "Unknown"); let (shortcut_chip, shortcut_value) = build_status_chip("Swap Key", "Pause"); diff --git a/client/src/launcher/ui_components/combo_helpers.rs b/client/src/launcher/ui_components/combo_helpers.rs index 514a6b2..476666d 100644 --- a/client/src/launcher/ui_components/combo_helpers.rs +++ b/client/src/launcher/ui_components/combo_helpers.rs @@ -253,11 +253,11 @@ fn human_audio_node_label(value: &str) -> String { } fn shorten_label(value: &str) -> String { - shorten_label_with_limit(value, 20) + shorten_label_with_limit(value, 34) } fn shorten_input_label(value: &str) -> String { - shorten_label_with_limit(value, 22) + shorten_label_with_limit(value, 34) } fn shorten_label_with_limit(value: &str, max: usize) -> String { diff --git a/client/src/launcher/ui_components/style.rs b/client/src/launcher/ui_components/style.rs index f68ddb7..64a763f 100644 --- a/client/src/launcher/ui_components/style.rs +++ b/client/src/launcher/ui_components/style.rs @@ -149,6 +149,10 @@ pub fn install_css(window: >k::ApplicationWindow) { border-radius: 999px; background: rgba(91, 179, 162, 0.88); } + scale slider { + min-width: 14px; + min-height: 14px; + } entry.server-entry { min-height: 38px; } diff --git a/client/src/launcher/ui_components/types.rs b/client/src/launcher/ui_components/types.rs index 4ef0287..d0dede2 100644 --- a/client/src/launcher/ui_components/types.rs +++ b/client/src/launcher/ui_components/types.rs @@ -140,6 +140,7 @@ pub struct LauncherWidgets { pub mouse_combo: gtk::ComboBoxText, pub camera_channel_toggle: gtk::ToggleButton, pub microphone_channel_toggle: gtk::ToggleButton, + pub noise_suppression_toggle: gtk::ToggleButton, pub audio_channel_toggle: gtk::ToggleButton, pub power_auto_button: gtk::Button, pub power_on_button: gtk::Button, @@ -164,6 +165,7 @@ pub struct LauncherWidgets { pub camera_test_button: gtk::Button, pub camera_preview_stack: gtk::Stack, pub webcam_transport_combo: gtk::ComboBoxText, + pub upstream_audio_transport_combo: gtk::ComboBoxText, pub camera_mirror_button: gtk::ToggleButton, pub camera_mirror_revealer: gtk::Revealer, pub microphone_test_button: gtk::Button, @@ -187,6 +189,7 @@ pub struct DeviceStageWidgets { pub camera_preview_frame: gtk::AspectFrame, pub camera_preview: gtk::Picture, pub webcam_transport_combo: gtk::ComboBoxText, + pub upstream_audio_transport_combo: gtk::ComboBoxText, pub camera_mirror_button: gtk::ToggleButton, pub camera_status: gtk::Label, } diff --git a/client/src/launcher/ui_runtime/control_paths.rs b/client/src/launcher/ui_runtime/control_paths.rs index 7969898..4014e5b 100644 --- a/client/src/launcher/ui_runtime/control_paths.rs +++ b/client/src/launcher/ui_runtime/control_paths.rs @@ -145,7 +145,7 @@ pub fn write_mic_gain_request(path: &Path, gain_percent: u32) -> Result<()> { pub fn write_media_control_request(path: &Path, state: &LauncherState) -> Result<()> { crate::live_media_control::write_media_control_request( path, - crate::live_media_control::MediaControlState::with_devices( + crate::live_media_control::MediaControlState::with_devices_and_audio( state.channels.camera, state.channels.microphone, state.channels.audio, @@ -153,6 +153,8 @@ pub fn write_media_control_request(path: &Path, state: &LauncherState) -> Result state.camera_quality.map(|mode| mode.id()), state.devices.microphone.clone(), state.devices.speaker.clone(), + state.upstream_audio_transport.as_common_codec(), + state.mic_noise_suppression, ), )?; Ok(()) diff --git a/client/src/launcher/ui_runtime/status_details.rs b/client/src/launcher/ui_runtime/status_details.rs index 47fb033..74f042f 100644 --- a/client/src/launcher/ui_runtime/status_details.rs +++ b/client/src/launcher/ui_runtime/status_details.rs @@ -153,13 +153,19 @@ fn recovery_uac_health( if state.server_microphone.is_none() { return (StatusLightState::Caution, "Unknown".to_string()); } + let codec = state.upstream_audio_transport.label().to_string(); if !relay_live { - return (StatusLightState::Live, "Ready".to_string()); + return (StatusLightState::Live, codec); } if !state.channels.microphone { return (StatusLightState::Idle, "Paused".to_string()); } - media_stream_health(stream, MediaStreamKind::Microphone) + let (health, label) = media_stream_health(stream, MediaStreamKind::Microphone); + if matches!(health, StatusLightState::Live) { + (health, codec) + } else { + (health, label) + } } /// Summarize whether the UVC camera function is advertised with the selected upstream codec. diff --git a/client/src/launcher/ui_runtime/status_refresh.rs b/client/src/launcher/ui_runtime/status_refresh.rs index be85ac3..b7e612c 100644 --- a/client/src/launcher/ui_runtime/status_refresh.rs +++ b/client/src/launcher/ui_runtime/status_refresh.rs @@ -98,7 +98,15 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi ); set_status_light(&widgets.summary.uac_light, uac_state); widgets.summary.uac_value.set_text(&uac_value); - widgets.summary.uac_value.set_tooltip_text(Some(&uac_value)); + widgets.summary.uac_value.set_tooltip_text(Some(&format!( + "Upstream microphone transport: {}. {}", + state.upstream_audio_transport.label(), + if state.mic_noise_suppression { + "Noise suppression is enabled before transport." + } else { + "Raw microphone audio is sent before transport." + } + ))); let (uvc_state, uvc_value) = recovery_uvc_health( state, relay_live, @@ -108,7 +116,10 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi ); set_status_light(&widgets.summary.uvc_light, uvc_state); widgets.summary.uvc_value.set_text(&uvc_value); - widgets.summary.uvc_value.set_tooltip_text(Some(&uvc_value)); + widgets.summary.uvc_value.set_tooltip_text(Some(&format!( + "Upstream webcam transport: {}. Server calibration is profile-specific.", + state.webcam_transport.label() + ))); let power_detail = if state.server_available { capture_power_detail(&state.capture_power) @@ -138,6 +149,11 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi .microphone_channel_toggle .set_active(state.channels.microphone); } + if widgets.noise_suppression_toggle.is_active() != state.mic_noise_suppression { + widgets + .noise_suppression_toggle + .set_active(state.mic_noise_suppression); + } if widgets.audio_channel_toggle.is_active() != state.channels.audio { widgets .audio_channel_toggle @@ -197,6 +213,9 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi widgets .microphone_channel_toggle .set_sensitive(!relay_live || state.devices.microphone.is_some()); + widgets + .noise_suppression_toggle + .set_sensitive(state.channels.microphone); widgets .audio_channel_toggle .set_sensitive(!relay_live || state.devices.speaker.is_some()); @@ -211,6 +230,13 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi widgets .microphone_channel_toggle .set_tooltip_text(Some(media_toggle_tooltip)); + widgets + .noise_suppression_toggle + .set_tooltip_text(Some(if state.mic_noise_suppression { + "Noise cancellation is on. Toggle to send raw microphone audio aside from gain." + } else { + "Noise cancellation is off. Toggle to suppress steady room, street, and fan noise." + })); widgets .audio_channel_toggle .set_tooltip_text(Some(media_toggle_tooltip)); @@ -243,6 +269,26 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi } else { "Choose HEVC for low-latency upstream video or MJPEG as the calibrated fallback for the next relay launch." })); + if widgets + .upstream_audio_transport_combo + .active_id() + .as_deref() + != Some(state.upstream_audio_transport.as_id()) + { + widgets + .upstream_audio_transport_combo + .set_active_id(Some(state.upstream_audio_transport.as_id())); + } + widgets + .upstream_audio_transport_combo + .set_sensitive(state.channels.microphone); + widgets + .upstream_audio_transport_combo + .set_tooltip_text(Some(if relay_live { + "Changing upstream audio transport restarts the microphone path; Opus compresses, PCM is the fallback." + } else { + "Choose Opus for compressed upstream audio or PCM as the known-good fallback." + })); widgets.input_toggle_button.set_label(match state.routing { InputRouting::Remote => "Route Local", InputRouting::Local => "Route Remote", diff --git a/client/src/live_media_control.rs b/client/src/live_media_control.rs index 44ecb3e..9580312 100644 --- a/client/src/live_media_control.rs +++ b/client/src/live_media_control.rs @@ -8,6 +8,7 @@ use std::{ }; use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; +use lesavka_common::audio_transport::{UpstreamAudioCodec, parse_upstream_audio_codec}; pub const MEDIA_CONTROL_ENV: &str = "LESAVKA_MEDIA_CONTROL"; pub const DEFAULT_MEDIA_CONTROL_PATH: &str = "/tmp/lesavka-media.control"; @@ -19,6 +20,54 @@ pub(crate) enum MediaDeviceChoice { Selected(String), } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum MediaAudioCodecChoice { + Inherit, + Selected(UpstreamAudioCodec), +} + +impl MediaAudioCodecChoice { + #[must_use] + pub fn selected(codec: UpstreamAudioCodec) -> Self { + Self::Selected(codec) + } + + #[must_use] + pub fn resolve(&self, fallback: UpstreamAudioCodec) -> UpstreamAudioCodec { + match self { + Self::Inherit => fallback, + Self::Selected(codec) => *codec, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum MediaNoiseSuppressionChoice { + Inherit, + Enabled, + Disabled, +} + +impl MediaNoiseSuppressionChoice { + #[must_use] + pub fn selected(enabled: bool) -> Self { + if enabled { + Self::Enabled + } else { + Self::Disabled + } + } + + #[must_use] + pub fn resolve(&self, fallback: bool) -> bool { + match self { + Self::Inherit => fallback, + Self::Enabled => true, + Self::Disabled => false, + } + } +} + impl MediaDeviceChoice { #[must_use] pub fn from_selection(selection: Option) -> Self { @@ -49,6 +98,8 @@ pub(crate) struct MediaControlState { pub camera_profile: MediaDeviceChoice, pub microphone_source: MediaDeviceChoice, pub audio_sink: MediaDeviceChoice, + pub audio_codec: MediaAudioCodecChoice, + pub noise_suppression: MediaNoiseSuppressionChoice, } impl MediaControlState { @@ -62,6 +113,8 @@ impl MediaControlState { camera_profile: MediaDeviceChoice::Inherit, microphone_source: MediaDeviceChoice::Inherit, audio_sink: MediaDeviceChoice::Inherit, + audio_codec: MediaAudioCodecChoice::Inherit, + noise_suppression: MediaNoiseSuppressionChoice::Inherit, } } @@ -83,6 +136,36 @@ impl MediaControlState { camera_profile: MediaDeviceChoice::from_selection(camera_profile), microphone_source: MediaDeviceChoice::from_selection(microphone_source), audio_sink: MediaDeviceChoice::from_selection(audio_sink), + audio_codec: MediaAudioCodecChoice::Inherit, + noise_suppression: MediaNoiseSuppressionChoice::Inherit, + } + } + + #[must_use] + #[allow(clippy::too_many_arguments)] + pub fn with_devices_and_audio( + camera: bool, + microphone: bool, + audio: bool, + camera_source: Option, + camera_profile: Option, + microphone_source: Option, + audio_sink: Option, + audio_codec: UpstreamAudioCodec, + noise_suppression: bool, + ) -> Self { + Self { + audio_codec: MediaAudioCodecChoice::selected(audio_codec), + noise_suppression: MediaNoiseSuppressionChoice::selected(noise_suppression), + ..Self::with_devices( + camera, + microphone, + audio, + camera_source, + camera_profile, + microphone_source, + audio_sink, + ) } } } @@ -134,7 +217,7 @@ pub(crate) fn write_media_control_request( fs::write( path, format!( - "camera={} microphone={} audio={} camera_source={} camera_profile={} microphone_source={} audio_sink={} nonce={}\n", + "camera={} microphone={} audio={} camera_source={} camera_profile={} microphone_source={} audio_sink={} audio_codec={} noise_suppression={} nonce={}\n", bool_flag(state.camera), bool_flag(state.microphone), bool_flag(state.audio), @@ -142,6 +225,8 @@ pub(crate) fn write_media_control_request( encode_choice(&state.camera_profile), encode_choice(&state.microphone_source), encode_choice(&state.audio_sink), + encode_audio_codec_choice(&state.audio_codec), + encode_noise_suppression_choice(&state.noise_suppression), control_request_nonce(), ), ) @@ -156,6 +241,8 @@ fn parse_media_control_state(raw: &str) -> Option { let mut camera_profile = MediaDeviceChoice::Inherit; let mut microphone_source = MediaDeviceChoice::Inherit; let mut audio_sink = MediaDeviceChoice::Inherit; + let mut audio_codec = MediaAudioCodecChoice::Inherit; + let mut noise_suppression = MediaNoiseSuppressionChoice::Inherit; for token in raw.split_ascii_whitespace() { let Some((key, value)) = token.split_once('=') else { continue; @@ -170,6 +257,12 @@ fn parse_media_control_state(raw: &str) -> Option { microphone_source = parse_choice(value)?; } "audio_sink" | "speaker_sink" | "audio_sink_b64" => audio_sink = parse_choice(value)?, + "audio_codec" | "uplink_audio_codec" => { + audio_codec = parse_audio_codec_choice(value)?; + } + "noise_suppression" | "mic_noise_suppression" => { + noise_suppression = parse_noise_suppression_choice(value)?; + } _ => {} } } @@ -181,6 +274,8 @@ fn parse_media_control_state(raw: &str) -> Option { camera_profile, microphone_source, audio_sink, + audio_codec, + noise_suppression, }) } @@ -212,6 +307,36 @@ fn parse_choice(value: &str) -> Option { Some(MediaDeviceChoice::from_selection(Some(value.to_string()))) } +fn encode_audio_codec_choice(choice: &MediaAudioCodecChoice) -> &'static str { + match choice { + MediaAudioCodecChoice::Inherit => "inherit", + MediaAudioCodecChoice::Selected(codec) => codec.as_id(), + } +} + +fn parse_audio_codec_choice(value: &str) -> Option { + let value = value.trim(); + if value.eq_ignore_ascii_case("inherit") || value.is_empty() { + return Some(MediaAudioCodecChoice::Inherit); + } + parse_upstream_audio_codec(value).map(MediaAudioCodecChoice::Selected) +} + +fn encode_noise_suppression_choice(choice: &MediaNoiseSuppressionChoice) -> &'static str { + match choice { + MediaNoiseSuppressionChoice::Inherit => "inherit", + MediaNoiseSuppressionChoice::Enabled => "1", + MediaNoiseSuppressionChoice::Disabled => "0", + } +} + +fn parse_noise_suppression_choice(value: &str) -> Option { + if value.trim().eq_ignore_ascii_case("inherit") || value.trim().is_empty() { + return Some(MediaNoiseSuppressionChoice::Inherit); + } + parse_bool_flag(value).map(MediaNoiseSuppressionChoice::selected) +} + /// Keeps `parse_bool_flag` explicit because it sits on this module contract, where hidden behavior would make regressions difficult to diagnose. /// Inputs are the typed parameters; output is the return value or side effect. fn parse_bool_flag(value: &str) -> Option { diff --git a/client/src/main.rs b/client/src/main.rs index b194ce2..6ccd2bf 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -25,6 +25,12 @@ async fn main() -> Result<()> { #[cfg(not(test))] let args = env::args().skip(1).collect::>(); + #[cfg(not(test))] + if args.iter().any(|arg| arg == "--version" || arg == "-V") { + println!("lesavka-client {}", lesavka_client::VERSION); + return Ok(()); + } + let headless = env::var("LESAVKA_HEADLESS").is_ok(); if !headless { ensure_runtime_dir(); diff --git a/client/src/output/video/monitor_window.rs b/client/src/output/video/monitor_window.rs index ed19755..b781b69 100644 --- a/client/src/output/video/monitor_window.rs +++ b/client/src/output/video/monitor_window.rs @@ -11,6 +11,12 @@ use std::process::Command; use tracing::{debug, error, info, warn}; /// Pick the first H.264 decoder that can be built on this client. +/// +/// Inputs: optional `LESAVKA_H264_DECODER` override and +/// `LESAVKA_H264_DECODER_PREFERENCE=hardware|software`. Output: a decoder +/// element name. Why: breakout windows should benefit from NVIDIA proprietary +/// decode when it is present, while keeping VAAPI/V4L2 and CPU routes usable +/// for open-source-driver machines and debugging. fn pick_h264_decoder() -> String { if let Ok(raw) = std::env::var("LESAVKA_H264_DECODER") { let name = raw.trim(); @@ -22,16 +28,7 @@ fn pick_h264_decoder() -> String { } } - for name in [ - "avdec_h264", - "openh264dec", - "nvh264dec", - "nvh264sldec", - "vah264dec", - "vaapih264dec", - "v4l2h264dec", - "v4l2slh264dec", - ] { + for name in h264_decoder_preference_order() { if buildable_decoder(name) { return name.to_string(); } @@ -40,6 +37,43 @@ fn pick_h264_decoder() -> String { "decodebin".to_string() } +/// Return automatic decoder candidates in the same order breakout windows use. +/// +/// Inputs: `LESAVKA_H264_DECODER_PREFERENCE`, if set. Output: ordered decoder +/// element names. Why: include-based tests need to protect the same hardware +/// and software route order as the launcher preview path. +fn h264_decoder_preference_order() -> Vec<&'static str> { + const HARDWARE: &[&str] = &[ + "nvh264dec", + "nvh264sldec", + "vah264dec", + "vaapih264dec", + "v4l2h264dec", + "v4l2slh264dec", + ]; + const SOFTWARE: &[&str] = &["avdec_h264", "openh264dec"]; + + let prefer_software = std::env::var("LESAVKA_H264_DECODER_PREFERENCE") + .ok() + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "software" | "sw" | "cpu" + ) + }) + .unwrap_or(false); + + let mut candidates = Vec::with_capacity(HARDWARE.len() + SOFTWARE.len()); + if prefer_software { + candidates.extend_from_slice(SOFTWARE); + candidates.extend_from_slice(HARDWARE); + } else { + candidates.extend_from_slice(HARDWARE); + candidates.extend_from_slice(SOFTWARE); + } + candidates +} + #[cfg(coverage)] /// Probe decoder availability, with a coverage hook for fallback behavior. fn buildable_decoder(name: &str) -> bool { diff --git a/client/src/sync_probe/runner/bundled_transport.rs b/client/src/sync_probe/runner/bundled_transport.rs index bc7ecc6..bd63930 100644 --- a/client/src/sync_probe/runner/bundled_transport.rs +++ b/client/src/sync_probe/runner/bundled_transport.rs @@ -185,6 +185,7 @@ fn build_probe_bundle( audio, audio_sample_rate: 48_000, audio_channels: 2, + audio_encoding: lesavka_common::lesavka::AudioEncoding::PcmS16le as i32, video_width: camera.width, video_height: camera.height, video_fps: camera.fps, diff --git a/client/src/video_support.rs b/client/src/video_support.rs index 7cebc5a..38deda1 100644 --- a/client/src/video_support.rs +++ b/client/src/video_support.rs @@ -5,11 +5,13 @@ use gstreamer as gst; /// Pick the client-side H.264 decoder in a predictable preference order. /// /// Inputs: none, though operators may override the choice with -/// `LESAVKA_H264_DECODER=`. +/// `LESAVKA_H264_DECODER=` or bias automatic fallback order with +/// `LESAVKA_H264_DECODER_PREFERENCE=hardware|software`. /// Outputs: the chosen decoder element name, or `decodebin` as a last-resort /// fallback when no explicit decoder is present. -/// Why: `decodebin` is flexible, but a stable preference order makes decode -/// behavior easier to reason about and compare in diagnostics. +/// Why: Lesavka should use GPU decode on NVIDIA/VAAPI/V4L2-capable clients +/// when possible, while keeping an explicit CPU route for open-source driver +/// comparisons and driver debugging. #[must_use] pub fn pick_h264_decoder() -> String { if let Ok(raw) = std::env::var("LESAVKA_H264_DECODER") { @@ -22,16 +24,7 @@ pub fn pick_h264_decoder() -> String { } } - for name in [ - "avdec_h264", - "openh264dec", - "nvh264dec", - "nvh264sldec", - "vah264dec", - "vaapih264dec", - "v4l2h264dec", - "v4l2slh264dec", - ] { + for name in h264_decoder_preference_order() { if buildable_decoder(name) { return name.to_string(); } @@ -40,6 +33,44 @@ pub fn pick_h264_decoder() -> String { "decodebin".to_string() } +/// Return automatic H.264 decoder candidates in selection order. +/// +/// Inputs: `LESAVKA_H264_DECODER_PREFERENCE`, if set. Output: ordered decoder +/// element names. Why: tests and diagnostics need to prove both proprietary +/// NVIDIA and open-source VAAPI/V4L2 routes stay available before CPU fallback. +#[must_use] +pub fn h264_decoder_preference_order() -> Vec<&'static str> { + const HARDWARE: &[&str] = &[ + "nvh264dec", + "nvh264sldec", + "vah264dec", + "vaapih264dec", + "v4l2h264dec", + "v4l2slh264dec", + ]; + const SOFTWARE: &[&str] = &["avdec_h264", "openh264dec"]; + + let prefer_software = std::env::var("LESAVKA_H264_DECODER_PREFERENCE") + .ok() + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "software" | "sw" | "cpu" + ) + }) + .unwrap_or(false); + + let mut candidates = Vec::with_capacity(HARDWARE.len() + SOFTWARE.len()); + if prefer_software { + candidates.extend_from_slice(SOFTWARE); + candidates.extend_from_slice(HARDWARE); + } else { + candidates.extend_from_slice(HARDWARE); + candidates.extend_from_slice(SOFTWARE); + } + candidates +} + fn buildable_decoder(name: &str) -> bool { #[cfg(coverage)] if std::env::var("TEST_FAIL_GST_INIT").is_ok() { diff --git a/common/Cargo.toml b/common/Cargo.toml index 3b4970e..02ddbc6 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.21.17" +version = "0.22.1" edition = "2024" build = "build.rs" diff --git a/common/proto/lesavka.proto b/common/proto/lesavka.proto index fbbf19f..c2aa24f 100644 --- a/common/proto/lesavka.proto +++ b/common/proto/lesavka.proto @@ -33,6 +33,11 @@ message VideoPacket { uint32 client_queue_depth = 15; uint32 client_queue_age_ms = 16; } +enum AudioEncoding { + AUDIO_UNSPECIFIED = 0; + PCM_S16LE = 1; + OPUS = 2; +} message AudioPacket { uint32 id = 1; uint64 pts = 2; @@ -44,6 +49,12 @@ message AudioPacket { uint64 client_send_pts_us = 6; uint32 client_queue_depth = 7; uint32 client_queue_age_ms = 8; + // Explicit payload encoding. Older/unstamped packets are interpreted as + // PCM_S16LE so the current stable raw-audio path stays backward compatible. + AudioEncoding encoding = 9; + uint32 sample_rate = 10; + uint32 channels = 11; + uint32 frame_duration_us = 12; } message UpstreamMediaBundle { @@ -61,6 +72,9 @@ message UpstreamMediaBundle { uint32 video_width = 9; uint32 video_height = 10; uint32 video_fps = 11; + // Batch-level audio encoding. This mirrors the packet encoding so receivers + // can route a bundle before inspecting every audio payload. + AudioEncoding audio_encoding = 12; } message OutputDelayProbeRequest { diff --git a/common/src/audio_transport.rs b/common/src/audio_transport.rs new file mode 100644 index 0000000..4044fa3 --- /dev/null +++ b/common/src/audio_transport.rs @@ -0,0 +1,294 @@ +//! Shared audio-transport metadata helpers. +//! +//! Why: upstream audio is currently raw PCM, but Opus experiments need an +//! explicit schema and sizing model so client, server, and tests agree before +//! any production switch is made. + +use crate::lesavka::{AudioEncoding, AudioPacket, UpstreamMediaBundle}; + +pub const PCM_SAMPLE_RATE: u32 = 48_000; +pub const PCM_CHANNELS: u32 = 2; +pub const PCM_FRAME_DURATION_US: u32 = 20_000; +pub const OPUS_SAMPLE_RATE: u32 = 48_000; +pub const OPUS_CHANNELS: u32 = 2; +pub const OPUS_FRAME_DURATION_US: u32 = 20_000; +pub const OPUS_DEFAULT_BITRATE_BPS: u32 = 64_000; + +/// Operator-facing upstream audio transport choice. +/// +/// Inputs: a UI/env/control-file token. Output: either the stable raw PCM route +/// or the compressed Opus route. Why: both ends need the same vocabulary so +/// Opus can be tried without weakening the known-good PCM fallback path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpstreamAudioCodec { + PcmS16le, + Opus, +} + +impl UpstreamAudioCodec { + /// Return the compact id stored in control files and environment values. + #[must_use] + pub const fn as_id(self) -> &'static str { + match self { + Self::PcmS16le => "pcm", + Self::Opus => "opus", + } + } + + /// Return a short label suitable for the launcher UI and diagnostics. + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::PcmS16le => "PCM", + Self::Opus => "Opus", + } + } + + /// Return the packet profile this transport should emit. + #[must_use] + pub const fn profile(self) -> AudioTransportProfile { + match self { + Self::PcmS16le => AudioTransportProfile::pcm_s16le(), + Self::Opus => AudioTransportProfile::opus_voice(), + } + } +} + +/// Parse a launcher/env codec token into an upstream audio transport. +#[must_use] +pub fn parse_upstream_audio_codec(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "pcm" | "pcm_s16le" | "s16le" | "raw" | "uncompressed" => { + Some(UpstreamAudioCodec::PcmS16le) + } + "opus" | "compressed" | "voice" => Some(UpstreamAudioCodec::Opus), + _ => None, + } +} + +/// A normalized description of the payload carried by `AudioPacket::data`. +/// +/// Inputs are codec metadata resolved from a packet or bundle. Outputs are the +/// exact framing values the transport should preserve. Why: the Opus path must +/// compare bandwidth and timing without weakening the known-good PCM fallback. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AudioTransportProfile { + pub encoding: AudioEncoding, + pub sample_rate: u32, + pub channels: u32, + pub frame_duration_us: u32, + pub target_bitrate_bps: Option, +} + +impl AudioTransportProfile { + /// Return the stable raw PCM profile used by today's upstream microphone path. + #[must_use] + pub const fn pcm_s16le() -> Self { + Self { + encoding: AudioEncoding::PcmS16le, + sample_rate: PCM_SAMPLE_RATE, + channels: PCM_CHANNELS, + frame_duration_us: PCM_FRAME_DURATION_US, + target_bitrate_bps: None, + } + } + + /// Return the first Opus profile Lesavka should test for upstream audio. + /// + /// Inputs: none. Outputs: a 48 kHz stereo, 20 ms, 64 kbps voice-oriented + /// profile. Why: Opus always runs internally at 48 kHz, and 20 ms frames + /// keep latency bounded while still giving the codec enough lookahead. + #[must_use] + pub const fn opus_voice() -> Self { + Self { + encoding: AudioEncoding::Opus, + sample_rate: OPUS_SAMPLE_RATE, + channels: OPUS_CHANNELS, + frame_duration_us: OPUS_FRAME_DURATION_US, + target_bitrate_bps: Some(OPUS_DEFAULT_BITRATE_BPS), + } + } + + /// Estimate the payload budget for one frame in this transport profile. + /// + /// Inputs: profile fields. Output: bytes per frame. Why: performance tests + /// need a stable, codec-independent way to compare PCM debt against Opus. + #[must_use] + pub fn expected_payload_bytes(self) -> u32 { + match self.encoding { + AudioEncoding::PcmS16le | AudioEncoding::AudioUnspecified => { + let samples = u64::from(self.sample_rate) + .saturating_mul(u64::from(self.frame_duration_us)) + / 1_000_000; + samples + .saturating_mul(u64::from(self.channels)) + .saturating_mul(2) + .min(u64::from(u32::MAX)) as u32 + } + AudioEncoding::Opus => { + let bits = u64::from(self.target_bitrate_bps.unwrap_or(OPUS_DEFAULT_BITRATE_BPS)) + .saturating_mul(u64::from(self.frame_duration_us)) + / 1_000_000; + bits.div_ceil(8).min(u64::from(u32::MAX)) as u32 + } + } + } +} + +/// Interpret unset/legacy audio encoding metadata as the stable PCM transport. +/// +/// Inputs: raw proto enum integer. Output: normalized encoding. Why: +/// `AUDIO_UNSPECIFIED` exists for wire compatibility but should not leave +/// callers guessing whether a payload is safe for the raw UAC sink. +#[must_use] +pub fn normalize_audio_encoding(raw: i32) -> AudioEncoding { + match AudioEncoding::try_from(raw).unwrap_or(AudioEncoding::AudioUnspecified) { + AudioEncoding::AudioUnspecified | AudioEncoding::PcmS16le => AudioEncoding::PcmS16le, + AudioEncoding::Opus => AudioEncoding::Opus, + } +} + +/// Resolve the packet-level transport profile. +/// +/// Inputs: a protobuf audio packet. Output: a normalized profile. Why: callers +/// should not need to duplicate the legacy-PCM fallback or Opus defaults. +#[must_use] +pub fn packet_audio_profile(packet: &AudioPacket) -> AudioTransportProfile { + match normalize_audio_encoding(packet.encoding) { + AudioEncoding::Opus => AudioTransportProfile { + encoding: AudioEncoding::Opus, + sample_rate: nonzero_or(packet.sample_rate, OPUS_SAMPLE_RATE), + channels: nonzero_or(packet.channels, OPUS_CHANNELS), + frame_duration_us: nonzero_or(packet.frame_duration_us, OPUS_FRAME_DURATION_US), + target_bitrate_bps: Some(OPUS_DEFAULT_BITRATE_BPS), + }, + AudioEncoding::PcmS16le | AudioEncoding::AudioUnspecified => AudioTransportProfile { + encoding: AudioEncoding::PcmS16le, + sample_rate: nonzero_or(packet.sample_rate, PCM_SAMPLE_RATE), + channels: nonzero_or(packet.channels, PCM_CHANNELS), + frame_duration_us: nonzero_or(packet.frame_duration_us, PCM_FRAME_DURATION_US), + target_bitrate_bps: None, + }, + } +} + +/// Resolve the bundle-level transport profile from the bundle marker or packets. +/// +/// Inputs: an upstream media bundle. Output: the audio profile receivers should +/// expect. Why: the server needs to route the whole A/V bundle without splitting +/// audio away from video just to discover its codec. +#[must_use] +pub fn bundle_audio_profile(bundle: &UpstreamMediaBundle) -> AudioTransportProfile { + let raw = if bundle.audio_encoding != 0 { + bundle.audio_encoding + } else { + bundle + .audio + .first() + .map(|packet| packet.encoding) + .unwrap_or_default() + }; + match normalize_audio_encoding(raw) { + AudioEncoding::Opus => AudioTransportProfile { + encoding: AudioEncoding::Opus, + sample_rate: nonzero_or(bundle.audio_sample_rate, OPUS_SAMPLE_RATE), + channels: nonzero_or(bundle.audio_channels, OPUS_CHANNELS), + frame_duration_us: bundle + .audio + .first() + .map(|packet| nonzero_or(packet.frame_duration_us, OPUS_FRAME_DURATION_US)) + .unwrap_or(OPUS_FRAME_DURATION_US), + target_bitrate_bps: Some(OPUS_DEFAULT_BITRATE_BPS), + }, + AudioEncoding::PcmS16le | AudioEncoding::AudioUnspecified => AudioTransportProfile { + encoding: AudioEncoding::PcmS16le, + sample_rate: nonzero_or(bundle.audio_sample_rate, PCM_SAMPLE_RATE), + channels: nonzero_or(bundle.audio_channels, PCM_CHANNELS), + frame_duration_us: bundle + .audio + .first() + .map(|packet| nonzero_or(packet.frame_duration_us, PCM_FRAME_DURATION_US)) + .unwrap_or(PCM_FRAME_DURATION_US), + target_bitrate_bps: None, + }, + } +} + +/// Stamp a packet as raw PCM with the current stable microphone framing. +/// +/// Inputs: mutable packet. Output: side-effect only. Why: explicit metadata lets +/// future Opus tests distinguish today's known-good path from experimental +/// compressed audio without relying on payload-size guesses. +pub fn mark_packet_pcm_s16le(packet: &mut AudioPacket) { + packet.encoding = AudioEncoding::PcmS16le as i32; + packet.sample_rate = PCM_SAMPLE_RATE; + packet.channels = PCM_CHANNELS; + if packet.frame_duration_us == 0 { + packet.frame_duration_us = PCM_FRAME_DURATION_US; + } +} + +/// Stamp a packet as Opus with the current low-latency voice framing. +/// +/// Inputs: mutable packet. Output: side-effect only. Why: receivers must never +/// infer compressed audio from payload size, especially when PCM fallback can +/// intentionally coexist with Opus during startup. +pub fn mark_packet_opus(packet: &mut AudioPacket) { + packet.encoding = AudioEncoding::Opus as i32; + packet.sample_rate = OPUS_SAMPLE_RATE; + packet.channels = OPUS_CHANNELS; + if packet.frame_duration_us == 0 { + packet.frame_duration_us = OPUS_FRAME_DURATION_US; + } +} + +/// Stamp a bundle with normalized audio metadata. +/// +/// Inputs: mutable bundle. Output: side-effect only. Why: the server should be +/// able to choose the PCM or future Opus handoff path from the bundle envelope. +pub fn mark_bundle_audio_profile(bundle: &mut UpstreamMediaBundle, profile: AudioTransportProfile) { + bundle.audio_encoding = profile.encoding as i32; + bundle.audio_sample_rate = profile.sample_rate; + bundle.audio_channels = profile.channels; +} + +/// True when a packet can be handed directly to the raw UAC appsrc. +#[must_use] +pub fn packet_is_raw_pcm_s16le(packet: &AudioPacket) -> bool { + packet_audio_profile(packet).encoding == AudioEncoding::PcmS16le +} + +const fn nonzero_or(value: u32, fallback: u32) -> u32 { + if value == 0 { fallback } else { value } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pcm_and_opus_profiles_keep_expected_frame_budgets() { + assert_eq!( + AudioTransportProfile::pcm_s16le().expected_payload_bytes(), + 3840 + ); + assert_eq!( + AudioTransportProfile::opus_voice().expected_payload_bytes(), + 160 + ); + assert!( + AudioTransportProfile::opus_voice().expected_payload_bytes() * 20 + <= AudioTransportProfile::pcm_s16le().expected_payload_bytes(), + "Opus profile should be dramatically smaller than raw PCM" + ); + } + + #[test] + fn legacy_packets_normalize_to_pcm() { + let packet = AudioPacket::default(); + let profile = packet_audio_profile(&packet); + assert_eq!(profile.encoding, AudioEncoding::PcmS16le); + assert_eq!(profile.sample_rate, 48_000); + assert_eq!(profile.channels, 2); + } +} diff --git a/common/src/lib.rs b/common/src/lib.rs index cf58858..b239932 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -2,6 +2,7 @@ // Re-export the code generated by build.rs (lesavka.rs, relay.rs, etc.) // common/src/lib.rs +pub mod audio_transport; pub mod cli; pub mod eye_source; pub mod hid; diff --git a/docs/operational-env.md b/docs/operational-env.md index f5a25f1..8acfd73 100644 --- a/docs/operational-env.md +++ b/docs/operational-env.md @@ -10,9 +10,11 @@ and makes it readable only by the installing account. The client installer first from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the installed `ca.crt`, `client.crt`, and `client.key` for HTTPS/mTLS relay connections. +| `LESAVKA_ALLOW_DISRUPTIVE_INPUT_TESTS` | test-only opt-in for virtual keyboard/mouse tests that can emit events into the active desktop; use only on isolated CI workers or lab sessions | | `LESAVKA_ALLOW_GADGET_CYCLE` | document near use before promoting to operator config | | `LESAVKA_ALLOW_GADGET_RESET` | permits installer/recovery flows to reset the USB gadget when a hard rebuild is otherwise required; by itself it must not force a live attached-host reset | | `LESAVKA_ALLOW_INSECURE` | client transport override; permits non-local `http://` relay URLs only for lab/debug use | +| `LESAVKA_ALLOW_LAB_HARDWARE_TESTS` | CI/lab safety opt-in before running bare-metal gates that may touch Theia/Tethys, UVC/UAC devices, or virtual HID input | | `LESAVKA_ALSA_DEV` | server hardware/device override | | `LESAVKA_ATTACH_WRITE_UDC` | server hardware/device override | | `LESAVKA_AUDIO_AUTO_RECOVER_AFTER` | client media capture/playback override | @@ -77,6 +79,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_CLIENT_RCT_MAX_AGE_MS` | manual client-to-RCT transport probe freshness limit; maximum client-origin-to-RCT-observed p95 age including clock uncertainty, defaults to `1000` | | `LESAVKA_CLIENT_RCT_MIN_PAIRS` | manual client-to-RCT transport probe evidence floor; minimum paired flash/tone events before freshness can pass, defaults to `13` | | `LESAVKA_CLIENT_RCT_MODE` | manual client-to-RCT transport probe expected RCT UVC mode in `WIDTHxHEIGHT@FPS` form, or `auto` to read the current gadget profile; defaults to `auto` and does not reconfigure the gadget | +| `LESAVKA_CLIENT_RCT_PROBE_CMD` | bare-metal lab gate command override for the client-to-RCT transport probe; use only on isolated lab workers | | `LESAVKA_CLIENT_RCT_REQUIRE_SMOOTHNESS` | manual client-to-RCT transport probe gate toggle; when `1`, cadence hiccups fail the transport summary instead of reporting warnings | | `LESAVKA_CLIENT_RCT_SYNC_SAMPLE_INTERVAL_SECONDS` | manual client-to-RCT transport probe introspection interval; controls how often the harness samples server `upstream-sync` state while the client-origin probe is live | | `LESAVKA_CLIENT_RCT_UVC_FRAME_META_LOG_REMOTE` | manual client-to-RCT transport probe optional artifact path; fetches a pre-enabled server `LESAVKA_UVC_FRAME_META_LOG_PATH` JSONL and summarizes UVC spool-boundary timing | @@ -84,6 +87,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `PROBE_EVENT_WIDTH_CODES` | manual client-to-RCT transport probe identity sequence; defaults to unique codes `1..16` so final RCT observations can be joined to client-origin timeline events after startup drops | | `LESAVKA_CLIENT_RELAYCTL_BIN_SRC` | test/build contract variable; not runtime operator config | | `LESAVKA_CLIENT_VIDEO_SUPPORT_SRC` | test/build contract variable; not runtime operator config | +| `LESAVKA_CI_PROFILE` | Jenkins/CI profile selector; `safe` runs non-disruptive gates, `daily` wraps primary-branch daily gates, and `lab` is reserved for isolated bare-metal probes | | `LESAVKA_CLIPBOARD_CHORD` | input routing/clipboard override | | `LESAVKA_CLIPBOARD_CMD` | input routing/clipboard override | | `LESAVKA_CLIPBOARD_DEBOUNCE_MS` | input routing/clipboard override | @@ -98,6 +102,8 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_DECODER_PROBE_WAIT_SECONDS` | manual probe override | | `LESAVKA_DETACH_CLEAR_UDC` | server hardware/device override | | `LESAVKA_DEV_MODE` | document near use before promoting to operator config | +| `LESAVKA_DAILY_ALLOW_NON_PRIMARY` | CI override that lets `daily_master_gate.sh` run on non-`master`/`main` branches for debugging the daily profile | +| `LESAVKA_DAILY_GATE_PUSHGATEWAY_JOB` | CI metrics destination override for the daily master gate profile | | `LESAVKA_DISABLE_UAC` | document near use before promoting to operator config | | `LESAVKA_DISABLE_UVC` | document near use before promoting to operator config | | `LESAVKA_DISABLE_VIDEO_RENDER` | eye preview/video transport override | @@ -122,7 +128,8 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_GADGET_FORCE_CYCLE` | server hardware/device override | | `LESAVKA_GADGET_SYSFS_ROOT` | server hardware/device override | | `LESAVKA_GIT_SHA` | runtime/install/session override | -| `LESAVKA_H264_DECODER` | eye preview/video transport override | +| `LESAVKA_H264_DECODER` | eye preview/video transport override; names an explicit GStreamer decoder such as `nvh264dec`, `v4l2h264dec`, `avdec_h264`, or `decodebin` | +| `LESAVKA_H264_DECODER_PREFERENCE` | eye preview/video transport override; `hardware`/unset prefers NVIDIA, VAAPI, and V4L2 decode before CPU fallback, while `software`/`cpu` keeps software first for driver comparison | | `LESAVKA_HDMI_CONNECTOR` | server hardware/device override | | `LESAVKA_HDMI_DRIVER` | server hardware/device override | | `LESAVKA_HDMI_FBDEV` | server hardware/device override | @@ -171,6 +178,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_LAUNCHER_PARENT_START_TICKS` | launcher UI/runtime override | | `LESAVKA_LAUNCHER_TOGGLE_KEY_CONTROL` | launcher UI/runtime override | | `LESAVKA_LAUNCHER_WINDOW_TITLE` | launcher UI/runtime override | +| `LESAVKA_LAB_GATE_PUSHGATEWAY_JOB` | CI metrics destination override for the opt-in bare-metal lab gate profile | | `LESAVKA_LIVE_KEYBOARD_REPORT_DELAY_MS` | input routing/clipboard override | | `LESAVKA_LIVE_MODIFIER_DELAY_MS` | input routing/clipboard override | | `LESAVKA_MAX_SPEED` | document near use before promoting to operator config | @@ -217,6 +225,9 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_REPO_URL` | runtime/install/session override | | `LESAVKA_REQUIRE_TLS` | server security override; require TLS credentials before binding public relay service | | `LESAVKA_RGBA` | document near use before promoting to operator config | +| `LESAVKA_RUN_CLIENT_RCT_PROBE` | bare-metal lab gate toggle for running the client-to-RCT transport probe; defaults off so shared CI never contacts lab hardware | +| `LESAVKA_RUN_SERVER_RCT_MATRIX` | bare-metal lab gate toggle for running the server-to-RCT mode matrix; defaults off because it can reconfigure attached UVC/UAC hardware | +| `LESAVKA_RUN_VIDEO_DOWNSTREAM_GATE` | bare-metal lab gate toggle for deterministic downstream video guard tests; defaults on inside the lab gate | | `LESAVKA_SERVER_ADDR` | runtime/install/session override | | `LESAVKA_SERVER_BIND_ADDR` | server bind address override; defaults to `0.0.0.0:50051` | | `LESAVKA_INSTALL_SERVER_BIND_ADDR` | installer override; sets the persisted default server bind address in `/etc/lesavka/server.env` | @@ -366,7 +377,8 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta These entries are intentionally concise because most are manual lab or CI harness controls. The detailed behavior lives in the scripts and source that consume them; this table keeps every `LESAVKA_*` knob discoverable for operators and the hygiene gate. | `LESAVKA_CAM_EMIT_UI_PROFILE` | client camera/profile negotiation override; used by launcher or lab probes to control emitted capture profile metadata | -| `LESAVKA_CALIBRATION_PROFILE` | server calibration profile override (`mjpeg` or `hevc`); selects profile-specific server-to-RCT offset maps | +| `LESAVKA_CALIBRATION_AUDIO_CODEC` | server calibration profile override for upstream microphone transport (`pcm` or `opus`); keeps Opus delay experiments separate from known-good PCM baselines | +| `LESAVKA_CALIBRATION_PROFILE` | server calibration profile override (`mjpeg`, `hevc`, or combined `camera+audio` such as `hevc+opus`); selects profile-specific server-to-RCT offset maps | | `LESAVKA_CAM_LOCK_TO_SERVER_PROFILE` | client camera/profile negotiation override; used by launcher or lab probes to control emitted capture profile metadata | | `LESAVKA_CAM_HEVC_KBIT` | client HEVC camera encoder bitrate in kbit/s; defaults to `3000` for latency-first upstream transport | | `LESAVKA_CLIENT_RCT_START_DELAY_SECONDS` | manual client-to-RCT transport probe start delay; lets installed server changes settle before capture starts | @@ -407,6 +419,7 @@ These entries are intentionally concise because most are manual lab or CI harnes | `LESAVKA_INSTALL_UVC_FRAME_META_LOG_PATH` | server installer diagnostic path; persists `LESAVKA_UVC_FRAME_META_LOG_PATH`, defaults to `/tmp/lesavka-uvc-frame-meta.jsonl` for optional client-to-RCT spool-boundary fetches | | `LESAVKA_INSTALL_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US` | installer default override; seeds server calibration env files with known lab-measured output-path offsets | | `LESAVKA_INSTALL_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US` | installer default override; seeds server calibration env files with known lab-measured output-path offsets | +| `LESAVKA_INSTALL_UPLINK_AUDIO_CODEC` | server installer microphone transport profile seed (`pcm` or `opus`); defaults to `pcm` so reruns preserve the established golden calibration unless Opus is explicitly selected | | `LESAVKA_LEGACY_SPLIT_UPLINK` | runtime/install/session override; document near use before promoting to broader operator config | | `LESAVKA_LOCAL_HEVC_BUNDLE_AUDIT_JSON` | local HEVC bundle audit output path; receives the generated JSON manifest for outgoing synthetic HEVC+audio bundles | | `LESAVKA_LOCAL_HEVC_BUNDLE_AUDIT_OUTPUT_DIR` | local HEVC bundle audit artifact directory, defaults to a timestamped `/tmp/lesavka-local-hevc-bundle-audit-*` path | @@ -514,6 +527,7 @@ These entries are intentionally concise because most are manual lab or CI harnes | `LESAVKA_SERVER_RC_TUNE_MIN_PAIRS` | manual server-to-RCT mode-matrix probe override; used to tune, confirm, or summarize server-generated UVC/UAC output against the RC capture target | | `LESAVKA_SERVER_RC_VERBOSE_PROBES` | manual server-to-RCT mode-matrix probe override; used to tune, confirm, or summarize server-generated UVC/UAC output against the RC capture target | | `LESAVKA_SERVER_RC_WAIT_TETHYS_READY` | manual server-to-RCT mode-matrix probe override; used to tune, confirm, or summarize server-generated UVC/UAC output against the RC capture target | +| `LESAVKA_SERVER_RCT_MATRIX_CMD` | bare-metal lab gate command override for the server-to-RCT mode matrix; use only on isolated lab workers | | `LESAVKA_SERVER_REPO` | manual probe/server connection override used to resolve the target Lesavka server and mode under test | | `LESAVKA_SERVER_SCHEME` | manual probe/server connection override used to resolve the target Lesavka server and mode under test | | `LESAVKA_STIMULUS_BROWSER_KIOSK` | manual browser-stimulus probe override; controls local review browser behavior for mirrored upstream A/V tests | @@ -546,13 +560,23 @@ These entries are intentionally concise because most are manual lab or CI harnes | `LESAVKA_UAC_APP_MAX_BUFFERS` | server UAC appsrc buffering override for lab tuning of microphone gadget output latency and stability | | `LESAVKA_UAC_APP_MAX_BYTES` | server UAC appsrc buffering override for lab tuning of microphone gadget output latency and stability | | `LESAVKA_UAC_APP_MAX_TIME_NS` | server UAC appsrc buffering override for lab tuning of microphone gadget output latency and stability | +| `LESAVKA_MIC_NOISE_SUPPRESSION` | client microphone capture toggle; when truthy, inserts WebRTC DSP noise suppression before upstream audio transport | +| `LESAVKA_UPLINK_AUDIO_CODEC` | client/server upstream microphone transport hint (`opus` or `pcm`); launcher defaults to Opus while the server installer defaults to PCM until Opus calibration is intentionally selected | | `LESAVKA_UPLINK_CAMERA_CODEC` | server camera ingress codec hint; records whether upstream camera media arrives as `mjpeg`, `h264`, or `hevc` before UVC output | | `LESAVKA_UPSTREAM_HEVC_AUDIO_PLAYOUT_MODE_OFFSETS_US` | server HEVC-ingress audio playout delay map by `WIDTHxHEIGHT@FPS`; overrides generic upstream audio offsets for HEVC | | `LESAVKA_UPSTREAM_HEVC_AUDIO_PLAYOUT_OFFSET_US` | server HEVC-ingress scalar audio playout delay in microseconds; used when no mode-specific value is present | +| `LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US` | server combined HEVC camera plus Opus microphone playout delay map by `WIDTHxHEIGHT@FPS`; forked from the PCM map until lab calibration replaces it | +| `LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_OFFSET_US` | server combined HEVC camera plus Opus microphone scalar playout delay in microseconds; used when no mode-specific value is present | +| `LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US` | server combined HEVC camera plus PCM microphone playout delay map by `WIDTHxHEIGHT@FPS`; preserves the current golden PCM baseline | +| `LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_OFFSET_US` | server combined HEVC camera plus PCM microphone scalar playout delay in microseconds; used when no mode-specific value is present | | `LESAVKA_UPSTREAM_HEVC_VIDEO_PLAYOUT_MODE_OFFSETS_US` | server HEVC-ingress video playout delay map by `WIDTHxHEIGHT@FPS`; includes decode/re-emit path calibration | | `LESAVKA_UPSTREAM_HEVC_VIDEO_PLAYOUT_OFFSET_US` | server HEVC-ingress scalar video playout delay in microseconds; used when no mode-specific value is present | | `LESAVKA_UPSTREAM_MJPEG_AUDIO_PLAYOUT_MODE_OFFSETS_US` | server MJPEG-ingress audio playout delay map by `WIDTHxHEIGHT@FPS`; preserves the calibrated MJPEG transport profile | | `LESAVKA_UPSTREAM_MJPEG_AUDIO_PLAYOUT_OFFSET_US` | server MJPEG-ingress scalar audio playout delay in microseconds; used when no mode-specific value is present | +| `LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US` | server combined MJPEG camera plus Opus microphone playout delay map by `WIDTHxHEIGHT@FPS`; forked from the PCM map until lab calibration replaces it | +| `LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_OFFSET_US` | server combined MJPEG camera plus Opus microphone scalar playout delay in microseconds; used when no mode-specific value is present | +| `LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US` | server combined MJPEG camera plus PCM microphone playout delay map by `WIDTHxHEIGHT@FPS`; preserves the current golden PCM baseline | +| `LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_OFFSET_US` | server combined MJPEG camera plus PCM microphone scalar playout delay in microseconds; used when no mode-specific value is present | | `LESAVKA_UPSTREAM_MJPEG_VIDEO_PLAYOUT_MODE_OFFSETS_US` | server MJPEG-ingress video playout delay map by `WIDTHxHEIGHT@FPS`; preserves the calibrated MJPEG transport profile | | `LESAVKA_UPSTREAM_MJPEG_VIDEO_PLAYOUT_OFFSET_US` | server MJPEG-ingress scalar video playout delay in microseconds; used when no mode-specific value is present | | `LESAVKA_UPSTREAM_BLIND_HEAL` | server upstream media blind-healer tuning knob; adjusts cautious runtime offset correction when telemetry indicates persistent skew | @@ -572,8 +596,10 @@ These entries are intentionally concise because most are manual lab or CI harnes | `LESAVKA_UPSTREAM_AUTO_HEAL_AFTER_MS` | client live bundled-upstream startup heal delay; defaults to `3000`ms before issuing the safe audio-epoch recovery | | `LESAVKA_UPSTREAM_SOURCE_LEAD_CAP_MS` | server upstream media timing override; bounds live source lead or playout behavior while tuning client-to-server transport | | `LESAVKA_UVC_CONFIGFS_BASE` | server UVC gadget mode/configfs override used by runtime reconfiguration and hardware-in-the-loop probes | +| `LESAVKA_UVC_HEVC_DOMINANT_BYTE_PCT` | server HEVC-to-MJPEG corruption guard threshold; flat decoded MJPEG payloads with one byte at or above this percentage are frozen out, default `92` | | `LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP` | server HEVC-to-MJPEG corruption guard toggle; defaults on so suspicious decoded frame collapses freeze the last good MJPEG frame | | `LESAVKA_UVC_HEVC_JPEG_QUALITY` | server HEVC-to-MJPEG UVC bridge JPEG quality; defaults to `72` to lower UVC payload pressure while keeping RCT output compatible | +| `LESAVKA_UVC_HEVC_MIN_PAYLOAD_DISTINCT_BYTES` | server HEVC-to-MJPEG corruption guard threshold; decoded MJPEG payloads with fewer distinct payload bytes are treated as flat grey/black damage, default `12` | | `LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES` | server HEVC-to-MJPEG corruption guard baseline; decoded MJPEG frames smaller than this do not become freeze references | | `LESAVKA_UVC_HEVC_SIZE_DROP_PCT` | server HEVC-to-MJPEG corruption guard threshold; frames below this percentage of the last good reference are frozen out | | `LESAVKA_UVC_MODE` | server UVC gadget mode/configfs override used by runtime reconfiguration and hardware-in-the-loop probes | diff --git a/docs/opus-transport-testing.md b/docs/opus-transport-testing.md new file mode 100644 index 0000000..c08037c --- /dev/null +++ b/docs/opus-transport-testing.md @@ -0,0 +1,39 @@ +# Opus Transport Testing + +Lesavka does not use SIP, RTP, or SIPp for upstream microphone media. Upstream +audio is bundled with webcam video in `UpstreamMediaBundle`, and the launcher can +select either compressed `Opus` or raw `S16LE` PCM. + +The Opus profile protects the production migration seam: + +- The protobuf schema can explicitly label `AudioPacket` payloads as `PCM_S16LE` + or `OPUS`. +- Legacy or unstamped audio still normalizes to `PCM_S16LE`. +- Client microphone packets stamp PCM metadata before optional Opus encoding. +- Bundled media carries batch-level audio encoding metadata beside video. +- The server decodes Opus back to raw PCM before handing audio to the UAC sink. +- If Opus encode/decode is unavailable, Lesavka falls back to or drops safely + instead of treating compressed bytes as PCM. +- Performance tests compare the 20 ms Opus budget against raw PCM byte pressure. + +Current Opus model: + +- sample rate: `48000` +- channels: `2` +- frame duration: `20000us` +- target bitrate: `64000bps` +- expected payload: about `160 bytes` per 20 ms frame + +The current route is: + +`raw mic capture -> optional webrtcdsp -> opusenc -> gRPC bundle -> opusdec -> raw UAC` + +PCM remains available as: + +`raw mic capture -> optional webrtcdsp -> gRPC bundle -> raw UAC` + +Any recalibration should record both the upstream video codec (`HEVC`/`MJPEG`) +and upstream audio codec (`Opus`/`PCM`) so old PCM baselines stay distinguishable. +Server calibration profiles use `camera+audio` names such as `hevc+pcm` and +`hevc+opus`. The PCM maps are the known-good factory values; the Opus maps start +as a separate namespace and should be replaced only after Theia/RCT calibration. diff --git a/scripts/ci/baremetal_lab_gate.sh b/scripts/ci/baremetal_lab_gate.sh new file mode 100755 index 0000000..5da45d7 --- /dev/null +++ b/scripts/ci/baremetal_lab_gate.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +# Run opt-in hardware/lab gates that are unsafe for shared CI desktops. +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +REPORT_DIR="${ROOT_DIR}/target/baremetal-lab-gate" +SUMMARY_JSON="${REPORT_DIR}/summary.json" +SUMMARY_TXT="${REPORT_DIR}/summary.txt" +METRICS_FILE="${REPORT_DIR}/metrics.prom" +RUN_LOG="${REPORT_DIR}/baremetal-lab-gate.log" +PUSHGATEWAY_URL=${QUALITY_GATE_PUSHGATEWAY_URL:-} +PUSHGATEWAY_JOB=${LESAVKA_LAB_GATE_PUSHGATEWAY_JOB:-lesavka-baremetal-lab-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:-} + +log() { + printf '%s\n' "$*" | tee -a "${RUN_LOG}" +} + +: >"${RUN_LOG}" + +status=0 +outcome=ok +detail="ran configured bare-metal lab gates" +steps_jsonl="${REPORT_DIR}/steps.jsonl" +: >"${steps_jsonl}" +start_seconds=$(date +%s) + +record_step() { + local name="$1" + local result="$2" + local note="$3" + python3 - "${steps_jsonl}" "${name}" "${result}" "${note}" <<'PY' +import json +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +entry = {'name': sys.argv[2], 'result': sys.argv[3], 'note': sys.argv[4]} +with path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(entry, sort_keys=True) + '\n') +PY +} + +run_shell_step() { + local name="$1" + local command="$2" + log "==> ${name}" + set +e + bash -lc "${command}" 2>&1 | tee -a "${RUN_LOG}" + local step_status=${PIPESTATUS[0]} + set -e + if [[ "${step_status}" -eq 0 ]]; then + record_step "${name}" ok "completed" + else + record_step "${name}" failed "exit ${step_status}" + status="${step_status}" + fi +} + +run_step() { + local name="$1" + shift + log "==> ${name}" + set +e + "$@" 2>&1 | tee -a "${RUN_LOG}" + local step_status=${PIPESTATUS[0]} + set -e + if [[ "${step_status}" -eq 0 ]]; then + record_step "${name}" ok "completed" + else + record_step "${name}" failed "exit ${step_status}" + status="${step_status}" + fi +} + +if [[ "${LESAVKA_ALLOW_LAB_HARDWARE_TESTS:-0}" != "1" ]]; then + outcome=skipped + detail="bare-metal lab gates require LESAVKA_ALLOW_LAB_HARDWARE_TESTS=1" + log "Skipping bare-metal lab gates." + log "These gates may use Theia/Tethys, UVC/UAC devices, or virtual HID input." + log "Run only on an isolated worker/session with LESAVKA_ALLOW_LAB_HARDWARE_TESTS=1." +else + if [[ "${LESAVKA_RUN_VIDEO_DOWNSTREAM_GATE:-1}" == "1" ]]; then + run_step "video_downstream_gate" scripts/ci/video_downstream_gate.sh + else + record_step "video_downstream_gate" skipped "LESAVKA_RUN_VIDEO_DOWNSTREAM_GATE!=1" + fi + + if [[ "${LESAVKA_ALLOW_DISRUPTIVE_INPUT_TESTS:-0}" == "1" ]]; then + run_step "input_transport_gate" scripts/ci/input_transport_gate.sh + else + record_step "input_transport_gate" skipped "LESAVKA_ALLOW_DISRUPTIVE_INPUT_TESTS!=1" + log "Skipping disruptive input transport gate; set LESAVKA_ALLOW_DISRUPTIVE_INPUT_TESTS=1 on an isolated worker." + fi + + if [[ "${LESAVKA_RUN_SERVER_RCT_MATRIX:-0}" == "1" ]]; then + server_rct_matrix_cmd=${LESAVKA_SERVER_RCT_MATRIX_CMD:-"${ROOT_DIR}/scripts/manual/run_server_to_rc_mode_matrix.sh"} + run_shell_step "server_to_rct_matrix" "${server_rct_matrix_cmd}" + else + record_step "server_to_rct_matrix" skipped "LESAVKA_RUN_SERVER_RCT_MATRIX!=1" + fi + + if [[ "${LESAVKA_RUN_CLIENT_RCT_PROBE:-0}" == "1" ]]; then + client_rct_probe_cmd=${LESAVKA_CLIENT_RCT_PROBE_CMD:-"${ROOT_DIR}/scripts/manual/run_client_to_rct_transport_probe.sh"} + run_shell_step "client_to_rct_transport_probe" "${client_rct_probe_cmd}" + else + record_step "client_to_rct_transport_probe" skipped "LESAVKA_RUN_CLIENT_RCT_PROBE!=1" + fi + + if [[ "${status}" -ne 0 ]]; then + outcome=failed + detail="one or more bare-metal lab gates failed" + fi +fi + +duration_seconds=$(($(date +%s) - start_seconds)) + +python3 - \ + "${SUMMARY_JSON}" \ + "${SUMMARY_TXT}" \ + "${METRICS_FILE}" \ + "${steps_jsonl}" \ + "${branch}" \ + "${commit}" \ + "${build_url}" \ + "${outcome}" \ + "${status}" \ + "${duration_seconds}" \ + "${detail}" <<'PY' +import json +import pathlib +import sys +from datetime import datetime, timezone + +summary_path = pathlib.Path(sys.argv[1]) +text_path = pathlib.Path(sys.argv[2]) +metrics_path = pathlib.Path(sys.argv[3]) +steps_path = pathlib.Path(sys.argv[4]) +branch = sys.argv[5] +commit = sys.argv[6] +build_url = sys.argv[7] +outcome = sys.argv[8] +status = int(sys.argv[9]) +duration_seconds = int(sys.argv[10]) +detail = sys.argv[11] + +def esc(value: str) -> str: + return value.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"') + +steps = [] +if steps_path.exists(): + for line in steps_path.read_text(encoding='utf-8').splitlines(): + if line.strip(): + steps.append(json.loads(line)) + +summary = { + 'suite': 'lesavka', + 'profile': 'lab', + 'branch': branch, + 'commit': commit, + 'build_url': build_url, + 'outcome': outcome, + 'exit_code': status, + 'duration_seconds': duration_seconds, + 'detail': detail, + 'steps': steps, + 'generated_at': datetime.now(timezone.utc).isoformat(), +} +summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + '\n', encoding='utf-8') +text_path.write_text( + '\n'.join([ + f'lesavka bare-metal lab gate: {outcome}', + f'branch: {branch}', + f'commit: {commit}', + f'duration: {duration_seconds}s', + f'detail: {detail}', + 'steps:', + *[f"- {step['name']}: {step['result']} ({step['note']})" for step in steps], + ]) + '\n', + encoding='utf-8', +) + +labels = f'suite="lesavka",profile="lab",branch="{esc(branch)}",commit="{esc(commit)}"' +ok = 1 if outcome == 'ok' else 0 +failed = 1 if outcome == 'failed' else 0 +skipped = 1 if outcome == 'skipped' else 0 +lines = [ + '# HELP lesavka_ci_profile_last_run_success Whether the latest Lesavka CI profile run succeeded.', + '# TYPE lesavka_ci_profile_last_run_success gauge', + f'lesavka_ci_profile_last_run_success{{{labels}}} {ok}', + '# HELP lesavka_ci_profile_duration_seconds Duration of the latest Lesavka CI profile run.', + '# TYPE lesavka_ci_profile_duration_seconds gauge', + f'lesavka_ci_profile_duration_seconds{{{labels}}} {duration_seconds}', + '# HELP lesavka_ci_profile_runs Current profile run outcome.', + '# TYPE lesavka_ci_profile_runs gauge', + f'lesavka_ci_profile_runs{{{labels},status="ok"}} {ok}', + f'lesavka_ci_profile_runs{{{labels},status="failed"}} {failed}', + f'lesavka_ci_profile_runs{{{labels},status="skipped"}} {skipped}', + '# HELP lesavka_lab_gate_step_result Bare-metal lab gate step result.', + '# TYPE lesavka_lab_gate_step_result gauge', +] +for step in steps: + name = esc(step['name']) + for result in ['ok', 'failed', 'skipped']: + value = 1 if step['result'] == result else 0 + lines.append(f'lesavka_lab_gate_step_result{{{labels},step="{name}",result="{result}"}} {value}') +metrics_path.write_text('\n'.join(lines) + '\n', encoding='utf-8') +PY + +if [[ -n "${PUSHGATEWAY_URL}" ]]; then + curl --fail --silent --show-error \ + --data-binary @"${METRICS_FILE}" \ + "${PUSHGATEWAY_URL%/}/metrics/job/${PUSHGATEWAY_JOB}/suite/lesavka" || status=$? +fi + +exit "${status}" diff --git a/scripts/ci/daily_master_gate.sh b/scripts/ci/daily_master_gate.sh new file mode 100755 index 0000000..b62ed91 --- /dev/null +++ b/scripts/ci/daily_master_gate.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# Run the safe daily gate for the current primary branch. +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +REPORT_DIR="${ROOT_DIR}/target/daily-master-gate" +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_DAILY_GATE_PUSHGATEWAY_JOB:-lesavka-daily-master-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:-} + +is_primary_branch=0 +case "${branch}" in + master|main|origin/master|origin/main|*/master|*/main) + is_primary_branch=1 + ;; +esac + +status=0 +outcome=ok +detail="ran safe platform quality gate" +start_seconds=$(date +%s) + +if [[ "${is_primary_branch}" != "1" && "${LESAVKA_DAILY_ALLOW_NON_PRIMARY:-0}" != "1" ]]; then + outcome=skipped + detail="daily gate only evaluates master/main by default" +else + set +e + LESAVKA_CI_PROFILE=daily scripts/ci/platform_quality_gate.sh + status=$? + set -e + if [[ "${status}" -ne 0 ]]; then + outcome=failed + detail="safe platform quality gate failed" + fi +fi + +duration_seconds=$(($(date +%s) - start_seconds)) + +python3 - \ + "${SUMMARY_JSON}" \ + "${SUMMARY_TXT}" \ + "${METRICS_FILE}" \ + "${branch}" \ + "${commit}" \ + "${build_url}" \ + "${outcome}" \ + "${status}" \ + "${duration_seconds}" \ + "${detail}" <<'PY' +import json +import pathlib +import sys +from datetime import datetime, timezone + +summary_path = pathlib.Path(sys.argv[1]) +text_path = pathlib.Path(sys.argv[2]) +metrics_path = pathlib.Path(sys.argv[3]) +branch = sys.argv[4] +commit = sys.argv[5] +build_url = sys.argv[6] +outcome = sys.argv[7] +status = int(sys.argv[8]) +duration_seconds = int(sys.argv[9]) +detail = sys.argv[10] + +def esc(value: str) -> str: + return value.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"') + +summary = { + 'suite': 'lesavka', + 'profile': 'daily', + 'branch': branch, + 'commit': commit, + 'build_url': build_url, + 'outcome': outcome, + 'exit_code': status, + 'duration_seconds': duration_seconds, + 'detail': detail, + 'generated_at': datetime.now(timezone.utc).isoformat(), +} +summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + '\n', encoding='utf-8') +text_path.write_text( + '\n'.join([ + f'lesavka daily gate: {outcome}', + f'branch: {branch}', + f'commit: {commit}', + f'duration: {duration_seconds}s', + f'detail: {detail}', + ]) + '\n', + encoding='utf-8', +) + +labels = f'suite="lesavka",profile="daily",branch="{esc(branch)}",commit="{esc(commit)}"' +ok = 1 if outcome == 'ok' else 0 +failed = 1 if outcome == 'failed' else 0 +skipped = 1 if outcome == 'skipped' else 0 +metrics_path.write_text( + '\n'.join([ + '# HELP lesavka_ci_profile_last_run_success Whether the latest Lesavka CI profile run succeeded.', + '# TYPE lesavka_ci_profile_last_run_success gauge', + f'lesavka_ci_profile_last_run_success{{{labels}}} {ok}', + '# HELP lesavka_ci_profile_duration_seconds Duration of the latest Lesavka CI profile run.', + '# TYPE lesavka_ci_profile_duration_seconds gauge', + f'lesavka_ci_profile_duration_seconds{{{labels}}} {duration_seconds}', + '# HELP lesavka_ci_profile_runs Current profile run outcome.', + '# TYPE lesavka_ci_profile_runs gauge', + f'lesavka_ci_profile_runs{{{labels},status="ok"}} {ok}', + f'lesavka_ci_profile_runs{{{labels},status="failed"}} {failed}', + f'lesavka_ci_profile_runs{{{labels},status="skipped"}} {skipped}', + ]) + '\n', + encoding='utf-8', +) +PY + +if [[ -n "${PUSHGATEWAY_URL}" ]]; then + curl --fail --silent --show-error \ + --data-binary @"${METRICS_FILE}" \ + "${PUSHGATEWAY_URL%/}/metrics/job/${PUSHGATEWAY_JOB}/suite/lesavka" || status=$? +fi + +exit "${status}" diff --git a/scripts/ci/hygiene_gate.sh b/scripts/ci/hygiene_gate.sh index ba5c100..774fc7a 100755 --- a/scripts/ci/hygiene_gate.sh +++ b/scripts/ci/hygiene_gate.sh @@ -46,7 +46,7 @@ commit = sys.argv[7] fn_re = re.compile(r'^\s*(?:pub(?:\([^)]+\))?\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+\w+') env_re = re.compile(r'LESAVKA_[A-Z0-9_]+') lazy_name_tokens = {'part', 'piece', 'chunk', 'misc', 'stuff', 'helpers2', 'new', 'old', 'tmp'} -expected_workspace_members = {'common', 'client', 'server', 'testing'} +expected_workspace_members = {'common', 'client', 'server'} required_binary_paths = { 'lesavka-client': 'client/Cargo.toml', 'lesavka-server': 'server/Cargo.toml', @@ -190,7 +190,7 @@ def env_doc_violations(files: list[str]) -> list[str]: docs_text = docs_path.read_text(encoding='utf-8') found: set[str] = set() - scan_prefixes = ('client/', 'common/', 'server/', 'testing/', 'scripts/') + scan_prefixes = ('client/', 'common/', 'server/', 'scripts/') scan_files = [ path for path in files if path == 'Jenkinsfile' or path.endswith('.toml') or path.startswith(scan_prefixes) @@ -302,24 +302,43 @@ def integration_layout_violations() -> list[str]: violations: list[str] = [] for file in sorted(root.rglob('*.rs')): rel = repo_relative(str(file)) - if rel is None or rel.startswith('target/') or rel.startswith('testing/'): + if rel is None or rel.startswith('target/'): continue parts = pathlib.Path(rel).parts if len(parts) >= 2 and parts[1] == 'tests': violations.append( - f'{rel}: integration tests must live under testing/tests/ instead of package-local tests/' + f'{rel}: integration tests must live under top-level tests/ instead of package-local tests/' ) return violations def testing_contract_violations() -> list[str]: violations: list[str] = [] - contract_dir = root / 'testing' / 'tests' - if not contract_dir.exists(): - return ['testing/tests: missing dedicated top-level integration test directory'] + taxonomy_dir = root / 'tests' + required_categories = { + 'unit', 'component', 'integration', 'api', 'contract', 'e2e', 'system', + 'performance', 'reliability', 'chaos', 'security', 'ui', 'installer', + 'compatibility', 'regression', 'smoke', 'fixtures', 'helpers', 'golden', + 'manual', + } + if not taxonomy_dir.exists(): + return ['tests: missing repository-level test taxonomy directory'] - test_files = sorted(contract_dir.rglob('*.rs')) + present_categories = { + path.name + for path in taxonomy_dir.iterdir() + if path.is_dir() + } + for category in sorted(required_categories - present_categories): + violations.append(f'tests/{category}: missing standard test category directory') + + test_files = [ + file + for file in sorted(taxonomy_dir.rglob('*.rs')) + if file != taxonomy_dir / 'lib.rs' + and 'helpers' not in file.relative_to(taxonomy_dir).parts + ] if not test_files: - return ['testing/tests: no integration test files found'] + violations.append('tests: no categorized test bodies found') filename_re = re.compile(r'^[a-z0-9_]+\.rs$') required_markers = ('Scope:', 'Targets:', 'Why:') @@ -340,6 +359,9 @@ def testing_contract_violations() -> list[str]: violations.append(f'{rel}: missing required module contract marker {marker}') if '#[test]' not in text and '#[tokio::test]' not in text: violations.append(f'{rel}: missing test entrypoints') + if 'misc' in pathlib.Path(rel).parts: + violations.append(f'{rel}: tests must use descriptive subsystem paths, not misc') + return violations current = {} @@ -411,7 +433,7 @@ lines.append(f"files over 500 LOC: {totals['over_500']}") lines.append(f"clippy warnings tracked: {totals['clippy_warnings']}") lines.append(f"non-trivial undocumented functions tracked: {totals['doc_debt']}") lines.append(f'legacy integration-test layout violations: {len(layout_violations)}') -lines.append(f'testing module contract violations: {len(testing_violations)}') +lines.append(f'test module contract violations: {len(testing_violations)}') lines.append(f'repository policy violations: {len(repo_violations)}') lines.append(f'naming policy violations: {len(naming_violations)}') lines.append(f'script policy violations: {len(script_violations)}') @@ -451,7 +473,7 @@ if layout_violations: if testing_violations: lines.append('') - lines.append('testing module contract violations') + lines.append('test module contract violations') lines.append('-' * 78) lines.extend(testing_violations) diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index b5990cc..a13220d 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -227,8 +227,8 @@ }, "client/src/input/mouse_event_contract_tests.rs": { "clippy_warnings": 0, - "doc_debt": 14, - "loc": 439 + "doc_debt": 13, + "loc": 457 }, "client/src/launcher/calibration.rs": { "clippy_warnings": 0, @@ -1223,7 +1223,7 @@ "server/src/video_sinks/hevc_mjpeg_guard.rs": { "clippy_warnings": 0, "doc_debt": 2, - "loc": 120 + "loc": 264 }, "server/src/video_sinks/mjpeg_spool.rs": { "clippy_warnings": 0, @@ -1233,17 +1233,12 @@ "server/src/video_sinks/webcam_sink.rs": { "clippy_warnings": 0, "doc_debt": 6, - "loc": 494 + "loc": 495 }, "server/src/video_support.rs": { "clippy_warnings": 0, "doc_debt": 1, "loc": 340 - }, - "testing/src/lib.rs": { - "clippy_warnings": 0, - "doc_debt": 0, - "loc": 10 } } } diff --git a/scripts/ci/input_transport_gate.sh b/scripts/ci/input_transport_gate.sh index 7eb1a4c..545c838 100755 --- a/scripts/ci/input_transport_gate.sh +++ b/scripts/ci/input_transport_gate.sh @@ -5,21 +5,37 @@ set -euo pipefail ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) cd "$ROOT" +if [[ "${LESAVKA_ALLOW_DISRUPTIVE_INPUT_TESTS:-0}" != "1" ]]; then + cat <<'MSG' +Skipping disruptive input transport tests. +These tests create virtual keyboards/mice and can emit events into the active +desktop. Run them only on an isolated worker/session with: + + LESAVKA_ALLOW_DISRUPTIVE_INPUT_TESTS=1 scripts/ci/input_transport_gate.sh +MSG + exit 0 +fi + INPUT_TESTS=( --test client_app_include_contract --test client_inputs_contract --test client_inputs_extra_contract + --test client_inputs_routing_contract --test client_keyboard_activation_contract + --test client_keyboard_paste_rpc_contract + --test client_keyboard_process_contract --test client_keyboard_shift_contract --test client_keyboard_include_contract --test client_keyboard_include_extra_contract --test client_mouse_include_contract --test client_mouse_include_extra_contract + --test client_mouse_uinput_contract --test server_gadget_include_contract --test server_main_binary_extra_contract --test server_runtime_smoke_contract ) cargo fmt --all -- --check -cargo check -q --bin lesavka-client --bin lesavka-server -cargo test -q -p lesavka_testing "${INPUT_TESTS[@]}" +cargo check -q -p lesavka_client --bin lesavka-client +cargo check -q -p lesavka_server --bin lesavka-server +cargo test -q -p lesavka_tests --features disruptive-input-tests "${INPUT_TESTS[@]}" diff --git a/scripts/ci/media_reliability_gate.sh b/scripts/ci/media_reliability_gate.sh index 7a01867..312764a 100755 --- a/scripts/ci/media_reliability_gate.sh +++ b/scripts/ci/media_reliability_gate.sh @@ -58,7 +58,7 @@ set +e camera_status=${PIPESTATUS[0]} echo echo '==> media reliability contract tests' - cargo test -p lesavka_testing --color never "${MEDIA_TESTS[@]}" + cargo test -p lesavka_tests --color never "${MEDIA_TESTS[@]}" contract_status=${PIPESTATUS[0]} if [[ "${camera_status}" -ne 0 || "${contract_status}" -ne 0 ]]; then exit 1 @@ -183,7 +183,7 @@ summary = { 'status': 'ok' if final_status == 0 else 'failed', 'deterministic_status': 'ok' if status == 0 else 'failed', 'duration_seconds': duration_seconds, - 'deterministic_tests': 'cargo test -p lesavka_testing media reliability contract subset', + 'deterministic_tests': 'cargo test -p lesavka_tests media reliability contract subset', 'tracked_media_signals': tracked_signals, 'manual_checks': manual_checks, 'sync_probe': { diff --git a/scripts/ci/performance_gate.sh b/scripts/ci/performance_gate.sh index 67da1bb..b5807e9 100755 --- a/scripts/ci/performance_gate.sh +++ b/scripts/ci/performance_gate.sh @@ -32,7 +32,7 @@ PERFORMANCE_TESTS=( start_seconds=$(date +%s) status=0 set +e -cargo test -p lesavka_testing "${PERFORMANCE_TESTS[@]}" --color never 2>&1 | tee "${TEST_LOG}" +cargo test -p lesavka_tests "${PERFORMANCE_TESTS[@]}" --color never 2>&1 | tee "${TEST_LOG}" status=${PIPESTATUS[0]} set -e duration_seconds=$(($(date +%s) - start_seconds)) diff --git a/scripts/ci/quality_gate.sh b/scripts/ci/quality_gate.sh index b965097..910312e 100755 --- a/scripts/ci/quality_gate.sh +++ b/scripts/ci/quality_gate.sh @@ -7,7 +7,7 @@ COVERAGE_LCOV="${REPORT_DIR}/coverage.lcov" SUMMARY_TXT="${REPORT_DIR}/summary.txt" METRICS_FILE="${REPORT_DIR}/metrics.prom" BASELINE_JSON="${ROOT_DIR}/scripts/ci/quality_gate_baseline.json" -COVERAGE_CONTRACT_JSON="${ROOT_DIR}/testing/coverage_contract.json" +COVERAGE_CONTRACT_JSON="${ROOT_DIR}/tests/coverage_contract.json" PUSHGATEWAY_URL=${QUALITY_GATE_PUSHGATEWAY_URL:-} mkdir -p "${REPORT_DIR}" @@ -294,7 +294,7 @@ metrics.append(f'platform_quality_gate_source_lines_over_500_total{{{labels}}} { metrics.append('# HELP platform_quality_gate_repo_source_lines_over_500_total Count of repo source files over 500 LOC, including untracked working-tree files.') metrics.append('# TYPE platform_quality_gate_repo_source_lines_over_500_total gauge') metrics.append(f'platform_quality_gate_repo_source_lines_over_500_total{{{labels}}} {len(source_loc_over_500)}') -metrics.append('# HELP platform_quality_gate_contract_files_total Count of files covered by the strict testing coverage contract.') +metrics.append('# HELP platform_quality_gate_contract_files_total Count of files covered by the strict test coverage contract.') metrics.append('# TYPE platform_quality_gate_contract_files_total gauge') metrics.append(f'platform_quality_gate_contract_files_total{{{labels}}} {len(contract_files)}') metrics.append('# HELP platform_quality_gate_contract_files_at_target_total Count of strict contract files meeting the line coverage target.') @@ -350,7 +350,7 @@ for item in files: if contract_files: lines.append('') - lines.append('strict testing coverage contract') + lines.append('strict test coverage contract') lines.append('-' * 86) for path in contract_files: current = current_by_path.get(path) diff --git a/scripts/ci/sonarqube_gate.sh b/scripts/ci/sonarqube_gate.sh index b9d780f..753d0c8 100755 --- a/scripts/ci/sonarqube_gate.sh +++ b/scripts/ci/sonarqube_gate.sh @@ -31,8 +31,8 @@ if [[ -n "${SONARQUBE_HOST_URL:-}" && -n "${SONARQUBE_TOKEN:-}" ]] && command -v if ! sonar-scanner \ -Dsonar.projectKey=lesavka \ -Dsonar.projectName=lesavka \ - -Dsonar.sources=client/src,server/src,common/src,testing/src \ - -Dsonar.tests=testing/tests \ + -Dsonar.sources=client/src,server/src,common/src \ + -Dsonar.tests=tests \ -Dsonar.host.url="${SONARQUBE_HOST_URL}" \ -Dsonar.token="${SONARQUBE_TOKEN}" \ >"${REPORT_DIR}/sonar-scanner.log" 2>&1; then diff --git a/scripts/ci/video_downstream_gate.sh b/scripts/ci/video_downstream_gate.sh index 2ff42f5..1e1a435 100755 --- a/scripts/ci/video_downstream_gate.sh +++ b/scripts/ci/video_downstream_gate.sh @@ -8,6 +8,10 @@ cd "$ROOT" VIDEO_TESTS=( --test client_launcher_layout_contract --test video_downstream_feed_contract + --test downstream_blackout_recovery_contract + --test downstream_video_latency_budget_contract + --test downstream_video_mode_decoder_matrix_contract + --test downstream_video_stall_chaos_contract --test server_video_include_contract --test video_support_contract --test client_output_video_include_contract @@ -15,11 +19,12 @@ VIDEO_TESTS=( --test server_video_sink_smoke_contract ) -VIDEO_IGNORE_REGEX='(/common/src/(hid|paste|process_metrics)\.rs|/server/src/(audio|camera|camera_runtime|capture_power|gadget|paste|runtime_support|uvc_runtime)\.rs)' +VIDEO_IGNORE_REGEX='(^|/)(common/src/(hid|paste|process_metrics)\.rs|server/src/(audio|camera|calibration|capture_power|gadget|output_delay_probe|runtime_support|upstream_media_runtime)(/.*)?|server/src/(blind_healer|camera_runtime|handshake|paste|security|uvc_runtime)\.rs)' cargo fmt --all -- --check -cargo check -q --bin lesavka-client --bin lesavka-server -cargo test -q -p lesavka_testing "${VIDEO_TESTS[@]}" +cargo check -q -p lesavka_client --bin lesavka-client +cargo check -q -p lesavka_server --bin lesavka-server +cargo test -q -p lesavka_tests "${VIDEO_TESTS[@]}" cargo llvm-cov clean --workspace cargo llvm-cov --workspace "${VIDEO_TESTS[@]}" \ diff --git a/scripts/daemon/lesavka-recovery-ladder.sh b/scripts/daemon/lesavka-recovery-ladder.sh new file mode 100755 index 0000000..adf7f7e --- /dev/null +++ b/scripts/daemon/lesavka-recovery-ladder.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# Soft recovery ladder for a deployed Lesavka server. +set -euo pipefail + +ACTION=${1:-recover} +LOG_PATH=${LESAVKA_RECOVERY_LOG:-/var/log/lesavka/recovery-ladder.log} +LAST_GOOD_DIR=${LESAVKA_RECOVERY_LAST_GOOD_DIR:-/var/lib/lesavka/recovery/last-good} +CHECK_TIMEOUT_SECONDS=${LESAVKA_RECOVERY_TIMEOUT_SECONDS:-60} +ALLOW_CORE_RESTART=${LESAVKA_RECOVERY_ALLOW_CORE_RESTART:-0} +ALLOW_REBOOT=${LESAVKA_RECOVERY_ALLOW_REBOOT:-0} +SERVER_BIND_ADDR=${LESAVKA_SERVER_BIND_ADDR:-0.0.0.0:50051} +LOCK_PATH=${LESAVKA_RECOVERY_LOCK:-/run/lesavka-recovery-ladder.lock} + +LIVE_FILES=( + /usr/local/bin/lesavka-server + /usr/local/bin/lesavka-uvc + /usr/local/bin/lesavka-core.sh + /usr/local/bin/lesavka-uvc.sh +) + +mkdir -p "$(dirname "$LOG_PATH")" "$LAST_GOOD_DIR" "$(dirname "$LOCK_PATH")" + +log() { + printf '%s %s\n' "$(date -Is)" "$*" | tee -a "$LOG_PATH" >&2 +} + +locked_main() { + exec 9>"$LOCK_PATH" + if ! flock -n 9; then + log "another recovery ladder run is already active; skipping" + exit 0 + fi + main "$@" +} + +nonempty_executable() { + [[ -s $1 && -x $1 ]] +} + +server_port() { + printf '%s\n' "${SERVER_BIND_ADDR##*:}" +} + +listener_ready() { + local port + port=$(server_port) + if ! command -v ss >/dev/null 2>&1; then + return 0 + fi + ss -ltn "sport = :$port" 2>/dev/null | grep -q ":$port" +} + +entrypoints_ready() { + local file + for file in "${LIVE_FILES[@]}"; do + if ! nonempty_executable "$file"; then + log "unhealthy: $file is missing, empty, or not executable" + return 1 + fi + done +} + +services_ready() { + systemctl is-active --quiet lesavka-core.service \ + && systemctl is-active --quiet lesavka-uvc.service \ + && systemctl is-active --quiet lesavka-server.service +} + +health_ready() { + entrypoints_ready && services_ready && listener_ready +} + +snapshot_last_good() { + local file dest + entrypoints_ready + install -d -m 0755 "$LAST_GOOD_DIR/usr-local-bin" + for file in "${LIVE_FILES[@]}"; do + dest="$LAST_GOOD_DIR/usr-local-bin/$(basename "$file")" + install -m 0755 "$file" "$dest" + done + log "snapshot: refreshed last-known-good Lesavka entrypoints" +} + +restore_last_good() { + local file src + for file in "${LIVE_FILES[@]}"; do + src="$LAST_GOOD_DIR/usr-local-bin/$(basename "$file")" + if ! nonempty_executable "$src"; then + log "restore: missing last-known-good copy for $(basename "$file")" + return 1 + fi + done + for file in "${LIVE_FILES[@]}"; do + src="$LAST_GOOD_DIR/usr-local-bin/$(basename "$file")" + install -m 0755 "$src" "$file" + done + log "restore: restored last-known-good Lesavka entrypoints" +} + +wait_for_health() { + local deadline + deadline=$((SECONDS + CHECK_TIMEOUT_SECONDS)) + while (( SECONDS <= deadline )); do + if health_ready; then + return 0 + fi + sleep 5 + done + return 1 +} + +restart_server_only() { + log "step 1: restarting lesavka-server only" + systemctl reset-failed lesavka-server.service >/dev/null 2>&1 || true + systemctl restart lesavka-server.service +} + +restart_uvc_and_server() { + log "step 2: restarting UVC helper and server" + systemctl reset-failed lesavka-uvc.service lesavka-server.service >/dev/null 2>&1 || true + systemctl restart lesavka-uvc.service + systemctl restart lesavka-server.service +} + +restart_full_stack_if_allowed() { + if [[ $ALLOW_CORE_RESTART == 0 || $ALLOW_CORE_RESTART == false || $ALLOW_CORE_RESTART == no ]]; then + log "step 4: core restart disabled; preserving attached USB gadget" + return 1 + fi + log "step 4: restarting full stack because LESAVKA_RECOVERY_ALLOW_CORE_RESTART is enabled" + systemctl reset-failed lesavka-core.service lesavka-uvc.service lesavka-server.service >/dev/null 2>&1 || true + systemctl restart lesavka-core.service + systemctl restart lesavka-uvc.service + systemctl restart lesavka-server.service +} + +reboot_if_allowed() { + if [[ $ALLOW_REBOOT == 0 || $ALLOW_REBOOT == false || $ALLOW_REBOOT == no ]]; then + log "step 5: reboot disabled; leaving host online for operator inspection" + return 1 + fi + log "step 5: rebooting because LESAVKA_RECOVERY_ALLOW_REBOOT is enabled" + systemctl reboot +} + +recover() { + if health_ready; then + snapshot_last_good + log "healthy: no recovery needed" + return 0 + fi + + log "unhealthy: waiting up to ${CHECK_TIMEOUT_SECONDS}s before recovery" + if wait_for_health; then + snapshot_last_good + log "healthy after wait: no recovery needed" + return 0 + fi + + restart_server_only || true + if wait_for_health; then + snapshot_last_good + return 0 + fi + + restart_uvc_and_server || true + if wait_for_health; then + snapshot_last_good + return 0 + fi + + log "step 3: restoring last-known-good entrypoints" + if restore_last_good; then + restart_uvc_and_server || true + if wait_for_health; then + snapshot_last_good + return 0 + fi + fi + + restart_full_stack_if_allowed || true + if wait_for_health; then + snapshot_last_good + return 0 + fi + + reboot_if_allowed || return 1 +} + +main() { + case "$ACTION" in + check) + health_ready + ;; + snapshot) + snapshot_last_good + ;; + restore) + restore_last_good + ;; + recover) + recover + ;; + *) + echo "usage: $0 {check|snapshot|restore|recover}" >&2 + exit 2 + ;; + esac +} + +locked_main "$@" diff --git a/scripts/install/client.sh b/scripts/install/client.sh index 426ad9d..c04268e 100755 --- a/scripts/install/client.sh +++ b/scripts/install/client.sh @@ -131,6 +131,102 @@ require_gst_element() { exit 1 } +gst_element_available() { + gst-inspect-1.0 "$1" >/dev/null 2>&1 +} + +first_available_gst_element() { + local element + for element in "$@"; do + if gst_element_available "$element"; then + printf '%s\n' "$element" + return 0 + fi + done + return 1 +} + +report_client_media_acceleration() { + log "1e. Inspecting client media acceleration routes" + + local hevc_encoder="" + local h264_decoder="" + local opus_encoder="" + local opus_decoder="" + local webrtc_dsp="" + local proprietary_bits=() + local opensource_bits=() + + hevc_encoder=$(first_available_gst_element \ + nvh265enc \ + vah265enc \ + vaapih265enc \ + v4l2h265enc \ + x265enc || true) + h264_decoder=$(first_available_gst_element \ + nvh264dec \ + nvh264sldec \ + vah264dec \ + vaapih264dec \ + v4l2h264dec \ + v4l2slh264dec \ + avdec_h264 \ + openh264dec || true) + opus_encoder=$(first_available_gst_element opusenc || true) + opus_decoder=$(first_available_gst_element opusdec || true) + webrtc_dsp=$(first_available_gst_element webrtcdsp || true) + + for element in nvh265enc nvh264dec nvh264sldec; do + if gst_element_available "$element"; then + proprietary_bits+=("$element") + fi + done + for element in vah265enc vaapih265enc v4l2h265enc vah264dec vaapih264dec v4l2h264dec v4l2slh264dec; do + if gst_element_available "$element"; then + opensource_bits+=("$element") + fi + done + + if command -v nvidia-smi >/dev/null 2>&1; then + echo " โ†ช nvidia-smi is available; proprietary NVIDIA driver tooling is present" + else + echo " โ†ช nvidia-smi is not available; NVIDIA proprietary tooling was not detected" + fi + + if [[ ${#proprietary_bits[@]} -gt 0 ]]; then + echo " โ†ช proprietary NVIDIA GStreamer route: ${proprietary_bits[*]}" + else + echo " โ†ช proprietary NVIDIA GStreamer route: not exposed" + fi + if [[ ${#opensource_bits[@]} -gt 0 ]]; then + echo " โ†ช open-source VAAPI/V4L2 GStreamer route: ${opensource_bits[*]}" + else + echo " โ†ช open-source VAAPI/V4L2 GStreamer route: not exposed" + fi + + if [[ -n $hevc_encoder ]]; then + echo " โ†ช upstream HEVC encoder candidate: $hevc_encoder" + else + echo "โš ๏ธ no HEVC encoder was detected; upstream HEVC will need NVIDIA/VAAPI/V4L2 or x265enc" + fi + if [[ -n $h264_decoder ]]; then + echo " โ†ช downstream H.264 decoder candidate: $h264_decoder" + else + echo "โš ๏ธ no H.264 decoder was detected; downstream eye preview may fall back to decodebin" + fi + if [[ -n $opus_encoder && -n $opus_decoder ]]; then + echo "โœ… Opus upstream audio transport route: encoder=$opus_encoder decoder=$opus_decoder" + else + echo "โš ๏ธ Opus upstream audio route is not fully exposed; Lesavka will fall back to PCM" + fi + if [[ -n $webrtc_dsp ]]; then + echo "โœ… microphone noise suppression route: $webrtc_dsp" + else + echo " โ†ช microphone noise suppression route: unavailable; raw microphone path still works" + fi + echo " โ†ช override decoder route with LESAVKA_H264_DECODER= or LESAVKA_H264_DECODER_PREFERENCE=software" +} + require_kernel_module() { local module=$1 local why=$2 @@ -161,6 +257,67 @@ run_as_user() { sudo -u "$ORIG_USER" env HOME="$USER_HOME" SSH_AUTH_SOCK="${SSH_AUTH_SOCK:-}" "$@" } +install_verified_executable() { + local src=$1 + local dest=$2 + local label=${3:-$dest} + local dest_dir dest_base tmp + + if [[ ! -s "$src" ]]; then + echo "โŒ refusing to install $label: source '$src' is missing or empty." >&2 + echo " Preserving the existing installed executable at '$dest'." >&2 + exit 1 + fi + if [[ ! -x "$src" ]]; then + echo "โŒ refusing to install $label: source '$src' is not executable." >&2 + echo " Preserving the existing installed executable at '$dest'." >&2 + exit 1 + fi + + dest_dir=$(dirname "$dest") + dest_base=$(basename "$dest") + sudo install -d -m 0755 "$dest_dir" + tmp=$(sudo mktemp "$dest_dir/.${dest_base}.install.XXXXXX") + sudo rm -f "$tmp" + sudo install -Dm755 "$src" "$tmp" + if ! sudo test -s "$tmp" || ! sudo test -x "$tmp"; then + sudo rm -f "$tmp" + echo "โŒ refusing to replace $label: staged install output was not a non-empty executable." >&2 + echo " Preserving the existing installed executable at '$dest'." >&2 + exit 1 + fi + sudo mv -f "$tmp" "$dest" + sudo chmod 0755 "$dest" +} + +pacman_install() { + local log_file + log_file=$(mktemp --tmpdir="$TMPDIR" lesavka-pacman.XXXXXX.log) + if sudo pacman -Sq --needed --noconfirm "$@" 2>&1 | tee "$log_file"; then + rm -f "$log_file" + return 0 + fi + if grep -Eq "breaks dependency '.*pipewire" "$log_file"; then + cat >&2 <<'MSG' +โŒ Arch stopped the package transaction because PipeWire packages are at mixed exact versions. + Lesavka now installs PipeWire as one coherent set, but this host still needs a sync transaction. + Run: + sudo pacman -Syu + Then rerun the Lesavka client installer. +MSG + elif grep -Eq "failed retrieving file|failed to retrieve some files|failed to commit transaction \\(failed to retrieve some files\\)" "$log_file"; then + cat >&2 <<'MSG' +โŒ Arch failed while downloading packages from the configured mirrors. + No Lesavka files were replaced. Refresh or choose healthier mirrors, then rerun the installer. + Good first retry: + sudo pacman -Syu --disable-download-timeout + If mirrors keep timing out, refresh /etc/pacman.d/mirrorlist before retrying. +MSG + fi + echo " pacman log: $log_file" >&2 + exit 1 +} + fetch_client_pki_bundle() { [[ $CLIENT_PKI_AUTO_FETCH != 0 && $CLIENT_PKI_AUTO_FETCH != false && $CLIENT_PKI_AUTO_FETCH != no ]] || return 1 [[ $CLIENT_PKI_SSH_SOURCE == *:* ]] || return 1 @@ -229,9 +386,10 @@ fi REPO_URL=${REPO_URL:-$DEFAULT_REPO_URL} log "1. Installing base packages" -sudo pacman -Sq --needed --noconfirm \ +pacman_install \ git rustup protobuf abseil-cpp gcc clang llvm-libs compiler-rt evtest base-devel libpulse \ - pipewire pipewire-pulse wireplumber alsa-utils gst-plugin-pipewire \ + libpipewire pipewire pipewire-audio pipewire-alsa pipewire-jack pipewire-pulse wireplumber \ + alsa-utils gst-plugin-pipewire \ gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav \ wmctrl qt6-tools wl-clipboard xclip xsel desktop-file-utils openssl @@ -248,7 +406,7 @@ ensure_yay() { log "1b. Installing grpcurl" if sudo pacman -Si grpcurl >/dev/null 2>&1; then - sudo pacman -Sq --needed --noconfirm grpcurl + pacman_install grpcurl else ensure_yay if ! run_as_user yay -S --needed --noconfirm grpcurl-bin; then @@ -284,6 +442,7 @@ require_kernel_module snd_usb_audio "USB microphones and USB headsets" require_gst_element pulsesrc require_gst_element pulsesink require_gst_element pipewiresrc +report_client_media_acceleration protoc --version >/dev/null if ! run_as_user pactl info >/dev/null 2>&1; then echo "โš ๏ธ pactl is installed, but no PulseAudio/PipeWire Pulse server is reachable right now." @@ -305,7 +464,7 @@ run_as_user env TMPDIR="$TMPDIR" bash -c "cd '$SRC/client' && cargo clean && car # 5. install binary log "5. Installing launchable client binaries" -sudo install -Dm755 "$SRC/target/release/lesavka-client" /usr/local/bin/lesavka-client +install_verified_executable "$SRC/target/release/lesavka-client" /usr/local/bin/lesavka-client "lesavka-client" sudo ln -sf /usr/local/bin/lesavka-client /usr/local/bin/lesavka sudo install -d -m 0755 -o "$ORIG_USER" -g "$ORIG_USER" "$USER_HOME/.local/bin" sudo ln -sf /usr/local/bin/lesavka-client "$USER_HOME/.local/bin/lesavka-client" diff --git a/scripts/install/server.sh b/scripts/install/server.sh index e0855eb..8978cf8 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -13,6 +13,7 @@ INSTALL_SOURCE=${LESAVKA_INSTALL_SOURCE:-auto} USER_HOME=$(getent passwd "$ORIG_USER" | cut -d: -f6) INSTALL_UVC_CODEC=${LESAVKA_INSTALL_UVC_CODEC:-mjpeg} INSTALL_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-hevc}} +INSTALL_UPLINK_AUDIO_CODEC=${LESAVKA_INSTALL_UPLINK_AUDIO_CODEC:-${LESAVKA_UPLINK_AUDIO_CODEC:-pcm}} INSTALL_UVC_FRAME_META=${LESAVKA_INSTALL_UVC_FRAME_META:-${LESAVKA_UVC_FRAME_META:-0}} INSTALL_UVC_FRAME_META_LOG_PATH=${LESAVKA_INSTALL_UVC_FRAME_META_LOG_PATH:-${LESAVKA_UVC_FRAME_META_LOG_PATH:-/tmp/lesavka-uvc-frame-meta.jsonl}} INSTALL_SERVER_BIND_ADDR=${LESAVKA_INSTALL_SERVER_BIND_ADDR:-0.0.0.0:50051} @@ -26,6 +27,14 @@ DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=0 DEFAULT_HEVC_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=110000 DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=1280x720@20=0,1280x720@30=0,1920x1080@20=0,1920x1080@30=0 DEFAULT_HEVC_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US=1280x720@20=173852,1280x720@30=110000,1920x1080@20=160045,1920x1080@30=127952 +DEFAULT_MJPEG_PCM_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=$DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US +DEFAULT_MJPEG_PCM_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=$DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US +DEFAULT_HEVC_PCM_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=$DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US +DEFAULT_HEVC_PCM_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=$DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US +DEFAULT_MJPEG_OPUS_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=$DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US +DEFAULT_MJPEG_OPUS_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=$DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US +DEFAULT_HEVC_OPUS_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=$DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US +DEFAULT_HEVC_OPUS_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=$DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US LEGACY_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=-45000 PREVIOUS_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=720000 PREVIOUS_TUNED_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=1260000 @@ -149,6 +158,12 @@ ensure_hevc_decode_support() { else echo "โš ๏ธ no HEVC decoder exposed to GStreamer; install gst-libav or a v4l2 HEVC decoder before enabling HEVC transport" fi + + if gst-inspect-1.0 opusdec >/dev/null 2>&1; then + echo "โœ… Opus upstream audio decoder exposed: opusdec" + else + echo "โš ๏ธ no Opus decoder exposed to GStreamer; upstream Opus audio will be dropped until gst-plugins-base is available" + fi } manifest_package_version() { @@ -911,6 +926,67 @@ run_as_user() { sudo -u "$ORIG_USER" env HOME="$USER_HOME" SSH_AUTH_SOCK="${SSH_AUTH_SOCK:-}" "$@" } +pacman_install() { + local log_file + log_file=$(mktemp --tmpdir="$TMPDIR" lesavka-pacman.XXXXXX.log) + if sudo pacman -Sq --needed --noconfirm "$@" 2>&1 | tee "$log_file"; then + rm -f "$log_file" + return 0 + fi + if grep -Eq "breaks dependency '.*pipewire" "$log_file"; then + cat >&2 <<'MSG' +โŒ Arch stopped the package transaction because PipeWire packages are at mixed exact versions. + Lesavka installs PipeWire as one coherent set, but this host still needs a sync transaction. + Run during a safe maintenance window: + sudo pacman -Syu + Then rerun the Lesavka server installer. +MSG + elif grep -Eq "failed retrieving file|failed to retrieve some files|failed to commit transaction \\(failed to retrieve some files\\)" "$log_file"; then + cat >&2 <<'MSG' +โŒ Arch failed while downloading packages from the configured mirrors. + No Lesavka files were replaced. Refresh or choose healthier mirrors, then rerun the installer. + Good first retry: + sudo pacman -Syu --disable-download-timeout + If mirrors keep timing out, refresh /etc/pacman.d/mirrorlist before retrying. +MSG + fi + echo " pacman log: $log_file" >&2 + exit 1 +} + +install_verified_executable() { + local src=$1 + local dest=$2 + local label=${3:-$dest} + local dest_dir dest_base tmp + + if [[ ! -s "$src" ]]; then + echo "โŒ refusing to install $label: source '$src' is missing or empty." >&2 + echo " Preserving the existing installed executable at '$dest'." >&2 + exit 1 + fi + if [[ ! -x "$src" ]]; then + echo "โŒ refusing to install $label: source '$src' is not executable." >&2 + echo " Preserving the existing installed executable at '$dest'." >&2 + exit 1 + fi + + dest_dir=$(dirname "$dest") + dest_base=$(basename "$dest") + sudo install -d -m 0755 "$dest_dir" + tmp=$(sudo mktemp "$dest_dir/.${dest_base}.install.XXXXXX") + sudo rm -f "$tmp" + sudo install -Dm755 "$src" "$tmp" + if ! sudo test -s "$tmp" || ! sudo test -x "$tmp"; then + sudo rm -f "$tmp" + echo "โŒ refusing to replace $label: staged install output was not a non-empty executable." >&2 + echo " Preserving the existing installed executable at '$dest'." >&2 + exit 1 + fi + sudo mv -f "$tmp" "$dest" + sudo chmod 0755 "$dest" +} + CAPTURE_DISCOVERY_RELAY_PRESENT=0 CAPTURE_DISCOVERY_RELAY_WAS_ACTIVE=0 CAPTURE_DISCOVERY_POWER_BORROWED=0 @@ -966,28 +1042,32 @@ fi REPO_URL=${REPO_URL:-$DEFAULT_REPO_URL} echo "==> 1a. Base packages" -sudo pacman -Sq --needed --noconfirm git \ - rustup \ - protobuf \ - abseil-cpp \ - gcc \ - alsa-utils \ - pipewire \ - pipewire-pulse \ - tailscale \ - base-devel \ - v4l-utils \ - gstreamer \ - gst-plugins-base \ - gst-plugins-base-libs \ - gst-plugins-good \ - gst-plugins-bad \ - gst-plugins-bad-libs \ - gst-plugins-ugly \ - gst-libav \ - tcpdump \ - lsof \ - openssl +pacman_install git \ + rustup \ + protobuf \ + abseil-cpp \ + gcc \ + alsa-utils \ + libpipewire \ + pipewire \ + pipewire-audio \ + pipewire-alsa \ + pipewire-jack \ + pipewire-pulse \ + tailscale \ + base-devel \ + v4l-utils \ + gstreamer \ + gst-plugins-base \ + gst-plugins-base-libs \ + gst-plugins-good \ + gst-plugins-bad \ + gst-plugins-bad-libs \ + gst-plugins-ugly \ + gst-libav \ + tcpdump \ + lsof \ + openssl if ! command -v yay >/dev/null 2>&1; then echo "==> 1b. installing yay from AUR ..." run_as_user env TMPDIR="$TMPDIR" bash -c ' @@ -1095,11 +1175,12 @@ echo "==> 4c. Source build" run_as_user env TMPDIR="$TMPDIR" bash -c "cd '$SRC_DIR/server' && cargo clean && cargo build --release --bins" echo "==> 5. Install binaries" -sudo install -Dm755 "$SRC_DIR/target/release/lesavka-server" /usr/local/bin/lesavka-server -sudo install -Dm755 "$SRC_DIR/target/release/lesavka-uvc" /usr/local/bin/lesavka-uvc -sudo install -Dm755 "$SRC_DIR/scripts/daemon/lesavka-core.sh" /usr/local/bin/lesavka-core.sh -sudo install -Dm755 "$SRC_DIR/scripts/daemon/lesavka-uvc.sh" /usr/local/bin/lesavka-uvc.sh -sudo install -Dm755 "$SRC_DIR/scripts/manual/run_uac_output_sanity.sh" /usr/local/bin/lesavka-uac-sanity +install_verified_executable "$SRC_DIR/target/release/lesavka-server" /usr/local/bin/lesavka-server "lesavka-server" +install_verified_executable "$SRC_DIR/target/release/lesavka-uvc" /usr/local/bin/lesavka-uvc "lesavka-uvc" +install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-core.sh" /usr/local/bin/lesavka-core.sh "lesavka-core.sh" +install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-uvc.sh" /usr/local/bin/lesavka-uvc.sh "lesavka-uvc.sh" +install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-recovery-ladder.sh" /usr/local/bin/lesavka-recovery-ladder "lesavka-recovery-ladder" +install_verified_executable "$SRC_DIR/scripts/manual/run_uac_output_sanity.sh" /usr/local/bin/lesavka-uac-sanity "lesavka-uac-sanity" echo "==> 5b. Runtime environment defaults" sudo install -d -m 0755 /etc/lesavka @@ -1119,6 +1200,8 @@ fi fi printf 'LESAVKA_CAM_OUTPUT=%s\n' "${LESAVKA_INSTALL_CAM_OUTPUT:-uvc}" printf 'LESAVKA_CAM_CODEC=%s\n' "${INSTALL_CAM_CODEC}" + printf 'LESAVKA_UPLINK_CAMERA_CODEC=%s\n' "${INSTALL_CAM_CODEC}" + printf 'LESAVKA_UPLINK_AUDIO_CODEC=%s\n' "${INSTALL_UPLINK_AUDIO_CODEC}" printf 'LESAVKA_CAM_WIDTH=%s\n' "${LESAVKA_CAM_WIDTH:-1920}" printf 'LESAVKA_CAM_HEIGHT=%s\n' "${LESAVKA_CAM_HEIGHT:-1080}" printf 'LESAVKA_CAM_FPS=%s\n' "${LESAVKA_CAM_FPS:-30}" @@ -1143,6 +1226,14 @@ fi printf 'LESAVKA_UPSTREAM_HEVC_VIDEO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_VIDEO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_HEVC_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US}" printf 'LESAVKA_UPSTREAM_HEVC_AUDIO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_AUDIO_PLAYOUT_OFFSET_US:-$DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US}" printf 'LESAVKA_UPSTREAM_HEVC_VIDEO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_VIDEO_PLAYOUT_OFFSET_US:-$DEFAULT_HEVC_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US}" + printf 'LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_MJPEG_PCM_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US}" + printf 'LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_OFFSET_US:-$DEFAULT_MJPEG_PCM_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US}" + printf 'LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_HEVC_PCM_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US}" + printf 'LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_OFFSET_US:-$DEFAULT_HEVC_PCM_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US}" + printf 'LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_MJPEG_OPUS_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US}" + printf 'LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_OFFSET_US:-$DEFAULT_MJPEG_OPUS_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US}" + printf 'LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_HEVC_OPUS_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US}" + printf 'LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_OFFSET_US:-$DEFAULT_HEVC_OPUS_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US}" printf 'LESAVKA_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US}" printf 'LESAVKA_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_MJPEG_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US}" printf 'LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=%s\n' "$(resolve_upstream_audio_playout_offset_us)" @@ -1154,6 +1245,8 @@ fi printf 'LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP=%s\n' "${LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP:-1}" printf 'LESAVKA_UVC_HEVC_SIZE_DROP_PCT=%s\n' "${LESAVKA_UVC_HEVC_SIZE_DROP_PCT:-45}" printf 'LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES=%s\n' "${LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES:-65536}" + printf 'LESAVKA_UVC_HEVC_MIN_PAYLOAD_DISTINCT_BYTES=%s\n' "${LESAVKA_UVC_HEVC_MIN_PAYLOAD_DISTINCT_BYTES:-12}" + printf 'LESAVKA_UVC_HEVC_DOMINANT_BYTE_PCT=%s\n' "${LESAVKA_UVC_HEVC_DOMINANT_BYTE_PCT:-92}" printf 'LESAVKA_SERVER_BIND_ADDR=%s\n' "${INSTALL_SERVER_BIND_ADDR}" printf 'LESAVKA_UVC_CODEC=%s\n' "${INSTALL_UVC_CODEC}" printf 'LESAVKA_UVC_WIDTH=%s\n' "${LESAVKA_UVC_WIDTH:-1280}" @@ -1327,7 +1420,38 @@ sudo rm -f /etc/systemd/system/lesavka-watchdog.timer \ /usr/local/bin/lesavka-watchdog.sh \ /etc/lesavka/watchdog.touch +echo "==> 6e. Systemd units - recovery ladder" +cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-recovery-ladder.service >/dev/null +[Unit] +Description=lesavka soft recovery ladder +After=network.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/lesavka-recovery-ladder recover +Environment=LESAVKA_RECOVERY_TIMEOUT_SECONDS=60 +Environment=LESAVKA_RECOVERY_ALLOW_CORE_RESTART=0 +Environment=LESAVKA_RECOVERY_ALLOW_REBOOT=0 +EnvironmentFile=-/etc/lesavka/server.env +EnvironmentFile=-/etc/lesavka/uvc.env +UNIT + +cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-recovery-ladder.timer >/dev/null +[Unit] +Description=periodically check and softly recover lesavka services + +[Timer] +OnBootSec=90s +OnUnitActiveSec=60s +AccuracySec=10s +Persistent=true + +[Install] +WantedBy=timers.target +UNIT + sudo systemctl daemon-reload +sudo systemctl enable lesavka-recovery-ladder.timer if [[ "$UVC_ENV_CHANGED" == "1" ]] && systemctl is-active --quiet lesavka-uvc; then sudo systemctl restart lesavka-uvc @@ -1348,6 +1472,8 @@ clear_stale_server_listener sudo systemctl reset-failed lesavka-server >/dev/null 2>&1 || true sudo systemctl restart lesavka-server validate_server_ready +sudo /usr/local/bin/lesavka-recovery-ladder snapshot || true +sudo systemctl start lesavka-recovery-ladder.timer INSTALLED_VERSION=$(manifest_package_version "$SRC_DIR/server/Cargo.toml" 2>/dev/null || true) INSTALLED_SHA=$(git -C "$SCRIPT_REPO_ROOT" rev-parse --short HEAD 2>/dev/null || true) PERSISTED_CAM_OUTPUT=$(grep '^LESAVKA_CAM_OUTPUT=' /etc/lesavka/server.env 2>/dev/null | tail -n1 | cut -d= -f2- || true) diff --git a/scripts/manual/run_client_to_rct_transport_probe.sh b/scripts/manual/run_client_to_rct_transport_probe.sh index af054c0..f483536 100755 --- a/scripts/manual/run_client_to_rct_transport_probe.sh +++ b/scripts/manual/run_client_to_rct_transport_probe.sh @@ -1,13 +1,7 @@ #!/usr/bin/env bash # scripts/manual/run_client_to_rct_transport_probe.sh -# Manual: client-origin bundled transport probe to the RCT UVC/UAC endpoints. -# Not part of CI; hardware/lab manual only. -# -# This runner keeps server->RCT calibration tooling untouched. It starts an -# RCT capture, injects deterministic flash/tone media through -# `lesavka-sync-probe`, then measures final sync, freshness, and smoothness from -# the captured UVC/UAC output. - +# Manual hardware probe: inject bundled synthetic client media, then measure +# final RCT UVC/UAC sync, freshness, and smoothness without mutating the server. set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." >/dev/null 2>&1 && pwd)" @@ -27,6 +21,7 @@ REMOTE_PULSE_AUDIO_ANCHOR_SILENCE=${REMOTE_PULSE_AUDIO_ANCHOR_SILENCE:-1} REMOTE_CAPTURE_READY_TIMEOUT_SECONDS=${REMOTE_CAPTURE_READY_TIMEOUT_SECONDS:-30} REMOTE_CAPTURE_READY_SETTLE_SECONDS=${REMOTE_CAPTURE_READY_SETTLE_SECONDS:-1} REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS=${REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS:-3} +REMOTE_CAPTURE_PREROLL_SETTLE_SECONDS=${REMOTE_CAPTURE_PREROLL_SETTLE_SECONDS:-1} LESAVKA_CLIENT_RCT_START_DELAY_SECONDS=${LESAVKA_CLIENT_RCT_START_DELAY_SECONDS:-0} PROBE_DURATION_SECONDS=${PROBE_DURATION_SECONDS:-20} @@ -195,6 +190,7 @@ start_tethys_capture() { "${REMOTE_PULSE_VIDEO_MODE}" \ "${REMOTE_PULSE_AUDIO_ANCHOR_SILENCE}" \ "${REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS}" \ + "${REMOTE_CAPTURE_PREROLL_SETTLE_SECONDS}" \ "${REMOTE_CAPTURE_READY_SETTLE_SECONDS}" \ "${CAPTURE_READY_MARKER}" \ >"${LOCAL_CAPTURE_LOG}" 2>&1 <<'REMOTE_CAPTURE_SCRIPT' & @@ -209,8 +205,9 @@ pulse_tool=$7 video_mode=$8 anchor_silence=$9 preroll_discard=${10} -ready_settle=${11} -ready_marker=${12} +preroll_settle=${11} +ready_settle=${12} +ready_marker=${13} resolve_video_device() { find /dev/v4l/by-id -maxdepth 1 -type l \ @@ -270,10 +267,14 @@ run_preroll() { local seconds=$2 [[ "${seconds}" =~ ^[0-9]+$ && "${seconds}" -gt 0 ]] || return 0 printf 'discarding %ss of post-enumeration capture before probe\n' "${seconds}" >&2 - timeout --kill-after=2 --signal=INT "${seconds}" \ + timeout --kill-after=5 --signal=INT "$((seconds + 5))" \ gst-launch-1.0 -q -e v4l2src device="${video_device}" do-timestamp=true num-buffers="$((fps * seconds))" \ ! "image/jpeg,width=${width},height=${height},framerate=${fps}/1" ! fakesink \ >/dev/null 2>&1 || true + if [[ "${preroll_settle}" =~ ^[0-9]+$ && "${preroll_settle}" -gt 0 ]]; then + printf 'settling %ss after preroll discard\n' "${preroll_settle}" >&2 + sleep "${preroll_settle}" + fi } run_gst_pulse_capture() { diff --git a/scripts/manual/run_server_to_rc_mode_matrix.sh b/scripts/manual/run_server_to_rc_mode_matrix.sh index a8d04aa..601053a 100755 --- a/scripts/manual/run_server_to_rc_mode_matrix.sh +++ b/scripts/manual/run_server_to_rc_mode_matrix.sh @@ -33,7 +33,9 @@ SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=30"} LESAVKA_SERVER_RC_CORE_WEBCAM_MODES=${LESAVKA_SERVER_RC_CORE_WEBCAM_MODES:-1280x720@20,1280x720@30,1920x1080@20,1920x1080@30} LESAVKA_SERVER_RC_MODES=${LESAVKA_SERVER_RC_MODES:-${LESAVKA_SERVER_RC_CORE_WEBCAM_MODES}} LESAVKA_SERVER_RC_PROFILE=${LESAVKA_SERVER_RC_PROFILE:-mjpeg} +LESAVKA_SERVER_RC_AUDIO_PROFILE=${LESAVKA_SERVER_RC_AUDIO_PROFILE:-pcm} LESAVKA_SERVER_RC_NORMALIZED_PROFILE=mjpeg +LESAVKA_SERVER_RC_NORMALIZED_AUDIO_PROFILE=pcm LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US:-${LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US:-0}} LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US=${LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US:-135090} LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US=${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US:-1280x720@20=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US},1280x720@30=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US},1920x1080@20=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US},1920x1080@30=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US}} @@ -52,6 +54,15 @@ case "${LESAVKA_SERVER_RC_PROFILE,,}" in LESAVKA_SERVER_RC_MODE_DELAYS_US=${LESAVKA_SERVER_RC_MODE_DELAYS_US:-${LESAVKA_SERVER_RC_MJPEG_MODE_DELAYS_US}} ;; esac +case "${LESAVKA_SERVER_RC_AUDIO_PROFILE,,}" in + opus|compressed|voice) + LESAVKA_SERVER_RC_NORMALIZED_AUDIO_PROFILE=opus + ;; + *) + LESAVKA_SERVER_RC_NORMALIZED_AUDIO_PROFILE=pcm + ;; +esac +LESAVKA_SERVER_RC_CALIBRATION_PROFILE="${LESAVKA_SERVER_RC_NORMALIZED_PROFILE}+${LESAVKA_SERVER_RC_NORMALIZED_AUDIO_PROFILE}" LESAVKA_SERVER_RC_MODE_DISCOVERY_SIZES=${LESAVKA_SERVER_RC_MODE_DISCOVERY_SIZES:-1280x720,1920x1080} LESAVKA_SERVER_RC_MODE_DISCOVERY_FPS=${LESAVKA_SERVER_RC_MODE_DISCOVERY_FPS:-20,30} LESAVKA_SERVER_RC_MODE_DISCOVERY_INCLUDE_REGEX=${LESAVKA_SERVER_RC_MODE_DISCOVERY_INCLUDE_REGEX:-Logitech|BRIO|C9[0-9]+|HD UVC WebCam|USB2[.]0 HD|Integrated Camera|Webcam|Camera} @@ -1007,13 +1018,19 @@ reconfigure_server_mode() { LESAVKA_UVC_HEIGHT="${height}" \ LESAVKA_UVC_FPS="${fps}" \ LESAVKA_SERVER_RC_PROFILE="${LESAVKA_SERVER_RC_NORMALIZED_PROFILE}" \ - LESAVKA_CALIBRATION_PROFILE="${LESAVKA_SERVER_RC_NORMALIZED_PROFILE}" \ + LESAVKA_SERVER_RC_AUDIO_PROFILE="${LESAVKA_SERVER_RC_NORMALIZED_AUDIO_PROFILE}" \ + LESAVKA_CALIBRATION_PROFILE="${LESAVKA_SERVER_RC_CALIBRATION_PROFILE}" \ LESAVKA_UPLINK_CAMERA_CODEC="${LESAVKA_SERVER_RC_NORMALIZED_PROFILE}" \ + LESAVKA_UPLINK_AUDIO_CODEC="${LESAVKA_SERVER_RC_NORMALIZED_AUDIO_PROFILE}" \ LESAVKA_CAM_CODEC="${LESAVKA_SERVER_RC_NORMALIZED_PROFILE}" \ LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US="${audio_delay_us}" \ LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US="${video_delay_us}" \ LESAVKA_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US="${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}" \ LESAVKA_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US="${LESAVKA_SERVER_RC_MODE_DELAYS_US}" \ + LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US="${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}" \ + LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US="${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}" \ + LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US="${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}" \ + LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US="${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}" \ bash -c "${LESAVKA_SERVER_RC_RECONFIGURE_COMMAND}" return 0 fi @@ -1034,6 +1051,8 @@ reconfigure_server_mode() { "${interval}" \ "${LESAVKA_SERVER_RC_RECONFIGURE_CODEC}" \ "${LESAVKA_SERVER_RC_NORMALIZED_PROFILE}" \ + "${LESAVKA_SERVER_RC_NORMALIZED_AUDIO_PROFILE}" \ + "${LESAVKA_SERVER_RC_CALIBRATION_PROFILE}" \ "${LESAVKA_SERVER_RC_ALLOW_GADGET_RESET}" \ "${LESAVKA_SERVER_RC_FORCE_GADGET_REBUILD}" \ "${LESAVKA_SERVER_RC_RECONFIGURE_SETTLE_SECONDS}" \ @@ -1050,14 +1069,16 @@ fps=$4 interval=$5 codec=$6 ingress_profile=$7 -allow_gadget_reset=$8 -force_gadget_rebuild=$9 -settle_seconds=${10} -verbose=${11} -audio_delay_us=${12} -video_delay_us=${13} -audio_delay_map=${14} -video_delay_map=${15} +audio_profile=$8 +calibration_profile=$9 +allow_gadget_reset=${10} +force_gadget_rebuild=${11} +settle_seconds=${12} +verbose=${13} +audio_delay_us=${14} +video_delay_us=${15} +audio_delay_map=${16} +video_delay_map=${17} set_env_value() { local file=$1 @@ -1102,7 +1123,8 @@ touch /etc/lesavka/server.env /etc/lesavka/uvc.env set_env_value /etc/lesavka/server.env LESAVKA_CAM_OUTPUT uvc set_env_value /etc/lesavka/server.env LESAVKA_CAM_CODEC "${ingress_profile}" set_env_value /etc/lesavka/server.env LESAVKA_UPLINK_CAMERA_CODEC "${ingress_profile}" -set_env_value /etc/lesavka/server.env LESAVKA_CALIBRATION_PROFILE "${ingress_profile}" +set_env_value /etc/lesavka/server.env LESAVKA_UPLINK_AUDIO_CODEC "${audio_profile}" +set_env_value /etc/lesavka/server.env LESAVKA_CALIBRATION_PROFILE "${calibration_profile}" set_env_value /etc/lesavka/server.env LESAVKA_UVC_CODEC "${codec}" set_env_value /etc/lesavka/server.env LESAVKA_UVC_WIDTH "${width}" set_env_value /etc/lesavka/server.env LESAVKA_UVC_HEIGHT "${height}" @@ -1126,6 +1148,24 @@ case "${ingress_profile}" in set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_MJPEG_VIDEO_PLAYOUT_OFFSET_US "${video_delay_us}" ;; esac +case "${ingress_profile}+${audio_profile}" in + hevc+opus) + set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US "${audio_delay_map}" + set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_OFFSET_US "${audio_delay_us}" + ;; + hevc+pcm) + set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US "${audio_delay_map}" + set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_OFFSET_US "${audio_delay_us}" + ;; + mjpeg+opus) + set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US "${audio_delay_map}" + set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_OFFSET_US "${audio_delay_us}" + ;; + *) + set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US "${audio_delay_map}" + set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_OFFSET_US "${audio_delay_us}" + ;; +esac set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_CODEC "${codec}" set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_WIDTH "${width}" @@ -2285,7 +2325,7 @@ fi echo "==> server-to-RC mode matrix" echo " โ†ช modes=${LESAVKA_SERVER_RC_MODES}" echo " โ†ช mode_source=${LESAVKA_SERVER_RC_MODE_SOURCE}" -echo " โ†ช profile=${LESAVKA_SERVER_RC_PROFILE} capture_seconds=${CAPTURE_SECONDS:-auto} probe_timeout_seconds=${PROBE_TIMEOUT_SECONDS:-auto}" +echo " โ†ช profile=${LESAVKA_SERVER_RC_PROFILE} audio_profile=${LESAVKA_SERVER_RC_AUDIO_PROFILE} calibration_profile=${LESAVKA_SERVER_RC_CALIBRATION_PROFILE} capture_seconds=${CAPTURE_SECONDS:-auto} probe_timeout_seconds=${PROBE_TIMEOUT_SECONDS:-auto}" echo " โ†ช repeat_count=${LESAVKA_SERVER_RC_REPEAT_COUNT} verbose_probes=${LESAVKA_SERVER_RC_VERBOSE_PROBES}" echo " โ†ช video_delays=${LESAVKA_SERVER_RC_MODE_DELAYS_US}" echo " โ†ช audio_delays=${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}" diff --git a/server/Cargo.toml b/server/Cargo.toml index d7de53f..c86fb20 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.21.17" +version = "0.22.1" edition = "2024" autobins = false diff --git a/server/src/audio.rs b/server/src/audio.rs index cfe9679..e3a29ae 100644 --- a/server/src/audio.rs +++ b/server/src/audio.rs @@ -2,6 +2,9 @@ #![forbid(unsafe_code)] //! Server-side audio capture, watchdogs, and microphone gadget input handling. include!("audio/ear_capture.rs"); +mod opus_decode { + include!("audio/opus_decode.rs"); +} include!("audio/voice_input.rs"); #[cfg(test)] diff --git a/server/src/audio/opus_decode.rs b/server/src/audio/opus_decode.rs new file mode 100644 index 0000000..e492aed --- /dev/null +++ b/server/src/audio/opus_decode.rs @@ -0,0 +1,190 @@ +// Server-side Opus decode for upstream microphone packets. +// +// Why: UAC output remains a raw PCM gadget sink; compressed transport packets +// must be decoded immediately at ingress so existing playout timing and +// calibration continue to operate on the same PCM contract. + +use anyhow::{Context, Result, bail}; +use gst::prelude::*; +use gstreamer as gst; +use gstreamer_app as gst_app; +use lesavka_common::{ + audio_transport, + lesavka::AudioPacket, +}; +use std::time::Duration; + +const OPUS_DECODE_PULL_TIMEOUT: Duration = Duration::from_millis(30); + +pub(super) struct OpusPacketDecoder { + _pipeline: gst::Pipeline, + appsrc: gst_app::AppSrc, + appsink: gst_app::AppSink, +} + +impl OpusPacketDecoder { + pub(super) fn new() -> Result { + gst::init().context("gst init")?; + if gst::ElementFactory::find("opusdec").is_none() { + bail!("GStreamer opusdec plugin is not available"); + } + + let desc = "\ + appsrc name=src is-live=true block=false format=time \ + caps=audio/x-opus,channel-mapping-family=0 ! \ + opusdec plc=true use-inband-fec=false ! \ + audioconvert ! audioresample ! \ + audio/x-raw,format=S16LE,layout=interleaved,channels=2,rate=48000 ! \ + appsink name=sink emit-signals=false sync=false max-buffers=8 drop=true"; + let pipeline: gst::Pipeline = gst::parse::launch(desc)?.downcast().expect("pipeline"); + let appsrc = pipeline + .by_name("src") + .context("missing opus decoder appsrc")? + .downcast::() + .expect("opus decoder appsrc"); + let appsink = pipeline + .by_name("sink") + .context("missing opus decoder appsink")? + .downcast::() + .expect("opus decoder appsink"); + pipeline + .set_state(gst::State::Playing) + .context("start opus decoder pipeline")?; + + Ok(Self { + _pipeline: pipeline, + appsrc, + appsink, + }) + } + + pub(super) fn decode_packet(&mut self, packet: &AudioPacket) -> Result> { + let mut buffer = gst::Buffer::from_slice(packet.data.clone()); + if let Some(meta) = buffer.get_mut() { + let pts = gst::ClockTime::from_useconds(packet.pts); + meta.set_pts(Some(pts)); + meta.set_dts(Some(pts)); + meta.set_duration(Some(gst::ClockTime::from_useconds(u64::from( + packet.frame_duration_us.max(1), + )))); + } + self.appsrc + .push_buffer(buffer) + .context("push opus into decoder")?; + + let Some(sample) = + self.appsink + .try_pull_sample(gst::ClockTime::from_nseconds( + OPUS_DECODE_PULL_TIMEOUT + .as_nanos() + .min(u128::from(u64::MAX)) as u64, + )) + else { + return Ok(None); + }; + let decoded = sample + .buffer() + .context("opus decoder sample missing buffer")? + .map_readable() + .context("map decoded pcm")? + .to_vec(); + if decoded.is_empty() { + return Ok(None); + } + + let mut output = AudioPacket { + data: decoded, + ..packet.clone() + }; + audio_transport::mark_packet_pcm_s16le(&mut output); + Ok(Some(output)) + } +} + +impl Drop for OpusPacketDecoder { + fn drop(&mut self) { + let _ = self._pipeline.set_state(gst::State::Null); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lesavka_common::lesavka::AudioEncoding; + + #[test] + fn opus_decoder_roundtrips_to_pcm_when_plugins_are_available() { + let _ = gst::init(); + if gst::ElementFactory::find("opusenc").is_none() + || gst::ElementFactory::find("opusdec").is_none() + { + return; + } + + let Some(opus_payload) = encode_silent_opus_payload() else { + return; + }; + let packet = AudioPacket { + pts: 42_000, + encoding: AudioEncoding::Opus as i32, + sample_rate: 48_000, + channels: 2, + frame_duration_us: 20_000, + data: opus_payload, + ..AudioPacket::default() + }; + + let mut decoder = OpusPacketDecoder::new().expect("opus decoder"); + let decoded = decoder + .decode_packet(&packet) + .expect("decode") + .expect("decoded pcm"); + + assert_eq!(decoded.pts, 42_000); + assert_eq!(decoded.encoding, AudioEncoding::PcmS16le as i32); + assert_eq!(decoded.sample_rate, 48_000); + assert_eq!(decoded.channels, 2); + assert!( + decoded.data.len() >= 1_000, + "decoded PCM should be far larger than one compressed Opus frame" + ); + } + + fn encode_silent_opus_payload() -> Option> { + let desc = "\ + appsrc name=src is-live=true block=false format=time \ + caps=audio/x-raw,format=S16LE,layout=interleaved,channels=2,rate=48000 ! \ + opusenc audio-type=voice bitrate=64000 bitrate-type=constrained-vbr complexity=5 frame-size=20 ! \ + appsink name=sink emit-signals=false sync=false max-buffers=8 drop=true"; + let pipeline: gst::Pipeline = gst::parse::launch(desc).ok()?.downcast().ok()?; + let appsrc = pipeline + .by_name("src")? + .downcast::() + .ok()?; + let appsink = pipeline + .by_name("sink")? + .downcast::() + .ok()?; + pipeline.set_state(gst::State::Playing).ok()?; + + for index in 0..4u64 { + let mut buffer = gst::Buffer::from_slice(vec![0; 3_840]); + if let Some(meta) = buffer.get_mut() { + let pts = gst::ClockTime::from_useconds(index * 20_000); + meta.set_pts(Some(pts)); + meta.set_dts(Some(pts)); + meta.set_duration(Some(gst::ClockTime::from_useconds(20_000))); + } + appsrc.push_buffer(buffer).ok()?; + if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(50)) { + let payload = sample.buffer()?.map_readable().ok()?.to_vec(); + let _ = pipeline.set_state(gst::State::Null); + if !payload.is_empty() { + return Some(payload); + } + } + } + let _ = pipeline.set_state(gst::State::Null); + None + } +} diff --git a/server/src/audio/voice_input.rs b/server/src/audio/voice_input.rs index e9ba9a6..7ff1be0 100644 --- a/server/src/audio/voice_input.rs +++ b/server/src/audio/voice_input.rs @@ -54,6 +54,7 @@ pub struct Voice { _pipe: gst::Pipeline, // keep pipeline alive clock_aligned: bool, tap: ClipTap, + opus_decoder: Option, } impl Drop for Voice { @@ -206,6 +207,7 @@ impl Voice { _pipe: pipeline, clock_aligned: false, tap: ClipTap::new("voice", Duration::from_secs(60)), + opus_decoder: opus_decode::OpusPacketDecoder::new().ok(), }) } @@ -362,12 +364,65 @@ impl Voice { _pipe: pipeline, clock_aligned: !clock_align_enabled, tap: ClipTap::new("voice", Duration::from_secs(60)), + opus_decoder: opus_decode::OpusPacketDecoder::new().ok(), }) } /// Keeps `push` explicit because it sits on this module contract, where hidden behavior would make regressions difficult to diagnose. /// Inputs are the typed parameters; output is the return value or side effect. pub fn push(&mut self, pkt: &AudioPacket) { + let decoded; + let pkt = if lesavka_common::audio_transport::packet_is_raw_pcm_s16le(pkt) { + pkt + } else if lesavka_common::audio_transport::normalize_audio_encoding(pkt.encoding) + == lesavka_common::lesavka::AudioEncoding::Opus + { + let Some(decoder) = self.opus_decoder.as_mut() else { + tracing::warn!( + target: "lesavka_server::audio", + pts = pkt.pts, + bytes = pkt.data.len(), + encoding = pkt.encoding, + "๐ŸŽคโš ๏ธ dropping Opus microphone packet because opusdec is unavailable" + ); + return; + }; + match decoder.decode_packet(pkt) { + Ok(Some(packet)) => { + decoded = packet; + &decoded + } + Ok(None) => { + tracing::debug!( + target: "lesavka_server::audio", + pts = pkt.pts, + bytes = pkt.data.len(), + "๐ŸŽค Opus decoder produced no PCM yet" + ); + return; + } + Err(err) => { + tracing::warn!( + target: "lesavka_server::audio", + %err, + pts = pkt.pts, + bytes = pkt.data.len(), + "๐ŸŽคโš ๏ธ dropping Opus microphone packet after decode failure" + ); + return; + } + } + } else { + tracing::warn!( + target: "lesavka_server::audio", + pts = pkt.pts, + bytes = pkt.data.len(), + encoding = pkt.encoding, + "๐ŸŽคโš ๏ธ dropping upstream microphone packet with unsupported audio encoding" + ); + return; + }; + self.tap.feed(&pkt.data); if !self.clock_aligned { crate::media_timing::align_pipeline_to_session_clock(&self._pipe, pkt.pts); diff --git a/server/src/bin/lesavka_uvc/coverage_model.rs b/server/src/bin/lesavka_uvc/coverage_model.rs index 90281d1..e54465d 100644 --- a/server/src/bin/lesavka_uvc/coverage_model.rs +++ b/server/src/bin/lesavka_uvc/coverage_model.rs @@ -57,6 +57,8 @@ const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000; const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 10_000_000; #[cfg(coverage)] const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024; +#[cfg(coverage)] +const EMPTY_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9]; #[cfg(coverage)] #[repr(C)] @@ -139,3 +141,76 @@ struct ConfigfsSnapshot { maxpacket: u32, maxburst: u32, } + +#[cfg(coverage)] +struct MmapBuffer { + ptr: *mut u8, + len: usize, +} + +#[cfg(coverage)] +struct UvcVideoStream { + buffers: Vec, + frame_path: std::path::PathBuf, + latest_frame: Vec, + frame_max_bytes: usize, +} + +#[cfg(coverage)] +impl UvcVideoStream { + fn new(_fd: i32) -> Self { + Self { + buffers: Vec::new(), + frame_path: frame_spool_path(), + latest_frame: EMPTY_MJPEG_FRAME.to_vec(), + frame_max_bytes: MAX_MJPEG_FRAME_BYTES, + } + } + + fn refresh_latest_frame(&mut self) { + let stale = frame_spool_is_stale(&self.frame_path, frame_spool_max_age()); + if stale && looks_like_mjpeg_frame(&self.latest_frame) { + return; + } + let max_frame_bytes = self.frame_payload_limit(); + if let Ok(frame) = std::fs::read(&self.frame_path) + && frame.len() <= max_frame_bytes + && looks_like_mjpeg_frame(&frame) + { + self.latest_frame = frame; + } else if !looks_like_mjpeg_frame(&self.latest_frame) { + self.latest_frame = EMPTY_MJPEG_FRAME.to_vec(); + } + } + + fn frame_payload_limit(&self) -> usize { + self.buffers + .iter() + .map(|buffer| buffer.len) + .min() + .unwrap_or(MAX_MJPEG_FRAME_BYTES) + .min(self.frame_max_bytes) + } + + fn frame_for_buffer(&self, buffer_len: usize) -> &[u8] { + if self.latest_frame.len() <= buffer_len && looks_like_mjpeg_frame(&self.latest_frame) { + &self.latest_frame + } else { + EMPTY_MJPEG_FRAME + } + } +} + +#[cfg(coverage)] +fn frame_spool_path() -> std::path::PathBuf { + env::var("LESAVKA_UVC_FRAME_PATH") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::path::PathBuf::from("/run/lesavka-uvc-frame.mjpg")) +} + +#[cfg(coverage)] +fn looks_like_mjpeg_frame(frame: &[u8]) -> bool { + frame.len() > EMPTY_MJPEG_FRAME.len() + && frame.starts_with(&[0xff, 0xd8]) + && frame.ends_with(&[0xff, 0xd9]) +} diff --git a/server/src/calibration.rs b/server/src/calibration.rs index c89c067..5374672 100644 --- a/server/src/calibration.rs +++ b/server/src/calibration.rs @@ -15,10 +15,14 @@ mod profile_offsets; use mode_env::{current_uvc_mode, lookup_mode_offset_us}; pub use profile_offsets::{ FACTORY_HEVC_AUDIO_MODE_OFFSETS_US, FACTORY_HEVC_AUDIO_OFFSET_US, + FACTORY_HEVC_OPUS_AUDIO_MODE_OFFSETS_US, FACTORY_HEVC_OPUS_AUDIO_OFFSET_US, + FACTORY_HEVC_PCM_AUDIO_MODE_OFFSETS_US, FACTORY_HEVC_PCM_AUDIO_OFFSET_US, FACTORY_HEVC_VIDEO_MODE_OFFSETS_US, FACTORY_HEVC_VIDEO_OFFSET_1280X720_20_US, FACTORY_HEVC_VIDEO_OFFSET_1280X720_30_US, FACTORY_HEVC_VIDEO_OFFSET_1920X1080_20_US, FACTORY_HEVC_VIDEO_OFFSET_1920X1080_30_US, FACTORY_HEVC_VIDEO_OFFSET_US, FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US, FACTORY_MJPEG_AUDIO_OFFSET_US, + FACTORY_MJPEG_OPUS_AUDIO_MODE_OFFSETS_US, FACTORY_MJPEG_OPUS_AUDIO_OFFSET_US, + FACTORY_MJPEG_PCM_AUDIO_MODE_OFFSETS_US, FACTORY_MJPEG_PCM_AUDIO_OFFSET_US, FACTORY_MJPEG_VIDEO_MODE_OFFSETS_US, FACTORY_MJPEG_VIDEO_OFFSET_1280X720_20_US, FACTORY_MJPEG_VIDEO_OFFSET_1280X720_30_US, FACTORY_MJPEG_VIDEO_OFFSET_1920X1080_20_US, FACTORY_MJPEG_VIDEO_OFFSET_1920X1080_30_US, FACTORY_MJPEG_VIDEO_OFFSET_US, @@ -40,6 +44,12 @@ const FACTORY_CONFIDENCE: &str = "factory"; const PREVIOUS_OFFSET_LIMIT_US: i64 = 500_000; const OFFSET_LIMIT_US: i64 = 1_500_000; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CalibrationMedia { + Audio, + Video, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct CalibrationSnapshot { profile: String, @@ -227,6 +237,37 @@ pub fn calibration_path() -> PathBuf { .unwrap_or_else(|| PathBuf::from("/var/lib/lesavka/calibration.toml")) } +/// Resolve the startup playout offset for the current calibration profile. +/// +/// Inputs: media kind plus process env. Output: microsecond offset selected +/// from profile-specific env or factory maps. Why: the upstream runtime starts +/// before the persisted calibration store is loaded, so it still needs the same +/// camera/audio profile fork used by the durable calibration state. +pub fn configured_playout_offset_us(media: CalibrationMedia) -> i64 { + let mode = current_uvc_mode(); + let profile = current_profile(); + let (media_name, factory_map, factory_scalar, stale) = match media { + CalibrationMedia::Audio => ( + "AUDIO", + factory_audio_mode_offsets_us(&profile), + factory_audio_scalar_offset_us(&profile), + is_stale_audio_offset_us as fn(i64) -> bool, + ), + CalibrationMedia::Video => ( + "VIDEO", + factory_video_mode_offsets_us(&profile), + factory_video_scalar_offset_us(&profile), + is_stale_video_offset_us as fn(i64) -> bool, + ), + }; + configured_profile_offset_us(&profile, media_name, mode.as_deref(), stale) + .or_else(|| { + mode.as_deref() + .and_then(|mode| lookup_mode_offset_us(factory_map, mode)) + }) + .unwrap_or(factory_scalar) +} + /// Keeps `snapshot_from_env` explicit because it sits on calibration state, where persisted and factory offsets must stay auditable. /// Inputs are the typed parameters; output is the return value or side effect. fn snapshot_from_env() -> CalibrationSnapshot { diff --git a/server/src/calibration/profile_offsets.rs b/server/src/calibration/profile_offsets.rs index 4a3d433..a5baee7 100644 --- a/server/src/calibration/profile_offsets.rs +++ b/server/src/calibration/profile_offsets.rs @@ -16,6 +16,14 @@ pub const FACTORY_HEVC_AUDIO_MODE_OFFSETS_US: &str = "1280x720@20=0,1280x720@30=0,1920x1080@20=0,1920x1080@30=0"; pub const FACTORY_HEVC_VIDEO_MODE_OFFSETS_US: &str = "1280x720@20=173852,1280x720@30=110000,1920x1080@20=160045,1920x1080@30=127952"; +pub const FACTORY_MJPEG_PCM_AUDIO_MODE_OFFSETS_US: &str = FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US; +pub const FACTORY_HEVC_PCM_AUDIO_MODE_OFFSETS_US: &str = FACTORY_HEVC_AUDIO_MODE_OFFSETS_US; +pub const FACTORY_MJPEG_OPUS_AUDIO_MODE_OFFSETS_US: &str = FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US; +pub const FACTORY_HEVC_OPUS_AUDIO_MODE_OFFSETS_US: &str = FACTORY_HEVC_AUDIO_MODE_OFFSETS_US; +pub const FACTORY_MJPEG_PCM_AUDIO_OFFSET_US: i64 = FACTORY_MJPEG_AUDIO_OFFSET_US; +pub const FACTORY_HEVC_PCM_AUDIO_OFFSET_US: i64 = FACTORY_HEVC_AUDIO_OFFSET_US; +pub const FACTORY_MJPEG_OPUS_AUDIO_OFFSET_US: i64 = FACTORY_MJPEG_AUDIO_OFFSET_US; +pub const FACTORY_HEVC_OPUS_AUDIO_OFFSET_US: i64 = FACTORY_HEVC_AUDIO_OFFSET_US; // Direct UVC/UAC output-delay probes against the lab RC target showed a // per-mode sync center for MJPEG/UVC video. This is output-path compensation, // not a freshness buffer. The scalar fallback follows the default UVC mode. @@ -24,6 +32,8 @@ pub const FACTORY_HEVC_VIDEO_OFFSET_US: i64 = FACTORY_HEVC_VIDEO_OFFSET_1280X720 const MJPEG_PROFILE: &str = "mjpeg"; const HEVC_PROFILE: &str = "hevc"; +const PCM_AUDIO_PROFILE: &str = "pcm"; +const OPUS_AUDIO_PROFILE: &str = "opus"; use super::mode_env::{env_i64, env_mode_offset_us}; @@ -40,24 +50,40 @@ pub(super) fn configured_profile_offset_us( mode: Option<&str>, is_stale_scalar: impl Fn(i64) -> bool, ) -> Option { - let profile_prefix = profile.to_ascii_uppercase(); + let camera_profile = camera_profile_from_calibration_profile(profile); + let audio_profile = audio_profile_from_calibration_profile(profile); + let combined_prefix = format!( + "{}_{}", + camera_profile.to_ascii_uppercase(), + audio_profile.to_ascii_uppercase() + ); + let camera_prefix = camera_profile.to_ascii_uppercase(); + let combined_mode_map_name = + format!("LESAVKA_UPSTREAM_{combined_prefix}_{media}_PLAYOUT_MODE_OFFSETS_US"); + let combined_scalar_name = + format!("LESAVKA_UPSTREAM_{combined_prefix}_{media}_PLAYOUT_OFFSET_US"); let profile_mode_map_name = - format!("LESAVKA_UPSTREAM_{profile_prefix}_{media}_PLAYOUT_MODE_OFFSETS_US"); - let profile_scalar_name = - format!("LESAVKA_UPSTREAM_{profile_prefix}_{media}_PLAYOUT_OFFSET_US"); + format!("LESAVKA_UPSTREAM_{camera_prefix}_{media}_PLAYOUT_MODE_OFFSETS_US"); + let profile_scalar_name = format!("LESAVKA_UPSTREAM_{camera_prefix}_{media}_PLAYOUT_OFFSET_US"); let generic_mode_map_name = format!("LESAVKA_UPSTREAM_{media}_PLAYOUT_MODE_OFFSETS_US"); let generic_scalar_name = format!("LESAVKA_UPSTREAM_{media}_PLAYOUT_OFFSET_US"); - let profile_offset = mode + let combined_offset = mode + .and_then(|mode| env_mode_offset_us(&combined_mode_map_name, mode)) + .or_else(|| env_i64(&combined_scalar_name).filter(|offset| !is_stale_scalar(*offset))); + if combined_offset.is_some() { + return combined_offset; + } + let camera_offset = mode .and_then(|mode| env_mode_offset_us(&profile_mode_map_name, mode)) .or_else(|| env_i64(&profile_scalar_name).filter(|offset| !is_stale_scalar(*offset))); - if profile_offset.is_some() { - return profile_offset; + if camera_offset.is_some() { + return camera_offset; } // Generic playout variables predate ingress profiles and were written by - // MJPEG installs. Do not let those stale maps silently override HEVC decode - // factory offsets; HEVC deployments can still opt in with the profile knobs. - if profile != MJPEG_PROFILE { + // MJPEG/PCM installs. Do not let those stale maps silently override HEVC or + // Opus factory offsets; new deployments can still opt in with profile knobs. + if camera_profile != MJPEG_PROFILE || audio_profile != PCM_AUDIO_PROFILE { return None; } @@ -67,49 +93,131 @@ pub(super) fn configured_profile_offset_us( /// Resolve the active calibration profile from explicit and codec env hints. /// -/// Inputs: process environment. Output: normalized profile string. Why: -/// persisted calibration must follow the ingress codec, not just the UVC output -/// codec, because HEVC media pays decode/re-encode cost before reaching RCT. +/// Inputs: process environment. Output: normalized `camera+audio` profile +/// string. Why: persisted calibration must follow both the video ingress codec +/// and the microphone transport, because HEVC and Opus can each add timing cost +/// before media reaches the RCT. pub(super) fn current_profile() -> String { + let camera_profile = current_camera_profile(); + let audio_profile = current_audio_profile(); + format!("{camera_profile}+{audio_profile}") +} + +/// Resolve the camera side of the active calibration profile. +/// +/// Inputs: process environment. Output: `mjpeg` or `hevc`. Why: the camera +/// ingress codec and microphone transport can now fork independently while +/// still sharing the old profile spellings for compatibility. +fn current_camera_profile() -> String { std::env::var("LESAVKA_CALIBRATION_PROFILE") .ok() - .and_then(|value| normalize_profile(&value)) + .and_then(|value| normalize_camera_profile(&value)) .or_else(|| { std::env::var("LESAVKA_UPLINK_CAMERA_CODEC") .ok() - .and_then(|value| normalize_profile(&value)) + .and_then(|value| normalize_camera_profile(&value)) }) .or_else(|| { std::env::var("LESAVKA_CAM_CODEC") .ok() - .and_then(|value| normalize_profile(&value)) + .and_then(|value| normalize_camera_profile(&value)) }) .unwrap_or_else(|| MJPEG_PROFILE.to_string()) } +/// Resolve the microphone side of the active calibration profile. +/// +/// Inputs: process environment. Output: `pcm` or `opus`. Why: Opus adds codec +/// delay before UAC, so it needs its own calibration namespace without erasing +/// the already measured PCM factory values. +fn current_audio_profile() -> String { + std::env::var("LESAVKA_CALIBRATION_PROFILE") + .ok() + .and_then(|value| normalize_audio_profile(&value)) + .or_else(|| { + std::env::var("LESAVKA_CALIBRATION_AUDIO_CODEC") + .ok() + .and_then(|value| normalize_audio_profile(&value)) + }) + .or_else(|| { + std::env::var("LESAVKA_UPLINK_AUDIO_CODEC") + .ok() + .and_then(|value| normalize_audio_profile(&value)) + }) + .or_else(|| { + std::env::var("LESAVKA_AUDIO_CODEC") + .ok() + .and_then(|value| normalize_audio_profile(&value)) + }) + .unwrap_or_else(|| PCM_AUDIO_PROFILE.to_string()) +} + /// Normalize user-facing codec spellings into calibration profile names. /// /// Inputs: a codec/profile string. Output: `Some(profile)` for known camera /// ingress profiles. Why: operators and install scripts use `h265`, `h.265`, /// `mjpg`, and `jpeg` spellings interchangeably, but the stored calibration /// profile must remain stable. -fn normalize_profile(value: &str) -> Option { - match value.trim().to_ascii_lowercase().as_str() { +fn normalize_camera_profile(value: &str) -> Option { + let normalized = value.trim().to_ascii_lowercase(); + for token in normalized.split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '.') { + match token { + "hevc" | "h265" | "h.265" => return Some(HEVC_PROFILE.to_string()), + "mjpeg" | "mjpg" | "jpeg" => return Some(MJPEG_PROFILE.to_string()), + _ => {} + } + } + match normalized.as_str() { "hevc" | "h265" | "h.265" => Some(HEVC_PROFILE.to_string()), "mjpeg" | "mjpg" | "jpeg" => Some(MJPEG_PROFILE.to_string()), _ => None, } } +/// Normalize user-facing microphone transport spellings into calibration names. +/// +/// Inputs: a codec/profile string. Output: `Some(profile)` for known upstream +/// audio transports. Why: Opus can be selected in the client UI while PCM +/// remains the known-good fallback, and each side needs a stable profile key. +fn normalize_audio_profile(value: &str) -> Option { + let normalized = value.trim().to_ascii_lowercase(); + for token in normalized.split(|ch: char| !ch.is_ascii_alphanumeric()) { + match token { + "opus" | "compressed" | "voice" => return Some(OPUS_AUDIO_PROFILE.to_string()), + "pcm" | "raw" | "s16le" | "uncompressed" => { + return Some(PCM_AUDIO_PROFILE.to_string()); + } + _ => {} + } + } + match normalized.as_str() { + "opus" | "compressed" | "voice" => Some(OPUS_AUDIO_PROFILE.to_string()), + "pcm" | "raw" | "s16le" | "uncompressed" => Some(PCM_AUDIO_PROFILE.to_string()), + _ => None, + } +} + +fn camera_profile_from_calibration_profile(profile: &str) -> String { + normalize_camera_profile(profile).unwrap_or_else(|| MJPEG_PROFILE.to_string()) +} + +fn audio_profile_from_calibration_profile(profile: &str) -> String { + normalize_audio_profile(profile).unwrap_or_else(|| PCM_AUDIO_PROFILE.to_string()) +} + /// Return the factory per-mode audio offset map for a calibration profile. /// /// Inputs: normalized profile. Output: comma-separated mode map. Why: keeping /// audio maps profile-aware lets future decode paths diverge without changing /// the persisted calibration schema again. pub(super) fn factory_audio_mode_offsets_us(profile: &str) -> &'static str { - match profile { - HEVC_PROFILE => FACTORY_HEVC_AUDIO_MODE_OFFSETS_US, - _ => FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US, + let camera = camera_profile_from_calibration_profile(profile); + let audio = audio_profile_from_calibration_profile(profile); + match (camera.as_str(), audio.as_str()) { + (HEVC_PROFILE, OPUS_AUDIO_PROFILE) => FACTORY_HEVC_OPUS_AUDIO_MODE_OFFSETS_US, + (HEVC_PROFILE, _) => FACTORY_HEVC_PCM_AUDIO_MODE_OFFSETS_US, + (MJPEG_PROFILE, OPUS_AUDIO_PROFILE) => FACTORY_MJPEG_OPUS_AUDIO_MODE_OFFSETS_US, + _ => FACTORY_MJPEG_PCM_AUDIO_MODE_OFFSETS_US, } } @@ -119,7 +227,7 @@ pub(super) fn factory_audio_mode_offsets_us(profile: &str) -> &'static str { /// where HEVC decode and MJPEG re-emission can shift sync, so each ingress /// profile needs its own baked server-to-RCT center points. pub(super) fn factory_video_mode_offsets_us(profile: &str) -> &'static str { - match profile { + match camera_profile_from_calibration_profile(profile).as_str() { HEVC_PROFILE => FACTORY_HEVC_VIDEO_MODE_OFFSETS_US, _ => FACTORY_MJPEG_VIDEO_MODE_OFFSETS_US, } @@ -131,9 +239,13 @@ pub(super) fn factory_video_mode_offsets_us(profile: &str) -> &'static str { /// callers still need a safe default when no UVC mode is visible during early /// process startup. pub(super) fn factory_audio_scalar_offset_us(profile: &str) -> i64 { - match profile { - HEVC_PROFILE => FACTORY_HEVC_AUDIO_OFFSET_US, - _ => FACTORY_MJPEG_AUDIO_OFFSET_US, + let camera = camera_profile_from_calibration_profile(profile); + let audio = audio_profile_from_calibration_profile(profile); + match (camera.as_str(), audio.as_str()) { + (HEVC_PROFILE, OPUS_AUDIO_PROFILE) => FACTORY_HEVC_OPUS_AUDIO_OFFSET_US, + (HEVC_PROFILE, _) => FACTORY_HEVC_PCM_AUDIO_OFFSET_US, + (MJPEG_PROFILE, OPUS_AUDIO_PROFILE) => FACTORY_MJPEG_OPUS_AUDIO_OFFSET_US, + _ => FACTORY_MJPEG_PCM_AUDIO_OFFSET_US, } } @@ -144,7 +256,7 @@ pub(super) fn factory_audio_scalar_offset_us(profile: &str) -> i64 { /// most common calibrated profile instead of silently borrowing stale MJPEG /// values for HEVC. pub(super) fn factory_video_scalar_offset_us(profile: &str) -> i64 { - match profile { + match camera_profile_from_calibration_profile(profile).as_str() { HEVC_PROFILE => FACTORY_HEVC_VIDEO_OFFSET_US, _ => FACTORY_MJPEG_VIDEO_OFFSET_US, } diff --git a/server/src/calibration/tests/mod.rs b/server/src/calibration/tests/mod.rs index 6ba5b77..87bd0dc 100644 --- a/server/src/calibration/tests/mod.rs +++ b/server/src/calibration/tests/mod.rs @@ -34,7 +34,30 @@ fn with_clean_offset_env(test: impl FnOnce()) { ("LESAVKA_CAM_FPS", None::<&str>), ("LESAVKA_CAM_CODEC", None::<&str>), ("LESAVKA_UPLINK_CAMERA_CODEC", None::<&str>), + ("LESAVKA_UPLINK_AUDIO_CODEC", None::<&str>), + ("LESAVKA_AUDIO_CODEC", None::<&str>), + ("LESAVKA_CALIBRATION_AUDIO_CODEC", None::<&str>), ("LESAVKA_CALIBRATION_PROFILE", None::<&str>), + ( + "LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US", + None::<&str>, + ), + ( + "LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US", + None::<&str>, + ), + ( + "LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US", + None::<&str>, + ), + ( + "LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US", + None::<&str>, + ), + ( + "LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_OFFSET_US", + None::<&str>, + ), ( "LESAVKA_UPSTREAM_HEVC_AUDIO_PLAYOUT_MODE_OFFSETS_US", None::<&str>, @@ -169,7 +192,7 @@ fn uvc_mode_detection_falls_back_to_camera_fields_after_invalid_modes() { fn default_snapshot_uses_factory_mjpeg_calibration() { with_clean_offset_env(|| { let state = snapshot_from_env(); - assert_eq!(state.profile, "mjpeg"); + assert_eq!(state.profile, "mjpeg+pcm"); assert_eq!(state.default_audio_offset_us, 0); assert_eq!(state.active_video_offset_us, FACTORY_MJPEG_VIDEO_OFFSET_US); assert_eq!(state.source, "factory"); @@ -188,7 +211,7 @@ fn hevc_profile_uses_hevc_factory_calibration_without_changing_mjpeg_defaults() ], || { let state = snapshot_from_env(); - assert_eq!(state.profile, "hevc"); + assert_eq!(state.profile, "hevc+pcm"); assert_eq!( state.factory_video_offset_us, FACTORY_HEVC_VIDEO_OFFSET_1280X720_20_US @@ -203,6 +226,65 @@ fn hevc_profile_uses_hevc_factory_calibration_without_changing_mjpeg_defaults() }); } +#[test] +fn opus_audio_profile_gets_its_own_factory_and_env_namespace() { + with_clean_offset_env(|| { + temp_env::with_vars( + [ + ("LESAVKA_UPLINK_CAMERA_CODEC", Some("hevc")), + ("LESAVKA_UPLINK_AUDIO_CODEC", Some("opus")), + ("LESAVKA_UVC_WIDTH", Some("1280")), + ("LESAVKA_UVC_HEIGHT", Some("720")), + ("LESAVKA_UVC_FPS", Some("30")), + ], + || { + let state = snapshot_from_env(); + assert_eq!(state.profile, "hevc+opus"); + assert_eq!( + state.factory_audio_offset_us, + FACTORY_HEVC_OPUS_AUDIO_OFFSET_US + ); + assert_eq!( + state.factory_video_offset_us, + FACTORY_HEVC_VIDEO_OFFSET_1280X720_30_US + ); + }, + ); + }); +} + +#[test] +fn opus_audio_profile_specific_map_does_not_overwrite_pcm_baseline() { + with_clean_offset_env(|| { + temp_env::with_vars( + [ + ("LESAVKA_UPLINK_CAMERA_CODEC", Some("hevc")), + ("LESAVKA_UPLINK_AUDIO_CODEC", Some("opus")), + ("LESAVKA_UVC_WIDTH", Some("1280")), + ("LESAVKA_UVC_HEIGHT", Some("720")), + ("LESAVKA_UVC_FPS", Some("30")), + ( + "LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US", + Some("1280x720@30=11111"), + ), + ( + "LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US", + Some("1280x720@30=22222"), + ), + ], + || { + let state = snapshot_from_env(); + assert_eq!(state.profile, "hevc+opus"); + assert_eq!(state.default_audio_offset_us, 22_222); + assert_eq!( + FACTORY_HEVC_PCM_AUDIO_MODE_OFFSETS_US, FACTORY_HEVC_AUDIO_MODE_OFFSETS_US, + "PCM factory map remains the established golden baseline" + ); + }, + ); + }); +} + #[test] fn hevc_profile_specific_env_map_overrides_generic_map() { with_clean_offset_env(|| { @@ -223,7 +305,7 @@ fn hevc_profile_specific_env_map_overrides_generic_map() { ], || { let state = snapshot_from_env(); - assert_eq!(state.profile, "hevc"); + assert_eq!(state.profile, "hevc+pcm"); assert_eq!(state.default_video_offset_us, 222_222); assert_eq!(state.source, "env"); }, @@ -247,7 +329,7 @@ fn hevc_profile_ignores_legacy_generic_mjpeg_map() { ], || { let state = snapshot_from_env(); - assert_eq!(state.profile, "hevc"); + assert_eq!(state.profile, "hevc+pcm"); assert_eq!( state.default_video_offset_us, FACTORY_HEVC_VIDEO_OFFSET_1280X720_30_US @@ -831,7 +913,7 @@ fn restore_factory_rebuilds_current_profile_and_mode() { ..CalibrationRequest::default() }) .expect("factory restore"); - assert_eq!(factory.profile, "hevc"); + assert_eq!(factory.profile, "hevc+pcm"); assert_eq!( factory.active_video_offset_us, FACTORY_HEVC_VIDEO_OFFSET_1280X720_20_US diff --git a/server/src/main/relay_service.rs b/server/src/main/relay_service.rs index 911a614..b9d3b13 100644 --- a/server/src/main/relay_service.rs +++ b/server/src/main/relay_service.rs @@ -1,11 +1,7 @@ -#[cfg(not(coverage))] const MEDIA_V2_DEFAULT_PLAYOUT_DELAY_MS: u64 = 20; -#[cfg(not(coverage))] const MEDIA_V2_DEFAULT_MAX_LIVE_AGE_MS: u64 = 1_000; -#[cfg(not(coverage))] const MEDIA_V2_MAX_MIXED_CAPTURE_SPAN_US: u64 = 250_000; -#[cfg(not(coverage))] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] struct MediaV2BundleFacts { has_audio: bool, @@ -16,7 +12,6 @@ struct MediaV2BundleFacts { max_queue_age_ms: u32, } -#[cfg(not(coverage))] #[derive(Clone, Copy, Debug)] struct MediaV2HandoffSchedule { audio_due_at: Option, @@ -38,7 +33,6 @@ struct MediaV2ScheduledVideo { due_at: tokio::time::Instant, } -#[cfg(not(coverage))] /// Keeps `summarize_media_v2_bundle` explicit because it sits on relay RPC orchestration, where hardware failures must surface without stopping the server. /// Inputs are the typed parameters; output is the return value or side effect. fn summarize_media_v2_bundle(bundle: &UpstreamMediaBundle) -> Option { @@ -75,7 +69,6 @@ fn summarize_media_v2_bundle(bundle: &UpstreamMediaBundle) -> Option u64 { @@ -86,7 +79,6 @@ fn packet_audio_capture_pts_us(packet: &AudioPacket) -> u64 { } } -#[cfg(not(coverage))] /// Keeps `packet_video_capture_pts_us` explicit because it sits on relay RPC orchestration, where hardware failures must surface without stopping the server. /// Inputs are the typed parameters; output is the return value or side effect. fn packet_video_capture_pts_us(packet: &VideoPacket) -> u64 { @@ -97,7 +89,6 @@ fn packet_video_capture_pts_us(packet: &VideoPacket) -> u64 { } } -#[cfg(not(coverage))] fn media_v2_playout_delay() -> Duration { std::env::var("LESAVKA_UPSTREAM_V2_PLAYOUT_DELAY_MS") .or_else(|_| std::env::var("LESAVKA_UPSTREAM_BUNDLED_PLAYOUT_DELAY_MS")) @@ -107,7 +98,6 @@ fn media_v2_playout_delay() -> Duration { .unwrap_or_else(|| Duration::from_millis(MEDIA_V2_DEFAULT_PLAYOUT_DELAY_MS)) } -#[cfg(not(coverage))] fn media_v2_max_live_age() -> Duration { std::env::var("LESAVKA_UPSTREAM_V2_MAX_LIVE_AGE_MS") .or_else(|_| std::env::var("LESAVKA_UPSTREAM_MAX_LIVE_LAG_MS")) @@ -117,7 +107,6 @@ fn media_v2_max_live_age() -> Duration { .unwrap_or_else(|| Duration::from_millis(MEDIA_V2_DEFAULT_MAX_LIVE_AGE_MS)) } -#[cfg(not(coverage))] /// Keeps `media_v2_handoff_schedule` explicit because it sits on relay RPC orchestration, where hardware failures must surface without stopping the server. /// Inputs are the typed parameters; output is the return value or side effect. fn media_v2_handoff_schedule( @@ -159,7 +148,6 @@ fn media_v2_handoff_schedule( }) } -#[cfg(not(coverage))] /// Convert a negotiated video FPS into a microsecond frame step. /// /// Inputs: frames per second from the camera config. Output: at least one @@ -177,7 +165,6 @@ fn media_v2_drop_late_plan(plan: &PlannedUpstreamPacket) -> bool { plan.late_by >= media_v2_max_live_age() } -#[cfg(not(coverage))] /// Decide whether server-side HEVC recovery should hold a video packet. /// /// Inputs: current recovery state, camera codec, and candidate video packet. @@ -196,7 +183,6 @@ fn media_v2_should_hold_hevc_video_for_recovery( }) } -#[cfg(not(coverage))] /// Report whether a video packet can repair a server-side HEVC reference gap. /// /// Inputs: camera codec and candidate video packet. Output: true for HEVC IRAP diff --git a/server/src/output_delay_probe/probe_runtime.rs b/server/src/output_delay_probe/probe_runtime.rs index 13bc7b3..38002f2 100644 --- a/server/src/output_delay_probe/probe_runtime.rs +++ b/server/src/output_delay_probe/probe_runtime.rs @@ -64,6 +64,10 @@ pub async fn run_server_output_delay_probe( client_send_pts_us: pts_us, client_queue_depth: 0, client_queue_age_ms: 0, + encoding: lesavka_common::lesavka::AudioEncoding::PcmS16le as i32, + sample_rate: AUDIO_SAMPLE_RATE, + channels: AUDIO_CHANNELS as u32, + frame_duration_us: AUDIO_CHUNK_MS.saturating_mul(1_000) as u32, }); if let Some(slot) = event_slot { let monotonic_us = monotonic_us_since(start); diff --git a/server/src/paste.rs b/server/src/paste.rs index 12878e1..eb8b2b3 100644 --- a/server/src/paste.rs +++ b/server/src/paste.rs @@ -1,7 +1,7 @@ // server/src/paste.rs #![forbid(unsafe_code)] -use anyhow::{Context, Result}; +use anyhow::{Context, Result, ensure}; use chacha20poly1305::aead::{Aead, KeyInit}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; use std::{path::PathBuf, sync::Arc}; @@ -23,6 +23,7 @@ pub fn decrypt(req: &PasteRequest) -> Result { if !req.encrypted { anyhow::bail!("paste request must be encrypted"); } + ensure!(req.nonce.len() == 12, "paste nonce must be 12 bytes"); let key = load_key()?; let cipher = ChaCha20Poly1305::new(Key::from_slice(&key)); let nonce = Nonce::from_slice(&req.nonce); @@ -97,8 +98,7 @@ fn unsupported_chars(chars: impl Iterator) -> (usize, String) { } fn load_key() -> Result<[u8; 32]> { - let raw = load_key_material()?; - decode_shared_key(&raw) + decode_shared_key(&load_key_material()?) } fn load_key_material() -> Result { diff --git a/server/src/upstream_media_runtime.rs b/server/src/upstream_media_runtime.rs index 7b849e0..f971980 100644 --- a/server/src/upstream_media_runtime.rs +++ b/server/src/upstream_media_runtime.rs @@ -7,10 +7,7 @@ use std::time::Duration; use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use tokio::time::Instant; -use crate::calibration::{ - FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US, FACTORY_MJPEG_AUDIO_OFFSET_US, - FACTORY_MJPEG_VIDEO_MODE_OFFSETS_US, FACTORY_MJPEG_VIDEO_OFFSET_US, -}; +use crate::calibration::{CalibrationMedia, configured_playout_offset_us}; const TIMING_WINDOW_CAPACITY: usize = 240; @@ -362,68 +359,11 @@ fn upstream_playout_delay() -> Duration { /// Keeps `playout_offset_us` explicit because it sits on server upstream media scheduling, where timing choices directly affect lip sync. /// Inputs are the typed parameters; output is the return value or side effect. fn playout_offset_us(kind: UpstreamMediaKind) -> i64 { - let (scalar_name, mode_map_name, factory_map, factory_offset_us) = match kind { - UpstreamMediaKind::Camera => ( - "LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US", - "LESAVKA_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US", - FACTORY_MJPEG_VIDEO_MODE_OFFSETS_US, - FACTORY_MJPEG_VIDEO_OFFSET_US, - ), - UpstreamMediaKind::Microphone => ( - "LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US", - "LESAVKA_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US", - FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US, - FACTORY_MJPEG_AUDIO_OFFSET_US, - ), + let media = match kind { + UpstreamMediaKind::Camera => CalibrationMedia::Video, + UpstreamMediaKind::Microphone => CalibrationMedia::Audio, }; - let mode = current_uvc_mode(); - mode.as_deref() - .and_then(|mode| env_mode_offset_us(mode_map_name, mode)) - .or_else(|| env_i64(scalar_name)) - .or_else(|| { - mode.as_deref() - .and_then(|mode| lookup_mode_offset_us(factory_map, mode)) - }) - .unwrap_or(factory_offset_us) -} - -fn current_uvc_mode() -> Option { - let width = env_u32("LESAVKA_UVC_WIDTH")?; - let height = env_u32("LESAVKA_UVC_HEIGHT")?; - let fps = env_u32("LESAVKA_UVC_FPS") - .or_else(|| { - env_u32("LESAVKA_UVC_INTERVAL") - .and_then(|interval| (interval > 0).then_some(10_000_000 / interval)) - })? - .max(1); - Some(format!("{width}x{height}@{fps}")) -} - -fn env_mode_offset_us(name: &str, mode: &str) -> Option { - std::env::var(name) - .ok() - .and_then(|map| lookup_mode_offset_us(&map, mode)) -} - -fn lookup_mode_offset_us(map: &str, mode: &str) -> Option { - map.split(',').find_map(|entry| { - let (key, value) = entry.trim().split_once('=')?; - (key.trim() == mode) - .then(|| value.trim().parse::().ok()) - .flatten() - }) -} - -fn env_i64(name: &str) -> Option { - std::env::var(name) - .ok() - .and_then(|value| value.trim().parse::().ok()) -} - -fn env_u32(name: &str) -> Option { - std::env::var(name) - .ok() - .and_then(|value| value.trim().parse::().ok()) + configured_playout_offset_us(media) } /// Keeps `apply_offset` explicit because it sits on server upstream media scheduling, where timing choices directly affect lip sync. diff --git a/server/src/upstream_media_runtime/tests/mod.rs b/server/src/upstream_media_runtime/tests/mod.rs index 9658452..5732cca 100644 --- a/server/src/upstream_media_runtime/tests/mod.rs +++ b/server/src/upstream_media_runtime/tests/mod.rs @@ -19,6 +19,15 @@ fn with_clean_offset_env(test: impl FnOnce()) { ("LESAVKA_UVC_HEIGHT", None::<&str>), ("LESAVKA_UVC_FPS", None::<&str>), ("LESAVKA_UVC_INTERVAL", None::<&str>), + ("LESAVKA_CAM_CODEC", None::<&str>), + ("LESAVKA_UPLINK_CAMERA_CODEC", None::<&str>), + ("LESAVKA_UPLINK_AUDIO_CODEC", None::<&str>), + ("LESAVKA_CALIBRATION_AUDIO_CODEC", None::<&str>), + ("LESAVKA_CALIBRATION_PROFILE", None::<&str>), + ( + "LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US", + None::<&str>, + ), ], test, ); @@ -78,6 +87,29 @@ fn runtime_prefers_mode_offset_map_over_scalar_fallback() { }); } +#[test] +fn runtime_can_start_with_opus_specific_audio_calibration_profile() { + with_clean_offset_env(|| { + temp_env::with_vars( + [ + ("LESAVKA_UVC_WIDTH", Some("1280")), + ("LESAVKA_UVC_HEIGHT", Some("720")), + ("LESAVKA_UVC_FPS", Some("30")), + ("LESAVKA_UPLINK_CAMERA_CODEC", Some("hevc")), + ("LESAVKA_UPLINK_AUDIO_CODEC", Some("opus")), + ( + "LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US", + Some("1280x720@30=24000"), + ), + ], + || { + let runtime = UpstreamMediaRuntime::new(); + assert_eq!(runtime.playout_offsets(), (110_000, 24_000)); + }, + ); + }); +} + #[test] /// Keeps `runtime_records_client_and_sink_timing_for_upstream_snapshots` explicit because the blind client-to-RCT probe depends on this telemetry to explain freshness losses. /// Inputs are paired camera/microphone timing samples plus sink handoff marks; output is a live snapshot with skew, queue, late, and freeze fields populated. diff --git a/server/src/video_sinks/hevc_mjpeg_guard.rs b/server/src/video_sinks/hevc_mjpeg_guard.rs index 315fb3c..4680637 100644 --- a/server/src/video_sinks/hevc_mjpeg_guard.rs +++ b/server/src/video_sinks/hevc_mjpeg_guard.rs @@ -3,6 +3,8 @@ use crate::video_support::env_u32; const DEFAULT_HEVC_JPEG_QUALITY: u32 = 72; const DEFAULT_HEVC_SIZE_DROP_PCT: u32 = 45; const DEFAULT_HEVC_MIN_REFERENCE_BYTES: u32 = 64 * 1024; +const DEFAULT_HEVC_MIN_PAYLOAD_DISTINCT_BYTES: u32 = 12; +const DEFAULT_HEVC_DOMINANT_BYTE_PCT: u32 = 92; /// Resolve the JPEG quality used after HEVC decode. /// @@ -55,6 +57,80 @@ pub(super) fn min_reference_bytes() -> u32 { .max(1) } +/// Resolve the minimum compressed-payload byte variety for decoded MJPEG. +/// +/// Inputs: optional `LESAVKA_UVC_HEVC_MIN_PAYLOAD_DISTINCT_BYTES`. Output: +/// distinct byte count threshold. Why: grey/black smear failures can arrive as +/// complete JPEG buffers, so the guard needs a conservative flat-payload check +/// in addition to simple size-collapse detection. +pub(super) fn min_payload_distinct_bytes() -> u32 { + env_u32( + "LESAVKA_UVC_HEVC_MIN_PAYLOAD_DISTINCT_BYTES", + DEFAULT_HEVC_MIN_PAYLOAD_DISTINCT_BYTES, + ) + .clamp(1, 64) +} + +/// Resolve the dominant-byte percentage that marks a payload as too flat. +/// +/// Inputs: optional `LESAVKA_UVC_HEVC_DOMINANT_BYTE_PCT`, clamped to 50..=99. +/// Output: percentage threshold. Why: a single repeated byte occupying almost +/// all entropy-coded JPEG payload is more likely a damaged decode/transfer +/// artifact than useful webcam video, and freezing is the safer conference UX. +pub(super) fn dominant_byte_pct() -> u32 { + env_u32( + "LESAVKA_UVC_HEVC_DOMINANT_BYTE_PCT", + DEFAULT_HEVC_DOMINANT_BYTE_PCT, + ) + .clamp(50, 99) +} + +/// Return whether a decoded buffer looks like one complete JPEG image. +/// +/// Inputs: decoded MJPEG bytes. Output: true when SOI, SOS, and EOI markers +/// are present. Why: incomplete JPEGs can still be non-empty buffers; freezing +/// them is safer than letting the UVC helper expose a partially decoded frame. +fn looks_like_complete_jpeg(bytes: &[u8]) -> bool { + bytes.len() >= 4 + && bytes.starts_with(&[0xff, 0xd8]) + && bytes.ends_with(&[0xff, 0xd9]) + && bytes.windows(2).any(|pair| pair == [0xff, 0xda]) +} + +/// Return whether one complete JPEG has an implausibly flat payload. +/// +/// Inputs: decoded MJPEG bytes. Output: true for dominant-byte or very low +/// variety payloads. Why: the visible failure mode is often a grey/black slab +/// that is syntactically present but not meaningful webcam video. +fn suspiciously_flat_payload(bytes: &[u8]) -> bool { + if bytes.len() < min_reference_bytes() as usize / 4 { + return false; + } + + let start = bytes + .windows(2) + .position(|pair| pair == [0xff, 0xda]) + .map(|idx| (idx + 2).min(bytes.len())) + .unwrap_or_else(|| bytes.len().min(256)); + let end = bytes.len().saturating_sub(2); + if end <= start || end - start < 512 { + return false; + } + + let payload = &bytes[start..end]; + let mut counts = [0u32; 256]; + for byte in payload { + counts[*byte as usize] += 1; + } + + let distinct = counts.iter().filter(|count| **count > 0).count() as u32; + let dominant = counts.iter().copied().max().unwrap_or(0) as u64; + let total = payload.len() as u64; + + distinct < min_payload_distinct_bytes() + || dominant.saturating_mul(100) >= total.saturating_mul(u64::from(dominant_byte_pct())) +} + /// Decide whether a decoded HEVC-to-MJPEG frame should be frozen out. /// /// Inputs: byte length of the last successfully spooled decoded MJPEG and the @@ -71,6 +147,22 @@ pub(super) fn should_freeze_decoded_mjpeg(previous_bytes: u64, next_bytes: usize next_bytes < threshold_bytes } +/// Decide whether a decoded MJPEG payload should be frozen before UVC spool. +/// +/// Inputs: byte length of the last successfully spooled decoded MJPEG and the +/// decoded MJPEG bytes. Output: true when the payload is incomplete, collapsed, +/// or suspiciously flat. Why: users prefer a short freeze over grey slabs, +/// mostly black frames, or torn images that conferencing apps may otherwise +/// display as if they were valid webcam frames. +pub(super) fn should_freeze_decoded_mjpeg_frame(previous_bytes: u64, decoded_mjpeg: &[u8]) -> bool { + if !freeze_on_size_drop_enabled() || previous_bytes < u64::from(min_reference_bytes()) { + return false; + } + !looks_like_complete_jpeg(decoded_mjpeg) + || should_freeze_decoded_mjpeg(previous_bytes, decoded_mjpeg.len()) + || suspiciously_flat_payload(decoded_mjpeg) +} + #[cfg(test)] mod tests { #[test] @@ -117,4 +209,56 @@ mod tests { }, ); } + + #[test] + /// Verifies flat or incomplete HEVC-decoded MJPEG frames freeze out. + /// + /// Inputs: synthetic JPEG-like payloads. Output: assertions only. Why: + /// the source-level guard should keep the same behavior as the root + /// taxonomy tests even when this helper is compiled inside the server crate. + fn freeze_guard_rejects_incomplete_or_flat_decoded_payloads() { + /// Build a minimal JPEG-like buffer for guard heuristic tests. + /// + /// Inputs: entropy-coded payload bytes. Output: synthetic SOI/SOS/EOI + /// wrapper. Why: the guard tests need deterministic payload shape + /// without depending on slow image encoding. + fn jpeg_with_payload(payload: &[u8]) -> Vec { + let mut bytes = vec![0xff, 0xd8, 0xff, 0xda]; + bytes.extend_from_slice(payload); + bytes.extend_from_slice(&[0xff, 0xd9]); + bytes + } + + temp_env::with_vars( + [ + ("LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP", Some("1")), + ("LESAVKA_UVC_HEVC_SIZE_DROP_PCT", Some("45")), + ("LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES", Some("65536")), + ( + "LESAVKA_UVC_HEVC_MIN_PAYLOAD_DISTINCT_BYTES", + Some("12"), + ), + ("LESAVKA_UVC_HEVC_DOMINANT_BYTE_PCT", Some("92")), + ], + || { + let healthy_payload: Vec = + (0..120_000).map(|idx| (idx % 251) as u8).collect(); + let flat_payload = vec![0x80; 120_000]; + let mut truncated = jpeg_with_payload(&healthy_payload); + truncated.pop(); + + assert!(!super::should_freeze_decoded_mjpeg_frame( + 200_000, + &jpeg_with_payload(&healthy_payload), + )); + assert!(super::should_freeze_decoded_mjpeg_frame( + 200_000, + &jpeg_with_payload(&flat_payload), + )); + assert!(super::should_freeze_decoded_mjpeg_frame( + 200_000, &truncated, + )); + }, + ); + } } diff --git a/server/src/video_sinks/webcam_sink.rs b/server/src/video_sinks/webcam_sink.rs index afebac1..a7e8a7a 100644 --- a/server/src/video_sinks/webcam_sink.rs +++ b/server/src/video_sinks/webcam_sink.rs @@ -410,7 +410,8 @@ impl WebcamSink { .last_decoded_mjpeg_bytes .load(std::sync::atomic::Ordering::Relaxed); let decoded_bytes = map.as_slice().len(); - if hevc_mjpeg_guard::should_freeze_decoded_mjpeg(previous_bytes, decoded_bytes) { + if hevc_mjpeg_guard::should_freeze_decoded_mjpeg_frame(previous_bytes, map.as_slice()) + { warn!( target:"lesavka_server::video", previous_bytes, diff --git a/testing/Cargo.toml b/testing/Cargo.toml deleted file mode 100644 index 57fdaa3..0000000 --- a/testing/Cargo.toml +++ /dev/null @@ -1,43 +0,0 @@ -[package] -name = "lesavka_testing" -version = "0.1.0" -edition = "2024" -publish = false -build = "build.rs" - -[lib] -name = "lesavka_testing" -path = "src/lib.rs" - -[dev-dependencies] -anyhow = "1.0" -async-stream = "0.3" -base64 = "0.22" -chrono = "0.4" -evdev = "0.13" -futures-util = "0.3" -libc = "0.2" -lesavka_client = { path = "../client" } -lesavka_common = { path = "../common" } -lesavka_server = { path = "../server" } -chacha20poly1305 = "0.10" -gstreamer = { version = "0.23", features = ["v1_22"] } -gstreamer-app = { version = "0.23", features = ["v1_22"] } -gstreamer-video = { version = "0.23", features = ["v1_22"] } -gtk = { version = "0.8", package = "gtk4", features = ["v4_6"] } -winit = "0.30" -serial_test = { workspace = true } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -shell-escape = "0.1" -temp-env = { workspace = true } -tempfile = { workspace = true } -tokio = { version = "1.45", features = ["full", "macros", "rt-multi-thread", "sync", "time"] } -tokio-stream = "0.1" -tonic = { version = "0.13", features = ["transport"] } -tonic-reflection = "0.13" -tracing = "0.1" -tracing-appender = "0.2" -tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "ansi"] } -udev = "0.8" -v4l = "0.14" diff --git a/testing/src/lib.rs b/testing/src/lib.rs deleted file mode 100644 index 5a8a51d..0000000 --- a/testing/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Top-level integration testing crate for the lesavka workspace. -//! -//! Scope: keep cross-crate and contract-style tests out of package-local -//! `src/tests` and `tests` folders so CI has one integration test entrypoint. -//! Targets: the `testing/tests` suite plus coverage contracts consumed by the -//! Jenkins quality gate. -//! Why: a single top-level testing module is easier to scale, review, and -//! ratchet than ad-hoc integration tests spread across workspace members. - -#![forbid(unsafe_code)] diff --git a/testing/tests/client_install_script_contract.rs b/testing/tests/client_install_script_contract.rs deleted file mode 100644 index 572e848..0000000 --- a/testing/tests/client_install_script_contract.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! Contract tests for client install-time security defaults. -//! -//! Scope: inspect the client installer shell contract without running it. -//! Targets: `scripts/install/client.sh`. -//! Why: secure-by-default relay transport depends on installing the server-issued -//! client cert bundle exactly where the desktop app auto-discovers it. - -const CLIENT_INSTALL: &str = include_str!("../../scripts/install/client.sh"); - -#[test] -fn client_install_accepts_server_generated_tls_bundle() { - for expected in [ - "LESAVKA_CLIENT_PKI_BUNDLE", - "CLIENT_PKI_DIR", - "ca.crt", - "client.crt", - "client.key", - "install_client_pki_bundle", - "fetch_client_pki_bundle", - "LESAVKA_CLIENT_PKI_SSH_SOURCE", - "LESAVKA_CLIENT_CAPTURE_DIR", - "theia:/etc/lesavka/lesavka-client-pki.tar.gz", - "Pictures/lesavka", - "HTTPS/mTLS relay connections will not work until this bundle is installed", - "TLS identity:", - "Captures:", - "$USER_HOME/.local/bin/lesavka-client", - "User PATH alias:", - ] { - assert!( - CLIENT_INSTALL.contains(expected), - "client installer should include TLS bundle contract fragment {expected}" - ); - } - assert!( - CLIENT_INSTALL.contains(".config/lesavka/pki"), - "client cert bundle should land in the same path the desktop app auto-loads" - ); - assert!( - CLIENT_INSTALL.contains("0600"), - "client private key should be installed with private permissions" - ); - assert!( - CLIENT_INSTALL.contains("scp -q -o BatchMode=yes"), - "client installer should auto-fetch the server enrollment bundle without hanging" - ); - assert!( - CLIENT_INSTALL.contains("run_as_user mktemp"), - "auto-fetch destination should be owned by the user who runs scp" - ); -} - -#[test] -fn client_install_prefers_invoked_checkout_for_development_installs() { - for expected in [ - "INSTALL_SOURCE=${LESAVKA_INSTALL_SOURCE:-auto}", - "resolve_source_checkout", - "Using local source checkout", - "set LESAVKA_INSTALL_SOURCE=ref", - "LESAVKA_INSTALL_SOURCE=local requested", - "unsupported LESAVKA_INSTALL_SOURCE", - "source_revision", - "+dirty", - ] { - assert!( - CLIENT_INSTALL.contains(expected), - "client installer should preserve local-source install marker {expected}" - ); - } - assert!( - CLIENT_INSTALL.find("resolve_source_checkout").unwrap() - < CLIENT_INSTALL - .find("cargo clean && cargo build --release") - .unwrap(), - "client installer should resolve source before building" - ); -} diff --git a/testing/tests/client_main_process_contract.rs b/testing/tests/client_main_process_contract.rs deleted file mode 100644 index 371f65e..0000000 --- a/testing/tests/client_main_process_contract.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Integration coverage for client process startup behavior. -//! -//! Scope: launch the real `lesavka-client` binary and validate guarded startup -//! behavior when desktop runtime prerequisites are missing. -//! Targets: `client/src/main.rs`. -//! Why: process-level startup failures should stay deterministic and -//! user-readable. - -use serial_test::serial; -use std::path::{Path, PathBuf}; -use std::process::Command; - -fn candidate_dirs() -> Vec { - let exe = std::env::current_exe().expect("current exe path"); - let mut dirs = Vec::new(); - if let Some(parent) = exe.parent() { - dirs.push(parent.to_path_buf()); - if let Some(grand) = parent.parent() { - dirs.push(grand.to_path_buf()); - } - } - dirs.push(PathBuf::from("target/debug")); - dirs.push(PathBuf::from("target/llvm-cov-target/debug")); - dirs -} - -fn find_binary(name: &str) -> Option { - candidate_dirs() - .into_iter() - .map(|dir| dir.join(name)) - .find(|path| path.exists() && path.is_file()) -} - -#[test] -#[serial] -fn client_binary_fails_fast_without_runtime_dir() { - let Some(bin) = find_binary("lesavka-client") else { - return; - }; - - let status = Command::new(Path::new(&bin)) - .env_remove("XDG_RUNTIME_DIR") - .env_remove("LESAVKA_HEADLESS") - .status() - .expect("spawn lesavka-client"); - - assert!(!status.success(), "startup should fail without runtime dir"); -} diff --git a/testing/tests/client_video_support_include_contract.rs b/testing/tests/client_video_support_include_contract.rs deleted file mode 100644 index ca64bf9..0000000 --- a/testing/tests/client_video_support_include_contract.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! Module-path coverage for client-side H.264 decoder selection. -//! -//! Scope: include the client decoder selection helper directly. -//! Targets: `client/src/video_support.rs`. -//! Why: operator decoder overrides should fall back cleanly on machines with -//! different GStreamer plugin sets. - -#[path = "../../client/src/video_support.rs"] -mod video_support; - -use serial_test::serial; -use temp_env::with_var; - -#[test] -#[serial] -fn decoder_override_accepts_decodebin_without_factory_lookup() { - with_var("LESAVKA_H264_DECODER", Some("decodebin"), || { - assert_eq!(video_support::pick_h264_decoder(), "decodebin"); - }); -} - -#[test] -#[serial] -fn decoder_override_accepts_buildable_element() { - with_var("LESAVKA_H264_DECODER", Some("fakesink"), || { - assert_eq!(video_support::pick_h264_decoder(), "fakesink"); - }); -} - -#[test] -#[serial] -fn decoder_override_ignores_blank_or_unknown_values() { - with_var("LESAVKA_H264_DECODER", Some(" "), || { - assert!(!video_support::pick_h264_decoder().trim().is_empty()); - }); - with_var("LESAVKA_H264_DECODER", Some("not-a-real-decoder"), || { - assert!(!video_support::pick_h264_decoder().trim().is_empty()); - }); -} - -#[test] -#[serial] -#[cfg(coverage)] -fn decoder_selection_falls_back_when_no_factory_can_build() { - with_var("TEST_DISABLE_H264_DECODER_FACTORY", Some("1"), || { - with_var("LESAVKA_H264_DECODER", Some("fakesink"), || { - assert_eq!(video_support::pick_h264_decoder(), "decodebin"); - }); - }); - with_var("TEST_FAIL_GST_INIT", Some("1"), || { - with_var("LESAVKA_H264_DECODER", None::<&str>, || { - assert_eq!(video_support::pick_h264_decoder(), "decodebin"); - }); - }); -} diff --git a/testing/tests/common_cli_binary_contract.rs b/testing/tests/common_cli_binary_contract.rs deleted file mode 100644 index d051100..0000000 --- a/testing/tests/common_cli_binary_contract.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! Integration coverage for the common CLI binary entrypoint. -//! -//! Scope: include the common CLI bin source and execute `main` directly. -//! Targets: `common/src/bin/cli.rs`. -//! Why: keep binary entrypoint coverage in the centralized testing crate. - -mod common_cli_binary { - include!(env!("LESAVKA_COMMON_CLI_BIN_SRC")); - - #[test] - fn cli_main_executes_without_panicking() { - main(); - } -} diff --git a/testing/tests/common_cli_contract.rs b/testing/tests/common_cli_contract.rs deleted file mode 100644 index 34d6784..0000000 --- a/testing/tests/common_cli_contract.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Integration coverage for the common CLI entrypoint contract. -//! -//! Scope: execute the public common CLI helper from the centralized testing -//! crate. -//! Targets: `common/src/lib.rs`. -//! Why: this keeps even tiny user-facing helpers represented in cross-crate -//! contract coverage without package-local integration tests. - -#[test] -fn run_cli_executes_without_panicking() { - lesavka_common::run_cli(); -} diff --git a/testing/tests/client_relayctl_binary_contract.rs b/tests/api/client/bin/lesavka_relayctl/client_relayctl_binary_contract.rs similarity index 90% rename from testing/tests/client_relayctl_binary_contract.rs rename to tests/api/client/bin/lesavka_relayctl/client_relayctl_binary_contract.rs index 8dbbbf3..fda2b80 100644 --- a/testing/tests/client_relayctl_binary_contract.rs +++ b/tests/api/client/bin/lesavka_relayctl/client_relayctl_binary_contract.rs @@ -1,10 +1,10 @@ -//! Include-based coverage for relay control CLI parsing and helpers. -//! -//! Scope: include `client/src/bin/lesavka-relayctl.rs` and exercise helper -//! branches that do not require a live relay server. -//! Targets: `client/src/bin/lesavka-relayctl.rs`. -//! Why: relay power recovery controls need parser coverage without depending on -//! a live relay endpoint. +// Include-based coverage for relay control CLI parsing and helpers. +// +// Scope: include `client/src/bin/lesavka-relayctl.rs` and exercise helper +// branches that do not require a live relay server. +// Targets: `client/src/bin/lesavka-relayctl.rs`. +// Why: relay power recovery controls need parser coverage without depending on +// a live relay endpoint. #[allow(warnings)] mod relayctl_binary { diff --git a/testing/tests/client_relayctl_process_contract.rs b/tests/api/client/bin/lesavka_relayctl/client_relayctl_process_contract.rs similarity index 84% rename from testing/tests/client_relayctl_process_contract.rs rename to tests/api/client/bin/lesavka_relayctl/client_relayctl_process_contract.rs index 9eb2d87..ad23595 100644 --- a/testing/tests/client_relayctl_process_contract.rs +++ b/tests/api/client/bin/lesavka_relayctl/client_relayctl_process_contract.rs @@ -1,9 +1,9 @@ -//! Process-level coverage for relay control CLI argument handling. -//! -//! Scope: launch the real `lesavka-relayctl` binary for argument-only paths. -//! Targets: `client/src/bin/lesavka-relayctl.rs`. -//! Why: CLI-only failures should stay fast and local instead of retrying a bad -//! network endpoint. +// Process-level coverage for relay control CLI argument handling. +// +// Scope: launch the real `lesavka-relayctl` binary for argument-only paths. +// Targets: `client/src/bin/lesavka-relayctl.rs`. +// Why: CLI-only failures should stay fast and local instead of retrying a bad +// network endpoint. use serial_test::serial; use std::path::{Path, PathBuf}; diff --git a/testing/tests/client_keyboard_paste_rpc_contract.rs b/tests/api/client/input/keyboard/client_keyboard_paste_rpc_contract.rs similarity index 100% rename from testing/tests/client_keyboard_paste_rpc_contract.rs rename to tests/api/client/input/keyboard/client_keyboard_paste_rpc_contract.rs diff --git a/tests/api/common/cli/common_cli_binary_contract.rs b/tests/api/common/cli/common_cli_binary_contract.rs new file mode 100644 index 0000000..1cdf93d --- /dev/null +++ b/tests/api/common/cli/common_cli_binary_contract.rs @@ -0,0 +1,14 @@ +// Integration coverage for the common CLI binary entrypoint. +// +// Scope: include the common CLI bin source and execute `main` directly. +// Targets: `common/src/bin/cli.rs`. +// Why: keep binary entrypoint coverage in the root test harness crate. + +mod common_cli_binary { + include!(env!("LESAVKA_COMMON_CLI_BIN_SRC")); + + #[test] + fn cli_main_executes_without_panicking() { + main(); + } +} diff --git a/tests/api/common/cli/common_cli_contract.rs b/tests/api/common/cli/common_cli_contract.rs new file mode 100644 index 0000000..b7773a4 --- /dev/null +++ b/tests/api/common/cli/common_cli_contract.rs @@ -0,0 +1,12 @@ +// Integration coverage for the common CLI entrypoint contract. +// +// Scope: execute the public common CLI helper from the centralized test harness +// crate. +// Targets: `common/src/lib.rs`. +// Why: this keeps even tiny user-facing helpers represented in cross-crate +// contract coverage without package-local integration tests. + +#[test] +fn run_cli_executes_without_panicking() { + lesavka_common::run_cli(); +} diff --git a/testing/tests/handshake_camera_contract.rs b/tests/api/common/handshake/handshake_camera_contract.rs similarity index 96% rename from testing/tests/handshake_camera_contract.rs rename to tests/api/common/handshake/handshake_camera_contract.rs index 5b899e0..fe065e2 100644 --- a/testing/tests/handshake_camera_contract.rs +++ b/tests/api/common/handshake/handshake_camera_contract.rs @@ -1,12 +1,12 @@ -//! Integration coverage for the handshake and camera-selection contract. -//! -//! Scope: exercise the real client handshake against a local server and the -//! server camera selection logic that feeds the handshake response. -//! Targets: `client/src/handshake.rs`, `server/src/handshake.rs`, and -//! `server/src/camera.rs`. -//! Why: the handshake path is the narrow entrypoint that decides whether the -//! client and server agree on output mode, so it deserves a centralized -//! contract test. +// Integration coverage for the handshake and camera-selection contract. +// +// Scope: exercise the real client handshake against a local server and the +// server camera selection logic that feeds the handshake response. +// Targets: `client/src/handshake.rs`, `server/src/handshake.rs`, and +// `server/src/camera.rs`. +// Why: the handshake path is the narrow entrypoint that decides whether the +// client and server agree on output mode, so it deserves a centralized +// contract test. use lesavka_client::handshake::{HandshakeProbe, PeerCaps, negotiate, probe}; use lesavka_common::lesavka::{ diff --git a/tests/api/server/auth/server_auth_rpc_contract.rs b/tests/api/server/auth/server_auth_rpc_contract.rs new file mode 100644 index 0000000..57f9c69 --- /dev/null +++ b/tests/api/server/auth/server_auth_rpc_contract.rs @@ -0,0 +1,91 @@ +// API/security contract for unauthenticated relay RPC rejection. +// +// Scope: preserve the security boundary between transport authentication and +// handler execution for media, HID, and paste RPCs. +// Targets: server entrypoint and relay helper modules. +// Why: without TLS/mTLS on the server builder, any client could stream media or +// HID reports into the remote controlled target. + +const ENTRYPOINT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/entrypoint.rs" +)); +const SERVER_SECURITY: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/security.rs" +)); +const INPUT_RPC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_service/input_stream_rpc.rs" +)); +const CAMERA_RPC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_service/camera_stream_rpc.rs" +)); +const MICROPHONE_RPC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_service/microphone_stream_rpc.rs" +)); +const RPC_HELPERS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/rpc_helpers.rs" +)); + +#[test] +fn server_installs_tls_before_exposing_relay_services() { + let tls_idx = ENTRYPOINT + .find("security::server_tls_config()?") + .expect("entrypoint should request TLS config"); + let relay_idx = ENTRYPOINT + .find(".add_service(RelayServer::new(handler))") + .expect("entrypoint should expose relay service"); + assert!( + tls_idx < relay_idx, + "TLS must be configured before media/HID RPC services are exposed" + ); + assert!(ENTRYPOINT.contains("server = server.tls_config(tls)?")); +} + +#[test] +fn production_mtls_requires_a_client_ca_unless_operator_explicitly_opts_out() { + assert!(SERVER_SECURITY.contains("client_ca_root")); + assert!(SERVER_SECURITY.contains("LESAVKA_TLS_CLIENT_AUTH_OPTIONAL")); + assert!( + SERVER_SECURITY.contains("TLS enabled with required client certificate authentication"), + "required client certificate auth should be the normal CA-backed path" + ); +} + +#[test] +fn powerful_rpc_handlers_depend_on_transport_auth_boundary() { + for (name, source, lease_marker) in [ + ("keyboard", INPUT_RPC, "capture_power.acquire_session()"), + ("mouse", INPUT_RPC, "capture_power.acquire_session()"), + ("camera", CAMERA_RPC, "activate_camera()"), + ("microphone", MICROPHONE_RPC, "reserve_microphone_sink"), + ] { + assert!( + source.contains(lease_marker), + "{name} RPC should continue using runtime leases after transport auth" + ); + assert!( + !source.contains("LESAVKA_ALLOW_INSECURE"), + "{name} RPC should not override transport security inside handler code" + ); + } +} + +#[test] +fn unauthenticated_paste_payloads_map_to_unauthenticated_status() { + assert!(RPC_HELPERS.contains("paste::decrypt(&req)")); + assert!(RPC_HELPERS.contains("Status::unauthenticated")); + assert!( + RPC_HELPERS + .find("paste::decrypt(&req)") + .expect("decrypt marker") + < RPC_HELPERS + .find("paste::type_text") + .expect("type text marker"), + "paste payloads must authenticate/decrypt before HID typing begins" + ); +} diff --git a/testing/tests/server_main_rpc_contract.rs b/tests/api/server/main/server_main_rpc_contract.rs similarity index 97% rename from testing/tests/server_main_rpc_contract.rs rename to tests/api/server/main/server_main_rpc_contract.rs index 4796c95..8fb0f64 100644 --- a/testing/tests/server_main_rpc_contract.rs +++ b/tests/api/server/main/server_main_rpc_contract.rs @@ -1,9 +1,9 @@ -//! Integration coverage for server main RPC handler branches. -//! -//! Scope: include `server/src/main.rs` and exercise additional RPC paths that -//! are awkward to hit from process-level tests. -//! Targets: `server/src/main.rs`. -//! Why: keep handler-side error/reply behavior stable without HID hardware. +// Integration coverage for server main RPC handler branches. +// +// Scope: include `server/src/main.rs` and exercise additional RPC paths that +// are awkward to hit from process-level tests. +// Targets: `server/src/main.rs`. +// Why: keep handler-side error/reply behavior stable without HID hardware. #[allow(warnings)] mod server_main_rpc { diff --git a/testing/tests/server_main_rpc_reset_contract.rs b/tests/api/server/main/server_main_rpc_reset_contract.rs similarity index 96% rename from testing/tests/server_main_rpc_reset_contract.rs rename to tests/api/server/main/server_main_rpc_reset_contract.rs index 7cdd3f8..6ac7dbb 100644 --- a/testing/tests/server_main_rpc_reset_contract.rs +++ b/tests/api/server/main/server_main_rpc_reset_contract.rs @@ -1,9 +1,9 @@ -//! RPC reset coverage for server main USB recovery replies. -//! -//! Scope: include `server/src/main.rs` and exercise reset RPC edge replies. -//! Targets: `server/src/main.rs`. -//! Why: USB reset is an operator recovery path, so failed HID reopen behavior -//! needs deterministic coverage without requiring real gadget hardware. +// RPC reset coverage for server main USB recovery replies. +// +// Scope: include `server/src/main.rs` and exercise reset RPC edge replies. +// Targets: `server/src/main.rs`. +// Why: USB reset is an operator recovery path, so failed HID reopen behavior +// needs deterministic coverage without requiring real gadget hardware. #[allow(warnings)] mod server_main_rpc_reset { diff --git a/testing/tests/server_main_state_rpc_contract.rs b/tests/api/server/main/server_main_state_rpc_contract.rs similarity index 97% rename from testing/tests/server_main_state_rpc_contract.rs rename to tests/api/server/main/server_main_state_rpc_contract.rs index 7f62bd7..2a8e7e1 100644 --- a/testing/tests/server_main_state_rpc_contract.rs +++ b/tests/api/server/main/server_main_state_rpc_contract.rs @@ -1,10 +1,10 @@ -//! Integration coverage for server state-oriented RPC handler branches. -//! -//! Scope: include `server/src/main.rs` and exercise calibration, capture-power, -//! and upstream-sync RPC surfaces. -//! Targets: `server/src/main.rs`. -//! Why: these RPCs expose live operational state, so tests should guard reply -//! shapes without requiring gadget, HID, or capture hardware. +// Integration coverage for server state-oriented RPC handler branches. +// +// Scope: include `server/src/main.rs` and exercise calibration, capture-power, +// and upstream-sync RPC surfaces. +// Targets: `server/src/main.rs`. +// Why: these RPCs expose live operational state, so tests should guard reply +// shapes without requiring gadget, HID, or capture hardware. #[allow(warnings)] mod server_main_state_rpc { @@ -174,7 +174,7 @@ mod server_main_state_rpc { }) .expect("initial calibration") .into_inner(); - assert_eq!(initial.profile, "mjpeg"); + assert_eq!(initial.profile, "mjpeg+pcm"); assert_eq!(initial.active_audio_offset_us, 0); let initial_video_offset_us = initial.active_video_offset_us; diff --git a/testing/tests/server_upstream_media_audio_contract.rs b/tests/api/server/upstream_media_runtime/server_upstream_media_audio_contract.rs similarity index 76% rename from testing/tests/server_upstream_media_audio_contract.rs rename to tests/api/server/upstream_media_runtime/server_upstream_media_audio_contract.rs index 1033fb4..5291aee 100644 --- a/testing/tests/server_upstream_media_audio_contract.rs +++ b/tests/api/server/upstream_media_runtime/server_upstream_media_audio_contract.rs @@ -1,16 +1,19 @@ -//! End-to-end server coverage for upstream microphone media streams. -//! -//! Scope: run a local gRPC server and push synthetic client microphone packets -//! through the public `StreamMicrophone` RPC. -//! Targets: `server/src/main.rs`, `server/src/audio.rs`. -//! Why: upstream audio should surface sink failures, supersession, and normal -//! packet delivery without requiring ALSA hardware in CI. +// End-to-end server coverage for upstream microphone media streams. +// +// Scope: run a local gRPC server and push synthetic client microphone packets +// through the public `StreamMicrophone` RPC. +// Targets: `server/src/main.rs`, `server/src/audio.rs`. +// Why: upstream audio should surface sink failures, supersession, and normal +// packet delivery without requiring ALSA hardware in CI. #[cfg(coverage)] #[allow(warnings)] mod server_upstream_media_audio { include!(env!("LESAVKA_SERVER_MAIN_SRC")); - include!("../support/server_upstream_media_harness.rs"); + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/helpers/support/server_upstream_media_harness.rs" + )); use serial_test::serial; use temp_env::with_var; @@ -126,3 +129,31 @@ mod server_upstream_media_audio { }); } } + +#[cfg(not(coverage))] +mod server_upstream_media_audio_normal_mode { + const MICROPHONE_RPC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_service/microphone_stream_rpc.rs" + )); + const RELAY_TRAIT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_service.rs" + )); + const AUDIO_SINK: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/audio/voice_input.rs" + )); + const RUNTIME: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/upstream_media_runtime.rs" + )); + + #[test] + fn microphone_rpc_stays_wired_to_voice_sink_runtime() { + assert!(RELAY_TRAIT.contains("stream_microphone")); + assert!(MICROPHONE_RPC.contains("open_voice_with_retry")); + assert!(AUDIO_SINK.contains("LESAVKA_UAC_BUFFER_TIME_US")); + assert!(RUNTIME.contains("LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US")); + } +} diff --git a/testing/tests/server_upstream_media_bundle_contract.rs b/tests/api/server/upstream_media_runtime/server_upstream_media_bundle_contract.rs similarity index 84% rename from testing/tests/server_upstream_media_bundle_contract.rs rename to tests/api/server/upstream_media_runtime/server_upstream_media_bundle_contract.rs index 867b021..a3972ef 100644 --- a/testing/tests/server_upstream_media_bundle_contract.rs +++ b/tests/api/server/upstream_media_runtime/server_upstream_media_bundle_contract.rs @@ -1,16 +1,19 @@ -//! End-to-end server coverage for bundled upstream webcam/microphone media. -//! -//! Scope: run a local gRPC server and send a synthetic HEVC video packet with -//! the audio that should play beside it through `StreamWebcamMedia`. -//! Targets: `server/src/main.rs`, `server/src/upstream_media_runtime.rs`. -//! Why: client transport should reach the server as one timestamped media unit -//! so the relay can preserve sync before final RCT playout. +// End-to-end server coverage for bundled upstream webcam/microphone media. +// +// Scope: run a local gRPC server and send a synthetic HEVC video packet with +// the audio that should play beside it through `StreamWebcamMedia`. +// Targets: `server/src/main.rs`, `server/src/upstream_media_runtime.rs`. +// Why: client transport should reach the server as one timestamped media unit +// so the relay can preserve sync before final RCT playout. #[cfg(coverage)] #[allow(warnings)] mod server_upstream_media_bundle { include!(env!("LESAVKA_SERVER_MAIN_SRC")); - include!("../support/server_upstream_media_harness.rs"); + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/helpers/support/server_upstream_media_harness.rs" + )); use serial_test::serial; use temp_env::with_var; @@ -59,6 +62,7 @@ mod server_upstream_media_bundle { }], audio_sample_rate: 48_000, audio_channels: 2, + audio_encoding: lesavka_common::lesavka::AudioEncoding::PcmS16le as i32, video_width: 1920, video_height: 1080, video_fps: 30, @@ -155,6 +159,7 @@ mod server_upstream_media_bundle { ], audio_sample_rate: 48_000, audio_channels: 2, + audio_encoding: lesavka_common::lesavka::AudioEncoding::PcmS16le as i32, video_width: 1920, video_height: 1080, video_fps: 30, @@ -206,3 +211,27 @@ mod server_upstream_media_bundle { }); } } + +#[cfg(not(coverage))] +mod server_upstream_media_bundle_normal_mode { + const RELAY_TRAIT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_service.rs" + )); + const RELAY_RPC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_service/upstream_media_rpc.rs" + )); + const RELAY_LIFECYCLE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_stream_lifecycle.rs" + )); + + #[test] + fn bundled_rpc_preserves_one_media_unit_at_server_ingress() { + assert!(RELAY_TRAIT.contains("stream_webcam_media")); + assert!(RELAY_RPC.contains("UpstreamMediaBundle")); + assert!(RELAY_LIFECYCLE.contains("client_capture_pts_us")); + assert!(RELAY_RPC.contains("record_client_timing")); + } +} diff --git a/testing/tests/server_upstream_media_contract.rs b/tests/api/server/upstream_media_runtime/server_upstream_media_contract.rs similarity index 86% rename from testing/tests/server_upstream_media_contract.rs rename to tests/api/server/upstream_media_runtime/server_upstream_media_contract.rs index 29d63e3..f5717c4 100644 --- a/testing/tests/server_upstream_media_contract.rs +++ b/tests/api/server/upstream_media_runtime/server_upstream_media_contract.rs @@ -1,16 +1,19 @@ -//! End-to-end server coverage for shared upstream media stream helpers. -//! -//! Scope: run local helper-level and output-delay probe checks around the public -//! upstream media RPC implementation. -//! Targets: `server/src/main.rs`, `server/src/output_delay_probe.rs`. -//! Why: the coverage harness should keep freshness decisions and probe plumbing -//! stable without physical UVC, HDMI, or ALSA hardware in CI. +// End-to-end server coverage for shared upstream media stream helpers. +// +// Scope: run local helper-level and output-delay probe checks around the public +// upstream media RPC implementation. +// Targets: `server/src/main.rs`, `server/src/output_delay_probe.rs`. +// Why: the coverage harness should keep freshness decisions and probe plumbing +// stable without physical UVC, HDMI, or ALSA hardware in CI. #[cfg(coverage)] #[allow(warnings)] mod server_upstream_media { include!(env!("LESAVKA_SERVER_MAIN_SRC")); - include!("../support/server_upstream_media_harness.rs"); + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/helpers/support/server_upstream_media_harness.rs" + )); use serial_test::serial; use temp_env::with_var; @@ -286,3 +289,34 @@ mod server_upstream_media { }); } } + +#[cfg(not(coverage))] +mod server_upstream_media_normal_mode { + const RELAY_COVERAGE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_service_coverage.rs" + )); + const FRESHNESS_HELPERS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_service_coverage/freshness_helpers.rs" + )); + + #[test] + fn upstream_media_coverage_helpers_stay_isolated_but_discoverable() { + assert!( + RELAY_COVERAGE.contains("freshness_helpers"), + "coverage module should expose freshness helper coverage" + ); + for helper in [ + "coverage_playable_plan", + "coverage_audio_plan_from_decision", + "coverage_video_plan_from_decision", + "coverage_audio_master_ready", + ] { + assert!( + FRESHNESS_HELPERS.contains(helper), + "missing upstream coverage helper {helper}" + ); + } + } +} diff --git a/testing/tests/server_upstream_media_pairing_contract.rs b/tests/api/server/upstream_media_runtime/server_upstream_media_pairing_contract.rs similarity index 96% rename from testing/tests/server_upstream_media_pairing_contract.rs rename to tests/api/server/upstream_media_runtime/server_upstream_media_pairing_contract.rs index 0800e0b..8c469b3 100644 --- a/testing/tests/server_upstream_media_pairing_contract.rs +++ b/tests/api/server/upstream_media_runtime/server_upstream_media_pairing_contract.rs @@ -1,9 +1,9 @@ -//! End-to-end server coverage for upstream media pairing and freshness. -//! -//! Scope: run a local gRPC server and verify webcam/mic packet pairing behavior. -//! Targets: `server/src/main.rs`, `server/src/upstream_media_runtime.rs`. -//! Why: MJPEG lip sync depends on keeping late/early packet decisions stable -//! while streams start, stop, or temporarily lose their pair. +// End-to-end server coverage for upstream media pairing and freshness. +// +// Scope: run a local gRPC server and verify webcam/mic packet pairing behavior. +// Targets: `server/src/main.rs`, `server/src/upstream_media_runtime.rs`. +// Why: MJPEG lip sync depends on keeping late/early packet decisions stable +// while streams start, stop, or temporarily lose their pair. #[cfg(coverage)] #[allow(warnings)] @@ -467,3 +467,18 @@ mod server_upstream_media_pairing { }); } } + +#[cfg(not(coverage))] +mod server_upstream_media_pairing_normal_mode { + const RUNTIME: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/upstream_media_runtime.rs" + )); + + #[test] + fn pairing_runtime_keeps_audio_video_overlap_decisions_explicit() { + assert!(RUNTIME.contains("AwaitingPair")); + assert!(RUNTIME.contains("DropBeforeOverlap")); + assert!(RUNTIME.contains("DropStale")); + } +} diff --git a/testing/tests/server_upstream_media_v2_handoff_contract.rs b/tests/api/server/upstream_media_runtime/server_upstream_media_v2_handoff_contract.rs similarity index 74% rename from testing/tests/server_upstream_media_v2_handoff_contract.rs rename to tests/api/server/upstream_media_runtime/server_upstream_media_v2_handoff_contract.rs index cc7d3c7..5a47fef 100644 --- a/testing/tests/server_upstream_media_v2_handoff_contract.rs +++ b/tests/api/server/upstream_media_runtime/server_upstream_media_v2_handoff_contract.rs @@ -1,18 +1,40 @@ -//! Static contract for the v2 bundled upstream media handoff. -//! -//! Scope: guard `StreamWebcamMedia` against reintroducing timed sink sleeps in -//! the gRPC receive loop. -//! Targets: `server/src/main/relay_service.rs` and -//! `server/src/main/relay_service/upstream_media_rpc.rs`. -//! Why: client->server freshness depends on draining bundled media as it -//! arrives; presentation timing belongs in bounded handoff workers. +// Static contract for the v2 bundled upstream media handoff. +// +// Scope: guard `StreamWebcamMedia` against reintroducing timed sink sleeps in +// the gRPC receive loop. +// Targets: `server/src/main/relay_service.rs` and +// `server/src/main/relay_service/upstream_media_rpc.rs`. +// Why: client->server freshness depends on draining bundled media as it +// arrives; presentation timing belongs in bounded handoff workers. -const RELAY_SERVICE: &str = include_str!("../../server/src/main/relay_service.rs"); -const WEBCAM_RPC: &str = include_str!("../../server/src/main/relay_service/upstream_media_rpc.rs"); -const WEBCAM_SINK: &str = include_str!("../../server/src/video_sinks/webcam_sink.rs"); -const MJPEG_SPOOL: &str = include_str!("../../server/src/video_sinks/mjpeg_spool.rs"); -const PROFILE_OFFSETS: &str = include_str!("../../server/src/calibration/profile_offsets.rs"); -const MATRIX_SCRIPT: &str = include_str!("../../scripts/manual/run_server_to_rc_mode_matrix.sh"); +const RELAY_SERVICE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_service.rs" +)); +const WEBCAM_RPC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_service/upstream_media_rpc.rs" +)); +const WEBCAM_SINK: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/video_sinks/webcam_sink.rs" +)); +const MJPEG_SPOOL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/video_sinks/mjpeg_spool.rs" +)); +const HEVC_MJPEG_GUARD: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/video_sinks/hevc_mjpeg_guard.rs" +)); +const PROFILE_OFFSETS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/calibration/profile_offsets.rs" +)); +const MATRIX_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_server_to_rc_mode_matrix.sh" +)); #[test] fn bundled_receive_loop_enqueues_instead_of_sleeping_for_handoff() { @@ -105,7 +127,9 @@ fn hevc_ingress_decodes_to_existing_mjpeg_uvc_path() { "HEVC camera uplink will be decoded and emitted as MJPEG/UVC", ] { assert!( - WEBCAM_SINK.contains(expected) || MJPEG_SPOOL.contains(expected), + WEBCAM_SINK.contains(expected) + || MJPEG_SPOOL.contains(expected) + || HEVC_MJPEG_GUARD.contains(expected), "HEVC UVC sink should preserve decode-to-MJPEG marker {expected}" ); } @@ -127,7 +151,9 @@ fn mjpeg_ingress_remains_passthrough_and_profile_calibrated() { "FACTORY_HEVC_VIDEO_MODE_OFFSETS_US", "LESAVKA_UPSTREAM_MJPEG_VIDEO_PLAYOUT_MODE_OFFSETS_US", "LESAVKA_UPSTREAM_HEVC_VIDEO_PLAYOUT_MODE_OFFSETS_US", + "LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US", "LESAVKA_SERVER_RC_PROFILE=${LESAVKA_SERVER_RC_PROFILE:-mjpeg}", + "LESAVKA_SERVER_RC_AUDIO_PROFILE=${LESAVKA_SERVER_RC_AUDIO_PROFILE:-pcm}", "LESAVKA_SERVER_RC_NORMALIZED_PROFILE=hevc", ] { assert!( diff --git a/testing/tests/server_upstream_media_video_contract.rs b/tests/api/server/upstream_media_runtime/server_upstream_media_video_contract.rs similarity index 82% rename from testing/tests/server_upstream_media_video_contract.rs rename to tests/api/server/upstream_media_runtime/server_upstream_media_video_contract.rs index 84c046e..a4c40c1 100644 --- a/testing/tests/server_upstream_media_video_contract.rs +++ b/tests/api/server/upstream_media_runtime/server_upstream_media_video_contract.rs @@ -1,16 +1,19 @@ -//! End-to-end server coverage for upstream webcam video streams. -//! -//! Scope: run a local gRPC server and push synthetic client video packets -//! through the public `StreamCamera` RPC. -//! Targets: `server/src/main.rs`, `server/src/video_sinks.rs`. -//! Why: upstream video needs freshness-first delivery while still waiting for -//! an audio timing master before playout. +// End-to-end server coverage for upstream webcam video streams. +// +// Scope: run a local gRPC server and push synthetic client video packets +// through the public `StreamCamera` RPC. +// Targets: `server/src/main.rs`, `server/src/video_sinks.rs`. +// Why: upstream video needs freshness-first delivery while still waiting for +// an audio timing master before playout. #[cfg(coverage)] #[allow(warnings)] mod server_upstream_media_video { include!(env!("LESAVKA_SERVER_MAIN_SRC")); - include!("../support/server_upstream_media_harness.rs"); + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/helpers/support/server_upstream_media_harness.rs" + )); use serial_test::serial; use temp_env::with_var; @@ -194,3 +197,37 @@ mod server_upstream_media_video { }); } } + +#[cfg(not(coverage))] +mod server_upstream_media_video_normal_mode { + const CAMERA_RPC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_service/camera_stream_rpc.rs" + )); + const RELAY_TRAIT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_service.rs" + )); + const VIDEO_SINKS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/video_sinks.rs" + )); + const CAMERA_RELAY: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/video_sinks/camera_relay.rs" + )); + const WEBCAM_SINK: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/video_sinks/webcam_sink.rs" + )); + + #[test] + fn camera_rpc_stays_wired_to_video_sink_and_audio_mastering() { + assert!(RELAY_TRAIT.contains("stream_camera")); + assert!(CAMERA_RPC.contains("camera_rt.activate")); + assert!(CAMERA_RPC.contains("audio_master")); + assert!(CAMERA_RELAY.contains("CameraSink")); + assert!(VIDEO_SINKS.contains("webcam_sink.rs")); + assert!(WEBCAM_SINK.contains("mjpeg_spool")); + } +} diff --git a/tests/chaos/client/server/rct/client_server_rct_route_chaos_contract.rs b/tests/chaos/client/server/rct/client_server_rct_route_chaos_contract.rs new file mode 100644 index 0000000..8b7ead1 --- /dev/null +++ b/tests/chaos/client/server/rct/client_server_rct_route_chaos_contract.rs @@ -0,0 +1,105 @@ +// Chaos contract for the client->server->RCT route. +// +// Scope: model jitter, loss, and reconnect effects on freshness-first bundled +// media without touching physical HID or RCT devices. +// Targets: scripts/manual/client_rct_transport_layers.py and +// client/src/sync_probe/runner/bundled_transport.rs. +// Why: the route should drop stale work, recover on fresh bundles, and point to +// the right bottleneck before we add invasive server introspection. + +const TRANSPORT_LAYERS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/client_rct_transport_layers.py" +)); +const CLIENT_RCT_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_client_to_rct_transport_probe.sh" +)); +const BUNDLED_TRANSPORT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/sync_probe/runner/bundled_transport.rs" +)); + +#[derive(Clone, Copy)] +struct BundleArrival { + pts_ms: u64, + arrived_ms: u64, + keyframe: bool, +} + +fn accepted_fresh_bundles(events: &[BundleArrival], max_age_ms: u64) -> Vec { + let mut waiting_for_keyframe = false; + let mut accepted = Vec::new(); + for event in events { + if event.arrived_ms.saturating_sub(event.pts_ms) > max_age_ms { + waiting_for_keyframe = true; + continue; + } + if waiting_for_keyframe && !event.keyframe { + continue; + } + waiting_for_keyframe = false; + accepted.push(event.pts_ms); + } + accepted +} + +#[test] +fn jitter_loss_and_reconnect_recover_only_on_fresh_keyframe_bundles() { + let route = [ + BundleArrival { + pts_ms: 0, + arrived_ms: 20, + keyframe: true, + }, + BundleArrival { + pts_ms: 33, + arrived_ms: 1_800, + keyframe: false, + }, + BundleArrival { + pts_ms: 66, + arrived_ms: 110, + keyframe: false, + }, + BundleArrival { + pts_ms: 99, + arrived_ms: 140, + keyframe: true, + }, + ]; + + assert_eq!(accepted_fresh_bundles(&route, 1_000), vec![0, 99]); +} + +#[test] +fn chaos_artifacts_classify_bottlenecks_before_deeper_introspection() { + for marker in [ + "client_queue_or_bundle_generation", + "server_receive_or_ingress_queue", + "client_to_server_transport", + "post_client_send_to_rct_path", + "needs_deeper_introspection", + ] { + assert!( + TRANSPORT_LAYERS.contains(marker), + "route chaos summary should preserve bottleneck marker {marker}" + ); + } + + assert!( + CLIENT_RCT_SCRIPT.contains("client-send-bundles.jsonl"), + "client-to-RCT harness should preserve send artifact filename" + ); + + for marker in [ + "local_age_ms", + "video_capture_pts_us", + "audio_first_capture_pts_us", + ] { + assert!( + BUNDLED_TRANSPORT.contains(marker), + "bundled transport should preserve send artifact marker {marker}" + ); + } +} diff --git a/tests/chaos/client/uplink/uplink_backpressure_chaos_contract.rs b/tests/chaos/client/uplink/uplink_backpressure_chaos_contract.rs new file mode 100644 index 0000000..bbefcc6 --- /dev/null +++ b/tests/chaos/client/uplink/uplink_backpressure_chaos_contract.rs @@ -0,0 +1,187 @@ +// Chaos contract for upstream media backpressure behavior. +// +// Scope: prove the freshness-first code path still models stalls, queue +// pressure, and stale media drops as first-class failure modes. +// Targets: `client/src/uplink_latency_harness.rs`, +// `client/src/uplink_fresh_queue.rs`, and `client/src/app/uplink_media/*`. +// Why: real WAN calls fail by forming backlog; chaos coverage keeps the code +// biased toward bounded stutter over minutes of delayed media. + +const LATENCY_HARNESS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/uplink_latency_harness.rs" +)); +const FRESH_QUEUE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/uplink_fresh_queue.rs" +)); +const DROP_LOGGING: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/app/uplink_media/drop_logging.rs" +)); + +#[path = "../../../../client/src/uplink_fresh_queue.rs"] +#[allow(warnings)] +mod uplink_fresh_queue; + +mod input { + pub mod camera { + #[derive(Clone, Copy)] + pub enum CameraCodec { + Hevc, + } + + #[derive(Clone, Copy)] + pub struct CameraConfig { + pub codec: CameraCodec, + } + } +} + +use lesavka_common::lesavka::{AudioEncoding, AudioPacket, UpstreamMediaBundle, VideoPacket}; +use std::time::Duration; +use uplink_fresh_queue::{FreshPacketQueue, FreshQueueConfig, FreshQueuePolicy}; + +include!("../../../../client/src/app/uplink_media/video_keyframes.rs"); + +fn bundle(seq: u64, capture_pts_us: u64, video_data: Vec) -> UpstreamMediaBundle { + UpstreamMediaBundle { + session_id: 13, + seq, + capture_start_us: capture_pts_us.saturating_sub(20_000), + capture_end_us: capture_pts_us.saturating_add(20_000), + video: Some(VideoPacket { + seq, + pts: capture_pts_us, + data: video_data, + client_capture_pts_us: capture_pts_us, + ..Default::default() + }), + audio: vec![AudioPacket { + seq, + pts: capture_pts_us, + client_capture_pts_us: capture_pts_us, + ..Default::default() + }], + audio_sample_rate: 48_000, + audio_channels: 2, + audio_encoding: AudioEncoding::PcmS16le as i32, + video_width: 1280, + video_height: 720, + video_fps: 30, + } +} + +#[test] +fn upstream_backpressure_model_prefers_bounded_drops_over_latency_debt() { + for marker in [ + "freshness_max_age", + "max_queue_depth", + "preserve_backlog_camera_policy_accumulates_stale_video_after_a_stall", + "drop_oldest_policy_keeps_media_live_by_sacrificing_old_packets", + ] { + assert!( + LATENCY_HARNESS.contains(marker), + "latency harness should preserve chaos marker {marker}" + ); + } + + for marker in [ + "FreshQueuePolicy::LatestOnly", + "dropped_queue_full", + "dropped_stale", + "pop_fresh_discards_stale_packets_before_returning_live_media", + ] { + assert!( + FRESH_QUEUE.contains(marker), + "fresh queue should preserve chaos marker {marker}" + ); + } + + assert!( + DROP_LOGGING + .contains("queue is dropping stale/superseded packets to preserve live A/V sync"), + "operator logs should explain freshness-first drops during chaos conditions" + ); +} + +#[tokio::test] +async fn jitter_burst_latest_only_policy_outputs_the_newest_complete_bundle() { + let queue = FreshPacketQueue::new(FreshQueueConfig { + capacity: 8, + max_age: Duration::from_millis(150), + policy: FreshQueuePolicy::LatestOnly, + }); + + let _ = queue.push( + bundle(1, 1_000_000, vec![0, 0, 0, 1, 0x26, 0x01]), + Duration::from_millis(120), + ); + let _ = queue.push( + bundle(2, 1_033_333, vec![0, 0, 0, 1, 0x02, 0x01]), + Duration::from_millis(80), + ); + let _ = queue.push( + bundle(3, 1_066_666, vec![0, 0, 0, 1, 0x26, 0x01]), + Duration::from_millis(20), + ); + + let popped = queue.pop_fresh().await; + let packet = popped.packet.expect("fresh bundle after jitter burst"); + + assert_eq!(popped.dropped_stale, 2); + assert_eq!(packet.seq, 3); + assert!(packet.video.is_some()); + assert_eq!(packet.audio.len(), 1); +} + +#[tokio::test] +async fn reordered_stale_arrival_is_dropped_as_a_whole_bundle() { + let queue = FreshPacketQueue::new(FreshQueueConfig { + capacity: 4, + max_age: Duration::from_millis(150), + policy: FreshQueuePolicy::DrainOldest, + }); + + let _ = queue.push( + bundle(10, 1_300_000, vec![0, 0, 0, 1, 0x26, 0x01]), + Duration::from_millis(220), + ); + let _ = queue.push( + bundle(8, 1_233_334, vec![0, 0, 0, 1, 0x26, 0x01]), + Duration::from_millis(15), + ); + + let popped = queue.pop_fresh().await; + let packet = popped.packet.expect("fresh reordered bundle"); + + assert_eq!(popped.dropped_stale, 1); + assert_eq!(packet.seq, 8); + assert!(packet.video.is_some()); + assert_eq!(packet.audio.len(), 1); +} + +#[test] +fn missing_keyframe_after_capture_gap_holds_predictive_video_until_irap() { + let mut waiting_for_keyframe = false; + assert!(upstream_camera_uses_hevc(Some( + input::camera::CameraConfig { + codec: input::camera::CameraCodec::Hevc, + } + ))); + note_hevc_capture_gap(true, &mut waiting_for_keyframe); + + let delta = bundle(20, 2_000_000, vec![0, 0, 0, 1, 0x02, 0x01]); + let recovery = bundle(21, 2_033_333, vec![0, 0, 0, 1, 0x26, 0x01]); + + assert!(waiting_for_keyframe); + assert!(should_hold_hevc_bundle_for_keyframe_recovery( + waiting_for_keyframe, + &delta + )); + assert!(!should_hold_hevc_bundle_for_keyframe_recovery( + waiting_for_keyframe, + &recovery + )); + assert!(bundle_has_hevc_recovery_keyframe(&recovery)); +} diff --git a/tests/chaos/downstream/video/downstream_video_stall_chaos_contract.rs b/tests/chaos/downstream/video/downstream_video_stall_chaos_contract.rs new file mode 100644 index 0000000..c0085c2 --- /dev/null +++ b/tests/chaos/downstream/video/downstream_video_stall_chaos_contract.rs @@ -0,0 +1,80 @@ +// Downstream video chaos contracts. +// +// Scope: source-stall, decoder-stall, and dropped-frame behavior without +// requiring capture cards or opening UI windows. +// Targets: server eye capture, server stream core, and client monitor pipelines. +// Why: when downstream video gets rough, it should drop or report stale frames +// rather than hiding backlog, wedging the stream, or silently showing black. + +const SERVER_EYE_CAPTURE: &str = include_str!("../../../../server/src/video/eye_capture.rs"); +const SERVER_STREAM_CORE: &str = include_str!("../../../../server/src/video/stream_core.rs"); +const CLIENT_MONITOR: &str = include_str!("../../../../client/src/output/video/monitor_window.rs"); +const CLIENT_UNIFIED_MONITOR: &str = + include_str!("../../../../client/src/output/video/unified_monitor.rs"); + +#[test] +fn source_stalls_are_reported_on_the_stream_and_in_logs() { + for marker in [ + "first_frame_timeout_ms", + "tx_for_first_frame_watchdog", + "Status::internal(detail)", + "idle_ms", + "stall_warn_ms", + ] { + assert!( + SERVER_EYE_CAPTURE.contains(marker), + "source stall handling should preserve marker {marker}" + ); + } +} + +#[test] +fn decoder_and_pipeline_errors_surface_instead_of_silently_blackholing() { + for marker in [ + "pipeline error", + "stream_errors.blocking_send(Err(Status::internal(detail)))", + "pipeline warning", + ] { + assert!( + SERVER_STREAM_CORE.contains(marker), + "server pipeline errors should preserve marker {marker}" + ); + } + for source in [CLIENT_MONITOR, CLIENT_UNIFIED_MONITOR] { + assert!( + source.contains("gst") && source.contains("Error(e)") && source.contains("Warning(w)"), + "client decoder pipeline should continue logging GStreamer errors and warnings" + ); + } +} + +#[test] +fn dropped_frames_increment_telemetry_and_force_decoder_recovery_on_idr() { + for marker in [ + "dropped_window.fetch_add(1", + "dropped_total_for_cb.fetch_add(1", + "wait_for_idr.store(true", + "if wait_for_idr.load(Ordering::Relaxed) && !is_idr", + "if is_idr", + "wait_for_idr.store(false", + ] { + assert!( + SERVER_EYE_CAPTURE.contains(marker), + "dropped-frame recovery should preserve marker {marker}" + ); + } +} + +#[test] +fn client_decoder_queues_drop_downstream_when_display_lags() { + for source in [CLIENT_MONITOR, CLIENT_UNIFIED_MONITOR] { + assert!( + source.matches("leaky=downstream").count() >= 1, + "client display path should drop stale frames under display lag" + ); + assert!( + source.contains("block=false"), + "client display appsrc should not block network receive loops" + ); + } +} diff --git a/tests/chaos/input/input_disconnect_chaos_contract.rs b/tests/chaos/input/input_disconnect_chaos_contract.rs new file mode 100644 index 0000000..fb6cc61 --- /dev/null +++ b/tests/chaos/input/input_disconnect_chaos_contract.rs @@ -0,0 +1,69 @@ +// Simulated disconnect safety contracts for input. +// +// Scope: source-level contracts for release-on-disconnect behavior. +// Targets: `client/src/input/keyboard.rs`, `client/src/input/mouse.rs`, and +// `client/src/input/inputs/routing_state.rs`. +// Why: the safest disconnected input is a released input; regressions here can +// leave remote modifiers/buttons stuck or leak typing into the wrong target. + +const KEYBOARD_REPORTING_SRC: &str = + include_str!("../../../client/src/input/keyboard/reporting.rs"); +const MOUSE_SRC: &str = include_str!(env!("LESAVKA_CLIENT_MOUSE_SRC")); +const INPUT_ROUTING_SRC: &str = include_str!("../../../client/src/input/inputs/routing_state.rs"); + +fn block_after<'a>(source: &'a str, marker: &str) -> &'a str { + let start = source.find(marker).expect("marker exists"); + &source[start..] +} + +#[test] +fn keyboard_drop_always_sends_empty_report_to_release_pressed_keys() { + let drop_impl = block_after(KEYBOARD_REPORTING_SRC, "impl Drop for KeyboardAggregator"); + + assert!(drop_impl.contains("data: [0; 8].into()")); +} + +#[test] +fn mouse_drop_uses_mouse_sized_empty_report_to_release_buttons() { + let drop_impl = block_after(MOUSE_SRC, "impl Drop for MouseAggregator"); + let drop_body = drop_impl + .split_once("#[cfg(test)]") + .map(|(body, _)| body) + .unwrap_or(drop_impl); + + assert!(drop_body.contains("data: [0; 4].into()")); + assert!( + !drop_body.contains("data: [0; 8].into()"), + "mouse disconnect release must not send keyboard-sized reports" + ); +} + +#[test] +fn local_release_sends_zero_reports_before_disabling_forwarding() { + let release = block_after(INPUT_ROUTING_SRC, "fn begin_local_release"); + let keyboard_zero = release + .find("k.send_empty_report()") + .expect("keyboard release"); + let keyboard_disable = release.find("k.set_send(false)").expect("keyboard disable"); + let mouse_reset = release.find("m.reset_state()").expect("mouse release"); + let mouse_disable = release.find("m.set_send(false)").expect("mouse disable"); + + assert!( + keyboard_zero < keyboard_disable, + "keyboard release report must be sent before forwarding is disabled" + ); + assert!( + mouse_reset < mouse_disable, + "mouse release report must be sent before forwarding is disabled" + ); +} + +#[test] +fn finishing_local_release_resets_state_after_ungrabbing_devices() { + let finish = block_after(INPUT_ROUTING_SRC, "fn finish_local_release"); + + assert!(finish.contains("k.set_grab(false);")); + assert!(finish.contains("k.reset_state();")); + assert!(finish.contains("m.set_grab(false);")); + assert!(finish.contains("m.reset_state();")); +} diff --git a/tests/chaos/input/input_packet_ordering_chaos_contract.rs b/tests/chaos/input/input_packet_ordering_chaos_contract.rs new file mode 100644 index 0000000..3025c52 --- /dev/null +++ b/tests/chaos/input/input_packet_ordering_chaos_contract.rs @@ -0,0 +1,46 @@ +// Simulated delayed/duplicate input packet contracts. +// +// Scope: verify the server HID stream remains bounded and freshness-biased +// under packet pressure, without opening real HID devices. +// Targets: `server/src/main/relay_service/input_stream_rpc.rs` and +// `server/src/runtime_support/hid_write.rs`. +// Why: the input path should not build an unbounded backlog that turns a +// delayed packet into a stale click or keystroke seconds later. + +const INPUT_STREAM_RPC: &str = + include_str!("../../../server/src/main/relay_service/input_stream_rpc.rs"); +const HID_WRITE: &str = include_str!("../../../server/src/runtime_support/hid_write.rs"); + +#[test] +fn keyboard_stream_uses_small_ack_channel_and_bounded_report_delay() { + assert!( + INPUT_STREAM_RPC.contains("tokio::sync::mpsc::channel(32)"), + "keyboard stream should keep the echo/ack queue small" + ); + assert!( + INPUT_STREAM_RPC.contains("let report_delay = live_keyboard_report_delay();"), + "keyboard stream should preserve the configured live report pacing" + ); +} + +#[test] +fn hid_write_retries_backpressure_but_does_not_retry_forever() { + assert!(HID_WRITE.contains("LESAVKA_HID_WRITE_RETRIES")); + assert!(HID_WRITE.contains(".unwrap_or(24)")); + assert!(HID_WRITE.contains(".max(1)")); + assert!( + HID_WRITE.contains( + "Err(last_error.unwrap_or_else(|| std::io::Error::from_raw_os_error(libc::EAGAIN)))" + ), + "bounded retry exhaustion should surface EAGAIN instead of hanging" + ); +} + +#[test] +fn eagain_is_dropped_in_the_stream_instead_of_triggering_gadget_recovery() { + assert!( + INPUT_STREAM_RPC.contains("if e.raw_os_error() == Some(libc::EAGAIN)") + && INPUT_STREAM_RPC.contains("write would block (dropped)"), + "stale HID backpressure should drop the packet, not wedge recovery" + ); +} diff --git a/tests/chaos/server/video_sinks/hevc_mjpeg_guard_chaos_contract.rs b/tests/chaos/server/video_sinks/hevc_mjpeg_guard_chaos_contract.rs new file mode 100644 index 0000000..b3780c5 --- /dev/null +++ b/tests/chaos/server/video_sinks/hevc_mjpeg_guard_chaos_contract.rs @@ -0,0 +1,162 @@ +// Chaos-style coverage for decoded HEVC-to-MJPEG visual damage handling. +// +// Scope: model bursts of partial, black, stale, and recovered decoded frames. +// Targets: server/src/video_sinks/hevc_mjpeg_guard.rs, +// server/src/video_sinks/webcam_sink.rs, and server/src/main/relay_service.rs. +// Why: the known-good user experience is sync/freshness first, with freezes +// preferred over showing corrupted grey or torn frames in conferencing apps. + +mod video_support { + pub fn env_u32(name: &str, default: u32) -> u32 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse().ok()) + .unwrap_or(default) + } +} + +#[allow(clippy::items_after_test_module)] +mod guard { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/video_sinks/hevc_mjpeg_guard.rs" + )); + + pub fn should_freeze(previous_bytes: u64, next_bytes: usize) -> bool { + should_freeze_decoded_mjpeg(previous_bytes, next_bytes) + } + + pub fn should_freeze_frame(previous_bytes: u64, decoded_mjpeg: &[u8]) -> bool { + should_freeze_decoded_mjpeg_frame(previous_bytes, decoded_mjpeg) + } +} + +const WEBCAM_SINK: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/video_sinks/webcam_sink.rs" +)); +const RELAY_SERVICE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_service.rs" +)); +const WEBCAM_RPC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_service/upstream_media_rpc.rs" +)); + +#[derive(Debug, Clone, Copy)] +enum FrameEvent { + Emitted(usize), + Frozen(usize), +} + +fn run_damage_burst(frames: &[usize]) -> Vec { + let mut last_good = 0; + let mut events = Vec::with_capacity(frames.len()); + for &bytes in frames { + if guard::should_freeze(last_good, bytes) { + events.push(FrameEvent::Frozen(bytes)); + } else { + last_good = bytes as u64; + events.push(FrameEvent::Emitted(bytes)); + } + } + events +} + +#[test] +fn corrupt_partial_and_black_frames_freeze_until_recovery_frame_arrives() { + temp_env::with_vars( + [ + ("LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP", Some("1")), + ("LESAVKA_UVC_HEVC_SIZE_DROP_PCT", Some("45")), + ("LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES", Some("65536")), + ], + || { + let events = run_damage_burst(&[230_000, 100_000, 12_000, 0, 225_000, 190_000]); + + assert!(matches!(events[0], FrameEvent::Emitted(230_000))); + assert!(matches!(events[1], FrameEvent::Frozen(100_000))); + assert!(matches!(events[2], FrameEvent::Frozen(12_000))); + assert!(matches!(events[3], FrameEvent::Frozen(0))); + assert!(matches!(events[4], FrameEvent::Emitted(225_000))); + assert!(matches!(events[5], FrameEvent::Emitted(190_000))); + }, + ); +} + +#[test] +fn tiny_startup_frames_do_not_poison_later_decode_baselines() { + temp_env::with_vars( + [ + ("LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP", Some("1")), + ("LESAVKA_UVC_HEVC_SIZE_DROP_PCT", Some("45")), + ("LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES", Some("65536")), + ], + || { + let events = run_damage_burst(&[8_000, 7_000, 120_000, 53_000]); + + assert!(matches!(events[0], FrameEvent::Emitted(8_000))); + assert!(matches!(events[1], FrameEvent::Emitted(7_000))); + assert!(matches!(events[2], FrameEvent::Emitted(120_000))); + assert!(matches!(events[3], FrameEvent::Frozen(53_000))); + }, + ); +} + +#[test] +fn server_hevc_recovery_and_freshest_spool_paths_remain_wired() { + for marker in [ + "freshest_mjpeg_sample(sink)", + "last_decoded_mjpeg_bytes", + "should_freeze_decoded_mjpeg_frame(previous_bytes, map.as_slice())", + "freezing suspicious decoded HEVC->MJPEG frame", + ] { + assert!( + WEBCAM_SINK.contains(marker), + "HEVC output damage handling should preserve marker {marker}" + ); + } + + for marker in [ + "media_v2_should_hold_hevc_video_for_recovery", + "media_v2_has_hevc_recovery_keyframe", + "held HEVC delta frame until next recovery keyframe", + ] { + assert!( + RELAY_SERVICE.contains(marker) || WEBCAM_RPC.contains(marker), + "server missing-keyframe recovery should preserve marker {marker}" + ); + } +} + +fn jpeg_with_payload(payload: &[u8]) -> Vec { + let mut bytes = vec![0xff, 0xd8, 0xff, 0xda]; + bytes.extend_from_slice(payload); + bytes.extend_from_slice(&[0xff, 0xd9]); + bytes +} + +#[test] +fn grey_and_black_slab_bursts_freeze_instead_of_reaching_uvc() { + temp_env::with_vars( + [ + ("LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP", Some("1")), + ("LESAVKA_UVC_HEVC_SIZE_DROP_PCT", Some("45")), + ("LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES", Some("65536")), + ("LESAVKA_UVC_HEVC_MIN_PAYLOAD_DISTINCT_BYTES", Some("12")), + ("LESAVKA_UVC_HEVC_DOMINANT_BYTE_PCT", Some("92")), + ], + || { + let prior_good = 240_000; + let healthy_payload: Vec = (0..140_000).map(|idx| (idx % 251) as u8).collect(); + let grey = jpeg_with_payload(&vec![0x80; 140_000]); + let black = jpeg_with_payload(&vec![0x00; 140_000]); + let healthy = jpeg_with_payload(&healthy_payload); + + assert!(guard::should_freeze_frame(prior_good, &grey)); + assert!(guard::should_freeze_frame(prior_good, &black)); + assert!(!guard::should_freeze_frame(prior_good, &healthy)); + }, + ); +} diff --git a/tests/chaos/system/interrupted_install_safe_state_contract.rs b/tests/chaos/system/interrupted_install_safe_state_contract.rs new file mode 100644 index 0000000..d2214a3 --- /dev/null +++ b/tests/chaos/system/interrupted_install_safe_state_contract.rs @@ -0,0 +1,143 @@ +// Chaos contract for interrupted or partially failed installs. +// +// Scope: preserve best-effort cleanup and explicit recovery boundaries in +// install scripts without executing privileged operations. +// Targets: client/server install scripts. +// Why: failed installs should leave the Pi and desktop in a recoverable state, +// especially around capture power, temporary PKI material, and USB gadget reset. + +const SERVER_INSTALL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/install/server.sh" +)); +const CLIENT_INSTALL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/install/client.sh" +)); + +#[test] +fn server_restores_capture_power_after_discovery_failures() { + for marker in [ + "trap restore_capture_power_after_discovery EXIT", + "restore_capture_power_after_discovery", + "borrowing relay GPIO power for capture discovery", + "trap - EXIT", + ] { + assert!( + SERVER_INSTALL.contains(marker), + "server install should preserve capture-power recovery marker {marker}" + ); + } +} + +#[test] +fn attached_host_gadget_rebuilds_require_two_explicit_knobs() { + for marker in [ + "LESAVKA_FORCE_GADGET_REBUILD", + "LESAVKA_ALLOW_GADGET_RESET", + "EXPLICIT_GADGET_REBUILD=1", + "Preserving the attached gadget to avoid wedging the Pi USB controller", + "Run during a maintenance window", + ] { + assert!( + SERVER_INSTALL.contains(marker), + "server install should preserve attached-gadget safety marker {marker}" + ); + } +} + +#[test] +fn client_tls_bundle_tempfiles_are_removed_on_failure_and_success() { + for marker in [ + "tmp_bundle=$(run_as_user mktemp", + "rm -f \"$tmp_bundle\"", + "tmp=$(mktemp -d)", + "sudo rm -rf \"$tmp\"", + "rm -f \"$bundle\"", + ] { + assert!( + CLIENT_INSTALL.contains(marker), + "client install should preserve temporary bundle cleanup marker {marker}" + ); + } +} + +#[test] +fn server_install_refuses_to_kill_unrelated_port_owners() { + for marker in [ + "unexpected process", + "no owning PID could be identified", + "refusing to start a duplicate server", + "clear_stale_server_listener", + ] { + assert!( + SERVER_INSTALL.contains(marker), + "stale listener cleanup should preserve fail-safe marker {marker}" + ); + } +} + +#[test] +fn installers_refuse_to_replace_live_entrypoints_with_empty_artifacts() { + for marker in [ + "install_verified_executable()", + "source '$src' is missing or empty", + "staged install output was not a non-empty executable", + "Preserving the existing installed executable", + ] { + assert!( + SERVER_INSTALL.contains(marker), + "server installer should preserve verified executable install marker {marker}" + ); + assert!( + CLIENT_INSTALL.contains(marker), + "client installer should preserve verified executable install marker {marker}" + ); + } + + for marker in [ + "install_verified_executable \"$SRC_DIR/target/release/lesavka-server\" /usr/local/bin/lesavka-server", + "install_verified_executable \"$SRC_DIR/target/release/lesavka-uvc\" /usr/local/bin/lesavka-uvc", + "install_verified_executable \"$SRC_DIR/scripts/daemon/lesavka-core.sh\" /usr/local/bin/lesavka-core.sh", + "install_verified_executable \"$SRC_DIR/scripts/daemon/lesavka-uvc.sh\" /usr/local/bin/lesavka-uvc.sh", + "install_verified_executable \"$SRC_DIR/scripts/daemon/lesavka-recovery-ladder.sh\" /usr/local/bin/lesavka-recovery-ladder", + ] { + assert!( + SERVER_INSTALL.contains(marker), + "server installer should protect systemd entrypoint {marker}" + ); + } + + assert!( + CLIENT_INSTALL.contains( + "install_verified_executable \"$SRC/target/release/lesavka-client\" /usr/local/bin/lesavka-client" + ), + "client installer should protect the launchable desktop binary" + ); +} + +#[test] +fn recovery_ladder_restores_before_rebooting_or_touching_the_core_gadget() { + let ladder = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/daemon/lesavka-recovery-ladder.sh" + )); + for marker in [ + "restore_last_good", + "restart_server_only", + "restart_uvc_and_server", + "LESAVKA_RECOVERY_ALLOW_CORE_RESTART:-0", + "LESAVKA_RECOVERY_ALLOW_REBOOT:-0", + "core restart disabled; preserving attached USB gadget", + "reboot disabled; leaving host online for operator inspection", + ] { + assert!( + ladder.contains(marker), + "recovery ladder should preserve soft-recovery marker {marker}" + ); + } + assert!( + ladder.find("restore_last_good").unwrap() < ladder.find("reboot_if_allowed").unwrap(), + "the ladder should try last-known-good restore before any optional reboot" + ); +} diff --git a/tests/compatibility/client/audio/client_opus_transport_contract.rs b/tests/compatibility/client/audio/client_opus_transport_contract.rs new file mode 100644 index 0000000..5cc8b11 --- /dev/null +++ b/tests/compatibility/client/audio/client_opus_transport_contract.rs @@ -0,0 +1,94 @@ +// Client compatibility contracts for an optional Opus upstream audio transport. +// +// Scope: inspect client capture/bundle/install seams without requiring a live +// audio stack. +// Targets: `client/src/input/microphone.rs`, +// `client/src/app/uplink_media/uplink_queue_metadata.rs`, and +// `scripts/install/client.sh`. +// Why: Opus is now the optimized microphone transport, while raw PCM remains +// one click away as the known-good fallback. + +const MICROPHONE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/input/microphone.rs" +)); +const AUDIO_CODEC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/input/audio_codec.rs" +)); +const UPLINK_QUEUE_METADATA: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/app/uplink_media/uplink_queue_metadata.rs" +)); +const CLIENT_INSTALL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/install/client.sh" +)); + +#[test] +fn client_microphone_path_preserves_pcm_fallback_and_opus_selection() { + for expected in [ + "audio_transport::mark_packet_pcm_s16le", + "OpusPacketEncoder", + "requested_upstream_audio_codec_from_env", + "LESAVKA_UPLINK_AUDIO_CODEC", + "LESAVKA_MIC_NOISE_SUPPRESSION", + "webrtcdsp", + "frame_duration_us", + "MIC_SAMPLE_RATE", + "MIC_CHANNELS", + "MIC_PACKET_TARGET_DURATION_ENV", + "DEFAULT_MIC_PACKET_TARGET_DURATION_US: u64 = 20_000", + ] { + assert!( + MICROPHONE.contains(expected) || AUDIO_CODEC.contains(expected), + "microphone capture should preserve audio transport marker {expected}" + ); + } + assert!( + AUDIO_CODEC.contains("mark_packet_opus") && AUDIO_CODEC.contains("Opus by default"), + "client Opus encoder should stamp packets while documenting the default" + ); +} + +#[test] +fn bundled_uplink_envelope_carries_audio_codec_metadata_with_video() { + for expected in [ + "packet_audio_profile", + "mark_bundle_audio_profile", + "has_audio", + "UpstreamMediaBundle", + "attach_bundle_queue_metadata", + ] { + assert!( + UPLINK_QUEUE_METADATA.contains(expected), + "bundled uplink should preserve audio profile marker {expected}" + ); + } + assert!( + UPLINK_QUEUE_METADATA + .find("mark_bundle_audio_profile") + .unwrap() + < UPLINK_QUEUE_METADATA + .find("attach_bundle_queue_metadata") + .unwrap(), + "bundle audio codec metadata should be available before queue metadata is attached" + ); +} + +#[test] +fn client_installer_reports_optional_opus_gstreamer_route() { + for expected in [ + "Opus upstream audio transport route", + "opusenc", + "opusdec", + "microphone noise suppression route", + "webrtcdsp", + "fall back to PCM", + ] { + assert!( + CLIENT_INSTALL.contains(expected), + "client installer should keep Opus diagnostic marker {expected}" + ); + } +} diff --git a/tests/compatibility/client/video_support/client_video_support_include_contract.rs b/tests/compatibility/client/video_support/client_video_support_include_contract.rs new file mode 100644 index 0000000..aca0152 --- /dev/null +++ b/tests/compatibility/client/video_support/client_video_support_include_contract.rs @@ -0,0 +1,94 @@ +// Module-path coverage for client-side H.264 decoder selection. +// +// Scope: include the client decoder selection helper directly. +// Targets: `client/src/video_support.rs`. +// Why: operator decoder overrides should fall back cleanly on machines with +// different GStreamer plugin sets. + +#[path = "../../../../client/src/video_support.rs"] +mod video_support; + +use serial_test::serial; +use temp_env::with_var; + +#[test] +#[serial] +fn decoder_override_accepts_decodebin_without_factory_lookup() { + with_var("LESAVKA_H264_DECODER", Some("decodebin"), || { + assert_eq!(video_support::pick_h264_decoder(), "decodebin"); + }); +} + +#[test] +#[serial] +fn decoder_override_accepts_buildable_element() { + with_var("LESAVKA_H264_DECODER", Some("fakesink"), || { + assert_eq!(video_support::pick_h264_decoder(), "fakesink"); + }); +} + +#[test] +#[serial] +fn decoder_override_ignores_blank_or_unknown_values() { + with_var("LESAVKA_H264_DECODER", Some(" "), || { + assert!(!video_support::pick_h264_decoder().trim().is_empty()); + }); + with_var("LESAVKA_H264_DECODER", Some("not-a-real-decoder"), || { + assert!(!video_support::pick_h264_decoder().trim().is_empty()); + }); +} + +#[test] +#[serial] +fn decoder_auto_order_supports_proprietary_and_open_source_routes() { + with_var("LESAVKA_H264_DECODER_PREFERENCE", None::<&str>, || { + let order = video_support::h264_decoder_preference_order(); + assert_eq!(order.first(), Some(&"nvh264dec")); + assert!(order.contains(&"nvh264sldec")); + assert!(order.contains(&"vah264dec")); + assert!(order.contains(&"vaapih264dec")); + assert!(order.contains(&"v4l2h264dec")); + assert!(order.contains(&"v4l2slh264dec")); + assert!( + order + .iter() + .position(|name| *name == "v4l2h264dec") + .unwrap() + < order.iter().position(|name| *name == "avdec_h264").unwrap(), + "hardware routes should be attempted before CPU fallback by default" + ); + }); +} + +#[test] +#[serial] +fn decoder_auto_order_can_prefer_software_for_driver_comparisons() { + with_var("LESAVKA_H264_DECODER_PREFERENCE", Some("software"), || { + let order = video_support::h264_decoder_preference_order(); + assert_eq!(order.first(), Some(&"avdec_h264")); + assert!( + order + .iter() + .position(|name| *name == "openh264dec") + .unwrap() + < order.iter().position(|name| *name == "nvh264dec").unwrap(), + "software preference should keep CPU decoders before GPU routes" + ); + }); +} + +#[test] +#[serial] +#[cfg(coverage)] +fn decoder_selection_falls_back_when_no_factory_can_build() { + with_var("TEST_DISABLE_H264_DECODER_FACTORY", Some("1"), || { + with_var("LESAVKA_H264_DECODER", Some("fakesink"), || { + assert_eq!(video_support::pick_h264_decoder(), "decodebin"); + }); + }); + with_var("TEST_FAIL_GST_INIT", Some("1"), || { + with_var("LESAVKA_H264_DECODER", None::<&str>, || { + assert_eq!(video_support::pick_h264_decoder(), "decodebin"); + }); + }); +} diff --git a/tests/compatibility/downstream/video/downstream_video_mode_decoder_matrix_contract.rs b/tests/compatibility/downstream/video/downstream_video_mode_decoder_matrix_contract.rs new file mode 100644 index 0000000..f4587db --- /dev/null +++ b/tests/compatibility/downstream/video/downstream_video_mode_decoder_matrix_contract.rs @@ -0,0 +1,72 @@ +// Downstream capture/decoder compatibility contracts. +// +// Scope: lock down the supported RCT capture modes and H.264 decoder fallback +// matrix used for server-to-client downstream video. +// Targets: `common/src/eye_source.rs`, `client/src/output/video/monitor_window.rs`, +// and `server/src/video/eye_capture.rs`. +// Why: downstream transport is H.264 pass-through from the capture cards; mode +// or decoder drift can increase lag or break display on a fresh install. + +use lesavka_common::eye_source::{ + EyeSourceMode, eye_source_mode_for_request, native_eye_source_modes, +}; + +const CLIENT_MONITOR: &str = include_str!("../../../../client/src/output/video/monitor_window.rs"); +const SERVER_EYE_CAPTURE: &str = include_str!("../../../../server/src/video/eye_capture.rs"); + +#[test] +fn downstream_native_modes_remain_1080p60_and_720p60() { + assert_eq!( + native_eye_source_modes(), + &[ + EyeSourceMode { + width: 1920, + height: 1080, + fps: 60, + }, + EyeSourceMode { + width: 1280, + height: 720, + fps: 60, + }, + ] + ); + assert_eq!(eye_source_mode_for_request(1920, 1080).fps, 60); + assert_eq!(eye_source_mode_for_request(1280, 720).fps, 60); +} + +#[test] +fn client_decoder_matrix_prefers_known_h264_decoders_with_decodebin_fallback() { + for decoder in [ + "avdec_h264", + "openh264dec", + "nvh264dec", + "nvh264sldec", + "vah264dec", + "vaapih264dec", + "v4l2h264dec", + "v4l2slh264dec", + "\"decodebin\".to_string()", + ] { + assert!( + CLIENT_MONITOR.contains(decoder), + "client decoder matrix should contain {decoder}" + ); + } +} + +#[test] +fn server_downstream_capture_stays_h264_annex_b_passthrough_for_real_cards() { + for marker in [ + "video/x-h264,width={},height={}", + "h264parse disable-passthrough=true config-interval=-1", + "video/x-h264,stream-format=byte-stream,alignment=au", + "server_encoder_label = if use_test_src", + "\"source-pass-through\".to_string()", + ] { + assert!( + SERVER_EYE_CAPTURE.contains(marker), + "server capture compatibility should preserve marker {marker}" + ); + } +} diff --git a/testing/tests/server_camera_runtime_contract.rs b/tests/compatibility/server/camera/server_camera_runtime_contract.rs similarity index 94% rename from testing/tests/server_camera_runtime_contract.rs rename to tests/compatibility/server/camera/server_camera_runtime_contract.rs index 9374b32..4bdc281 100644 --- a/testing/tests/server_camera_runtime_contract.rs +++ b/tests/compatibility/server/camera/server_camera_runtime_contract.rs @@ -1,10 +1,10 @@ -//! Integration coverage for the camera runtime activation contract. -//! -//! Scope: validate activation error handling and configuration equality edges -//! through public camera-runtime APIs. -//! Targets: `server/src/camera_runtime.rs`. -//! Why: camera runtime generation and guardrails are core to safe stream -//! transitions, so they need direct integration-level assertions. +// Integration coverage for the camera runtime activation contract. +// +// Scope: validate activation error handling and configuration equality edges +// through public camera-runtime APIs. +// Targets: `server/src/camera_runtime.rs`. +// Why: camera runtime generation and guardrails are core to safe stream +// transitions, so they need direct integration-level assertions. use lesavka_server::camera::{CameraCodec, CameraConfig, CameraOutput, HdmiConnector, HdmiMode}; use lesavka_server::camera_runtime::{CameraRuntime, camera_cfg_eq}; diff --git a/testing/tests/server_uvc_runtime_contract.rs b/tests/compatibility/server/uvc/server_uvc_runtime_contract.rs similarity index 95% rename from testing/tests/server_uvc_runtime_contract.rs rename to tests/compatibility/server/uvc/server_uvc_runtime_contract.rs index 433d802..d858871 100644 --- a/testing/tests/server_uvc_runtime_contract.rs +++ b/tests/compatibility/server/uvc/server_uvc_runtime_contract.rs @@ -1,10 +1,10 @@ -//! Integration coverage for the UVC runtime supervision contract. -//! -//! Scope: exercise the long-running UVC helper supervisor in both successful -//! spawn and spawn-failure modes. -//! Targets: `server/src/uvc_runtime.rs`. -//! Why: the helper supervisor is operationally critical and should be covered -//! through top-level integration behavior, not only unit checks. +// Integration coverage for the UVC runtime supervision contract. +// +// Scope: exercise the long-running UVC helper supervisor in both successful +// spawn and spawn-failure modes. +// Targets: `server/src/uvc_runtime.rs`. +// Why: the helper supervisor is operationally critical and should be covered +// through top-level integration behavior, not only unit checks. use lesavka_server::uvc_runtime::{pick_uvc_device, supervise_uvc_control}; use serial_test::serial; diff --git a/tests/compatibility/server/video/hevc_mjpeg_profile_matrix_contract.rs b/tests/compatibility/server/video/hevc_mjpeg_profile_matrix_contract.rs new file mode 100644 index 0000000..7765374 --- /dev/null +++ b/tests/compatibility/server/video/hevc_mjpeg_profile_matrix_contract.rs @@ -0,0 +1,69 @@ +// Compatibility coverage for profile-specific server-to-RCT calibration. +// +// Scope: ensure the calibrated MJPEG and HEVC ingress profiles both cover every +// supported UVC mode. +// Targets: server/src/calibration/profile_offsets.rs and +// scripts/manual/run_server_to_rc_mode_matrix.sh. +// Why: the HEVC path decodes and re-encodes before UVC, so it must never +// silently borrow the MJPEG timing map unless explicitly asked. + +use lesavka_server::calibration::{ + FACTORY_HEVC_AUDIO_MODE_OFFSETS_US, FACTORY_HEVC_OPUS_AUDIO_MODE_OFFSETS_US, + FACTORY_HEVC_VIDEO_MODE_OFFSETS_US, FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US, + FACTORY_MJPEG_PCM_AUDIO_MODE_OFFSETS_US, FACTORY_MJPEG_VIDEO_MODE_OFFSETS_US, +}; + +const MATRIX_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_server_to_rc_mode_matrix.sh" +)); + +const SUPPORTED_MODES: [&str; 4] = ["1280x720@20", "1280x720@30", "1920x1080@20", "1920x1080@30"]; + +fn map_contains_mode(map: &str, mode: &str) -> bool { + map.split(',') + .any(|entry| entry.starts_with(&format!("{mode}="))) +} + +#[test] +fn hevc_and_mjpeg_factory_maps_cover_all_supported_uvc_modes() { + for mode in SUPPORTED_MODES { + assert!(map_contains_mode(FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US, mode)); + assert!(map_contains_mode(FACTORY_MJPEG_VIDEO_MODE_OFFSETS_US, mode)); + assert!(map_contains_mode(FACTORY_HEVC_AUDIO_MODE_OFFSETS_US, mode)); + assert!(map_contains_mode(FACTORY_HEVC_VIDEO_MODE_OFFSETS_US, mode)); + } +} + +#[test] +fn hevc_profile_defaults_are_separate_from_mjpeg_profile_defaults() { + assert_ne!( + FACTORY_MJPEG_VIDEO_MODE_OFFSETS_US, FACTORY_HEVC_VIDEO_MODE_OFFSETS_US, + "HEVC decode-to-MJPEG should retain its own calibrated timing profile" + ); + assert_eq!( + FACTORY_MJPEG_PCM_AUDIO_MODE_OFFSETS_US, + FACTORY_HEVC_AUDIO_MODE_OFFSETS_US + ); + assert_eq!( + FACTORY_HEVC_OPUS_AUDIO_MODE_OFFSETS_US, FACTORY_HEVC_AUDIO_MODE_OFFSETS_US, + "Opus has a separate audio map that currently inherits PCM until lab calibration lands" + ); +} + +#[test] +fn mode_matrix_script_can_calibrate_profiles_without_overwriting_each_other() { + for marker in [ + "LESAVKA_SERVER_RC_PROFILE=${LESAVKA_SERVER_RC_PROFILE:-mjpeg}", + "LESAVKA_SERVER_RC_NORMALIZED_PROFILE=hevc", + "LESAVKA_SERVER_RC_HEVC_MODE_DELAYS_US", + "LESAVKA_UPSTREAM_MJPEG_VIDEO_PLAYOUT_MODE_OFFSETS_US", + "LESAVKA_UPSTREAM_HEVC_VIDEO_PLAYOUT_MODE_OFFSETS_US", + "LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US", + ] { + assert!( + MATRIX_SCRIPT.contains(marker), + "mode matrix should preserve profile marker {marker}" + ); + } +} diff --git a/testing/tests/video_support_contract.rs b/tests/compatibility/video/video_support_contract.rs similarity index 76% rename from testing/tests/video_support_contract.rs rename to tests/compatibility/video/video_support_contract.rs index 8c44cb2..32f48f2 100644 --- a/testing/tests/video_support_contract.rs +++ b/tests/compatibility/video/video_support_contract.rs @@ -1,10 +1,10 @@ -//! Integration coverage for the server video-support helpers. -//! -//! Scope: exercise the pure policy helpers and cached env flag behavior from -//! the top-level testing module. -//! Targets: `server/src/video_support.rs`. -//! Why: these helpers are small but central to the adaptive video policy, so -//! they are a good fit for a centralized contract test. +// Integration coverage for the server video-support helpers. +// +// Scope: exercise the pure policy helpers and cached env flag behavior from +// the root test taxonomy. +// Targets: `server/src/video_support.rs`. +// Why: these helpers are small but central to the adaptive video policy, so +// they are a good fit for a centralized contract test. use gstreamer as gst; use serial_test::serial; @@ -12,8 +12,9 @@ use std::process::Command; use temp_env::with_var; use lesavka_server::video_support::{ - adjust_effective_fps, contains_idr, default_eye_fps, dev_mode_enabled, env_u32, env_usize, - next_local_pts, pick_h264_decoder, pick_hevc_decoder, should_send_frame, + adjust_effective_fps, contains_hevc_irap, contains_idr, default_eye_fps, dev_mode_enabled, + env_u32, env_usize, next_local_pts, pick_h264_decoder, pick_hevc_decoder, reserve_local_pts, + should_send_frame, }; #[test] @@ -32,20 +33,38 @@ fn contains_idr_handles_short_and_multi_nal_annex_b_streams() { assert!(!contains_idr(&[0, 0, 2, 0x65, 0, 0, 0, 2, 0x65])); } +#[test] +fn contains_hevc_irap_handles_short_and_multi_nal_annex_b_streams() { + assert!(contains_hevc_irap(&[0, 0, 1, 0x26, 0x01, 0xaa])); + assert!(contains_hevc_irap(&[ + 0, 0, 0, 1, 0x02, 0x01, 0xaa, 0, 0, 1, 0x28, 0x01, 0xbb, + ])); + assert!(!contains_hevc_irap(&[0, 0, 0, 1, 0x02, 0x01, 0xaa])); + assert!(!contains_hevc_irap(&[0, 0, 2, 0x26, 0, 0, 0, 2, 0x28])); + assert!(!contains_hevc_irap(&[0, 0, 1, 0x26])); +} + #[test] fn adjust_effective_fps_and_timestamp_helpers_stay_stable() { assert_eq!(adjust_effective_fps(20, 12, 25, 5, 10), 17); assert_eq!(adjust_effective_fps(20, 12, 25, 0, 20), 21); assert_eq!(adjust_effective_fps(12, 12, 25, 10, 10), 12); assert_eq!(adjust_effective_fps(18, 12, 25, 0, 0), 18); + assert_eq!(adjust_effective_fps(0, 0, 25, 1, 100), 1); assert!(should_send_frame(0, 10, 25)); assert!(!should_send_frame(40_000, 50_000, 25)); assert!(should_send_frame(40_000, 90_000, 25)); + assert!(should_send_frame(40_000, 1_040_000, 0)); let counter = std::sync::atomic::AtomicU64::new(0); assert_eq!(next_local_pts(&counter, 40_000), 0); assert_eq!(next_local_pts(&counter, 40_000), 40_000); + + let reserved = std::sync::atomic::AtomicU64::new(0); + assert_eq!(reserve_local_pts(&reserved, 20_000, 0), 20_000); + assert_eq!(reserve_local_pts(&reserved, 20_000, 40_000), 20_001); + assert_eq!(reserve_local_pts(&reserved, 100_000, 40_000), 100_000); } #[test] @@ -60,6 +79,9 @@ fn env_helpers_parse_and_fallback_without_panicking() { with_var("LESAVKA_TEST_USIZE", Some("128"), || { assert_eq!(env_usize("LESAVKA_TEST_USIZE", 64), 128); }); + with_var("LESAVKA_TEST_USIZE", Some("oops"), || { + assert_eq!(env_usize("LESAVKA_TEST_USIZE", 64), 64); + }); with_var("LESAVKA_TEST_USIZE", None::<&str>, || { assert_eq!(env_usize("LESAVKA_TEST_USIZE", 64), 64); }); diff --git a/tests/component/client/uplink/client_uplink_component_contract.rs b/tests/component/client/uplink/client_uplink_component_contract.rs new file mode 100644 index 0000000..921e3b7 --- /dev/null +++ b/tests/component/client/uplink/client_uplink_component_contract.rs @@ -0,0 +1,67 @@ +// Component contract for the client upstream bundling path. +// +// Scope: protect the client-side media component that joins live audio and +// encoded video before transport. +// Targets: `client/src/app/uplink_media/*.rs`. +// Why: the current sync win depends on sending related audio and video together +// while still dropping stale backlog, so the component wiring must not regress. + +const BUNDLED_QUEUE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/app/uplink_media/bundled_media_queue.rs" +)); +const WEBCAM_LOOP: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/app/uplink_media/webcam_media_loop.rs" +)); +const VIDEO_KEYFRAMES: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/app/uplink_media/video_keyframes.rs" +)); +const QUEUE_METADATA: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/app/uplink_media/uplink_queue_metadata.rs" +)); + +#[test] +fn bundled_component_keeps_audio_video_and_hevc_recovery_on_the_same_path() { + for marker in [ + "const VIDEO_UPLINK_QUEUE", + "const AUDIO_UPLINK_QUEUE", + "const BUNDLED_MEDIA_UPLINK_QUEUE", + "FreshQueuePolicy::LatestOnly", + "FreshQueuePolicy::DrainOldest", + "bundle_captured_media(", + ] { + assert!( + BUNDLED_QUEUE.contains(marker), + "bundled component should preserve marker {marker}" + ); + } + + for marker in [ + "stream_webcam_media", + "should_hold_hevc_bundle_for_keyframe_recovery", + "note_hevc_capture_gap", + "spawn_upstream_epoch_auto_heal", + "microphone_telemetry.record_connected()", + ] { + assert!( + WEBCAM_LOOP.contains(marker) || VIDEO_KEYFRAMES.contains(marker), + "webcam component should preserve marker {marker}" + ); + } + + for marker in [ + "capture_start_us", + "capture_end_us", + "attach_bundle_queue_metadata", + "packet_audio_capture_pts_us", + "packet_video_capture_pts_us", + ] { + assert!( + QUEUE_METADATA.contains(marker), + "bundle metadata should preserve marker {marker}" + ); + } +} diff --git a/testing/tests/client_app_include_contract.rs b/tests/contract/client/app/client_app_include_contract.rs similarity index 86% rename from testing/tests/client_app_include_contract.rs rename to tests/contract/client/app/client_app_include_contract.rs index 5051a97..5997813 100644 --- a/testing/tests/client_app_include_contract.rs +++ b/tests/contract/client/app/client_app_include_contract.rs @@ -1,11 +1,10 @@ -//! Include-based coverage for client app startup reactor behavior. -//! -//! Scope: compile the client app reactor with deterministic local stubs for -//! capture/render dependencies, then exercise `new` + `run`. -//! Targets: `client/src/app.rs`, `client/src/app/downlink_media.rs`. -//! Why: app orchestration branches should stay stable in CI without physical -//! devices. -#![allow(dead_code)] +// Include-based coverage for client app startup reactor behavior. +// +// Scope: compile the client app reactor with deterministic local stubs for +// capture/render dependencies, then exercise `new` + `run`. +// Targets: `client/src/app.rs`, `client/src/app/downlink_media.rs`. +// Why: app orchestration branches should stay stable in CI without physical +// devices. mod handshake { #[allow(dead_code)] @@ -29,18 +28,21 @@ mod handshake { #[allow(warnings)] mod live_capture_clock { - include!("support/live_capture_clock_shim.rs"); + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/helpers/support/live_capture_clock_shim.rs" + )); } -#[path = "../../client/src/uplink_fresh_queue.rs"] +#[path = "../../../../client/src/uplink_fresh_queue.rs"] #[allow(warnings)] mod uplink_fresh_queue; -#[path = "../../client/src/uplink_telemetry.rs"] +#[path = "../../../../client/src/uplink_telemetry.rs"] #[allow(warnings)] mod uplink_telemetry; -#[path = "../../client/src/live_media_control.rs"] +#[path = "../../../../client/src/live_media_control.rs"] #[allow(warnings)] mod live_media_control; @@ -48,6 +50,7 @@ mod app_support { use super::handshake::PeerCaps; use std::time::Duration; + #[allow(dead_code)] #[derive(Clone, Copy, Debug)] pub enum CameraCodec { H264, @@ -97,6 +100,19 @@ mod relay_transport { } mod input { + pub mod audio_codec { + pub use lesavka_common::audio_transport::{UpstreamAudioCodec, parse_upstream_audio_codec}; + + pub fn requested_upstream_audio_codec_from_env() -> UpstreamAudioCodec { + std::env::var("LESAVKA_UPLINK_AUDIO_CODEC") + .ok() + .or_else(|| std::env::var("LESAVKA_AUDIO_CODEC").ok()) + .as_deref() + .and_then(parse_upstream_audio_codec) + .unwrap_or(UpstreamAudioCodec::Opus) + } + } + pub mod camera { pub use crate::app_support::{CameraCodec, CameraConfig}; use lesavka_common::lesavka::VideoPacket; @@ -140,6 +156,21 @@ mod input { Self::new() } + pub fn new_default_source_options( + _codec: super::audio_codec::UpstreamAudioCodec, + _noise_suppression: bool, + ) -> anyhow::Result { + Self::new_default_source() + } + + pub fn new_with_source_options( + source: Option<&str>, + _codec: super::audio_codec::UpstreamAudioCodec, + _noise_suppression: bool, + ) -> anyhow::Result { + Self::new_with_source(source) + } + pub fn pull(&self) -> Option { None } @@ -269,7 +300,7 @@ mod paste { } } -#[path = "../../client/src/app.rs"] +#[path = "../../../../client/src/app.rs"] #[allow(warnings)] mod app_include_contract; @@ -282,8 +313,14 @@ mod tests { use temp_env::with_var; use tokio_stream::wrappers::errors::BroadcastStreamRecvError; - const DOWNLINK_MEDIA_SRC: &str = include_str!("../../client/src/app/downlink_media.rs"); - const INPUT_STREAMS_SRC: &str = include_str!("../../client/src/app/input_streams.rs"); + const DOWNLINK_MEDIA_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/app/downlink_media.rs" + )); + const INPUT_STREAMS_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/app/input_streams.rs" + )); #[test] #[serial] diff --git a/testing/tests/client_app_process_contract.rs b/tests/contract/client/app/client_app_process_contract.rs similarity index 87% rename from testing/tests/client_app_process_contract.rs rename to tests/contract/client/app/client_app_process_contract.rs index 0f2737c..93b8b4a 100644 --- a/testing/tests/client_app_process_contract.rs +++ b/tests/contract/client/app/client_app_process_contract.rs @@ -1,10 +1,10 @@ -//! Integration coverage for client app runtime startup paths. -//! -//! Scope: launch the real `lesavka-client` binary with runtime toggles that -//! execute `LesavkaClientApp::run` startup branches. -//! Targets: `client/src/app.rs`. -//! Why: process-level startup behavior should stay deterministic in both -//! headless and desktop-style launches. +// Integration coverage for client app runtime startup paths. +// +// Scope: launch the real `lesavka-client` binary with runtime toggles that +// execute `LesavkaClientApp::run` startup branches. +// Targets: `client/src/app.rs`. +// Why: process-level startup behavior should stay deterministic in both +// headless and desktop-style launches. use serial_test::serial; use std::path::{Path, PathBuf}; diff --git a/testing/tests/client_camera_include_contract.rs b/tests/contract/client/input/camera/client_camera_include_contract.rs similarity index 92% rename from testing/tests/client_camera_include_contract.rs rename to tests/contract/client/input/camera/client_camera_include_contract.rs index 214faad..6f09c7d 100644 --- a/testing/tests/client_camera_include_contract.rs +++ b/tests/contract/client/input/camera/client_camera_include_contract.rs @@ -1,13 +1,16 @@ -//! Include-based coverage for camera capture configuration branches. -//! -//! Scope: include `client/src/input/camera.rs` and exercise encoder/source -//! selection helpers plus non-device fallbacks. -//! Targets: `client/src/input/camera.rs`. -//! Why: camera startup should remain robust across codec/env permutations. +// Include-based coverage for camera capture configuration branches. +// +// Scope: include `client/src/input/camera.rs` and exercise encoder/source +// selection helpers plus non-device fallbacks. +// Targets: `client/src/input/camera.rs`. +// Why: camera startup should remain robust across codec/env permutations. #[allow(warnings)] mod live_capture_clock { - include!("support/live_capture_clock_shim.rs"); + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/helpers/support/live_capture_clock_shim.rs" + )); } #[allow(warnings)] @@ -112,6 +115,28 @@ mod camera_include_contract { } } + #[test] + fn nvidia_and_open_source_hevc_routes_get_explicit_raw_caps() { + let pipeline_source = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/input/camera/capture_pipeline.rs" + )); + + for expected in [ + "\"nvh264enc\" | \"nvh265enc\" if have_nvvidconv", + "\"nvh264enc\" | \"nvh265enc\" /* else */", + "\"vaapih264enc\" | \"vah265enc\" | \"vaapih265enc\" | \"v4l2h265enc\"", + "video/x-raw(memory:NVMM),format=NV12", + "video/x-raw,format=NV12", + "video/x-raw,format=I420", + ] { + assert!( + pipeline_source.contains(expected), + "camera pipeline should keep driver-compatible caps fragment {expected}" + ); + } + } + #[test] #[cfg(coverage)] fn camera_bus_logger_coverage_stub_is_non_blocking() { diff --git a/testing/tests/client_camera_timing_contract.rs b/tests/contract/client/input/camera/client_camera_timing_contract.rs similarity index 74% rename from testing/tests/client_camera_timing_contract.rs rename to tests/contract/client/input/camera/client_camera_timing_contract.rs index 393d9fb..5216b1c 100644 --- a/testing/tests/client_camera_timing_contract.rs +++ b/tests/contract/client/input/camera/client_camera_timing_contract.rs @@ -1,14 +1,17 @@ -//! Include-based coverage for camera capture timing helpers. -//! -//! Scope: include `client/src/input/camera.rs` and exercise timestamp logging -//! branches without requiring a physical webcam. -//! Targets: `client/src/input/camera.rs`. -//! Why: upstream freshness diagnostics depend on stable first-packet and -//! periodic timing traces while the HEVC transport path is being tuned. +// Include-based coverage for camera capture timing helpers. +// +// Scope: include `client/src/input/camera.rs` and exercise timestamp logging +// branches without requiring a physical webcam. +// Targets: `client/src/input/camera.rs`. +// Why: upstream freshness diagnostics depend on stable first-packet and +// periodic timing traces while the HEVC transport path is being tuned. #[allow(warnings)] mod live_capture_clock { - include!("support/live_capture_clock_shim.rs"); + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/helpers/support/live_capture_clock_shim.rs" + )); } #[allow(warnings)] diff --git a/testing/tests/client_inputs_contract.rs b/tests/contract/client/input/inputs/client_inputs_contract.rs similarity index 97% rename from testing/tests/client_inputs_contract.rs rename to tests/contract/client/input/inputs/client_inputs_contract.rs index 2f1b39c..2a73ccf 100644 --- a/testing/tests/client_inputs_contract.rs +++ b/tests/contract/client/input/inputs/client_inputs_contract.rs @@ -1,10 +1,10 @@ -//! Integration coverage for client input-device classification helpers. -//! -//! Scope: include the input aggregator source and exercise private device -//! classification against synthetic uinput keyboard/mouse devices. -//! Targets: `client/src/input/inputs.rs`. -//! Why: device classification regressions can silently break all input capture -//! at runtime, so classifier behavior should stay under contract. +// Integration coverage for client input-device classification helpers. +// +// Scope: include the input aggregator source and exercise private device +// classification against synthetic uinput keyboard/mouse devices. +// Targets: `client/src/input/inputs.rs`. +// Why: device classification regressions can silently break all input capture +// at runtime, so classifier behavior should stay under contract. mod layout { pub use lesavka_client::layout::*; diff --git a/testing/tests/client_inputs_extra_contract.rs b/tests/contract/client/input/inputs/client_inputs_extra_contract.rs similarity index 97% rename from testing/tests/client_inputs_extra_contract.rs rename to tests/contract/client/input/inputs/client_inputs_extra_contract.rs index 5a8c6c1..10c3f50 100644 --- a/testing/tests/client_inputs_extra_contract.rs +++ b/tests/contract/client/input/inputs/client_inputs_extra_contract.rs @@ -1,10 +1,10 @@ -//! Extra include-based coverage for input aggregator edge cases. -//! -//! Scope: keep additional quick-toggle regression checks in a separate file so -//! each testing module stays under the 500 LOC contract. -//! Targets: `client/src/input/inputs.rs`. -//! Why: quick swap-key taps can otherwise disappear inside one poll cycle and -//! make local/remote handoff feel flaky in the live launcher path. +// Extra include-based coverage for input aggregator edge cases. +// +// Scope: keep additional quick-toggle regression checks in a separate file so +// each test module stays under the 500 LOC contract. +// Targets: `client/src/input/inputs.rs`. +// Why: quick swap-key taps can otherwise disappear inside one poll cycle and +// make local/remote handoff feel flaky in the live launcher path. mod layout { pub use lesavka_client::layout::*; diff --git a/testing/tests/client_inputs_routing_contract.rs b/tests/contract/client/input/inputs/client_inputs_routing_contract.rs similarity index 97% rename from testing/tests/client_inputs_routing_contract.rs rename to tests/contract/client/input/inputs/client_inputs_routing_contract.rs index bb50c8f..8c7dfdd 100644 --- a/testing/tests/client_inputs_routing_contract.rs +++ b/tests/contract/client/input/inputs/client_inputs_routing_contract.rs @@ -1,10 +1,10 @@ -//! 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. +// 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::*; diff --git a/testing/tests/client_keyboard_clipboard_contract.rs b/tests/contract/client/input/keyboard/client_keyboard_clipboard_contract.rs similarity index 90% rename from testing/tests/client_keyboard_clipboard_contract.rs rename to tests/contract/client/input/keyboard/client_keyboard_clipboard_contract.rs index edbef9d..202abaa 100644 --- a/testing/tests/client_keyboard_clipboard_contract.rs +++ b/tests/contract/client/input/keyboard/client_keyboard_clipboard_contract.rs @@ -1,10 +1,10 @@ -//! Focused clipboard-read coverage for the client keyboard helper surface. -//! -//! Scope: isolate clipboard command and fallback reader behavior so the -//! keyboard extra contract stays below the hygiene size cap. -//! Targets: `client/src/input/keyboard.rs`. -//! Why: clipboard sourcing must stay predictable across operator overrides, -//! fallback tools, and coverage-mode shells. +// Focused clipboard-read coverage for the client keyboard helper surface. +// +// Scope: isolate clipboard command and fallback reader behavior so the +// keyboard extra contract stays below the hygiene size cap. +// Targets: `client/src/input/keyboard.rs`. +// Why: clipboard sourcing must stay predictable across operator overrides, +// fallback tools, and coverage-mode shells. mod keymap { pub use lesavka_client::input::keymap::*; diff --git a/testing/tests/client_keyboard_include_contract.rs b/tests/contract/client/input/keyboard/client_keyboard_include_contract.rs similarity index 96% rename from testing/tests/client_keyboard_include_contract.rs rename to tests/contract/client/input/keyboard/client_keyboard_include_contract.rs index 4c30be3..34f5925 100644 --- a/testing/tests/client_keyboard_include_contract.rs +++ b/tests/contract/client/input/keyboard/client_keyboard_include_contract.rs @@ -1,10 +1,10 @@ -//! Integration coverage for client keyboard aggregator internals. -//! -//! Scope: include keyboard input source and validate report shaping, magic -//! chords, paste handling, and event processing against synthetic keyboards. -//! Targets: `client/src/input/keyboard.rs`. -//! Why: keyboard chord and paste logic is stateful and needs direct branch -//! coverage to avoid regressions. +// Integration coverage for client keyboard aggregator internals. +// +// Scope: include keyboard input source and validate report shaping, magic +// chords, paste handling, and event processing against synthetic keyboards. +// Targets: `client/src/input/keyboard.rs`. +// Why: keyboard chord and paste logic is stateful and needs direct branch +// coverage to avoid regressions. mod keymap { pub use lesavka_client::input::keymap::*; diff --git a/testing/tests/client_keyboard_include_extra_contract.rs b/tests/contract/client/input/keyboard/client_keyboard_include_extra_contract.rs similarity index 97% rename from testing/tests/client_keyboard_include_extra_contract.rs rename to tests/contract/client/input/keyboard/client_keyboard_include_extra_contract.rs index abf7bd4..749b871 100644 --- a/testing/tests/client_keyboard_include_extra_contract.rs +++ b/tests/contract/client/input/keyboard/client_keyboard_include_extra_contract.rs @@ -1,11 +1,11 @@ -//! Extra integration coverage for client keyboard aggregator helpers. -//! -//! Scope: include keyboard input source and cover reset-state, clipboard -//! fallback, send-toggle, and auxiliary paste branches without blowing the -//! primary keyboard contract past the 500 LOC cap. -//! Targets: `client/src/input/keyboard.rs`. -//! Why: keyboard helper branches are numerous enough to merit a paired -//! contract file for hygiene-gate modularity. +// Extra integration coverage for client keyboard aggregator helpers. +// +// Scope: include keyboard input source and cover reset-state, clipboard +// fallback, send-toggle, and auxiliary paste branches without blowing the +// primary keyboard contract past the 500 LOC cap. +// Targets: `client/src/input/keyboard.rs`. +// Why: keyboard helper branches are numerous enough to merit a paired +// contract file for hygiene-gate modularity. mod keymap { pub use lesavka_client::input::keymap::*; diff --git a/testing/tests/client_keyboard_process_contract.rs b/tests/contract/client/input/keyboard/client_keyboard_process_contract.rs similarity index 92% rename from testing/tests/client_keyboard_process_contract.rs rename to tests/contract/client/input/keyboard/client_keyboard_process_contract.rs index 388a9e4..9e8b397 100644 --- a/testing/tests/client_keyboard_process_contract.rs +++ b/tests/contract/client/input/keyboard/client_keyboard_process_contract.rs @@ -1,9 +1,9 @@ -//! Keyboard event-processing coverage for swallowed and live-report paths. -//! -//! Scope: include keyboard aggregation and drive synthetic evdev updates through -//! `process_events`/`drain_key_updates`. -//! Targets: `client/src/input/keyboard.rs`. -//! Why: paste chords must be swallowed cleanly while normal keys keep flowing. +// Keyboard event-processing coverage for swallowed and live-report paths. +// +// Scope: include keyboard aggregation and drive synthetic evdev updates through +// `process_events`/`drain_key_updates`. +// Targets: `client/src/input/keyboard.rs`. +// Why: paste chords must be swallowed cleanly while normal keys keep flowing. mod keymap { pub use lesavka_client::input::keymap::*; diff --git a/testing/tests/client_keyboard_shift_contract.rs b/tests/contract/client/input/keyboard/client_keyboard_shift_contract.rs similarity index 95% rename from testing/tests/client_keyboard_shift_contract.rs rename to tests/contract/client/input/keyboard/client_keyboard_shift_contract.rs index bb58bab..8899e9a 100644 --- a/testing/tests/client_keyboard_shift_contract.rs +++ b/tests/contract/client/input/keyboard/client_keyboard_shift_contract.rs @@ -1,10 +1,10 @@ -//! Focused coverage for shifted live-key emission. -//! -//! Scope: verify the keyboard aggregator stages modifier state before shifted -//! printable keys so firmware and bootloaders do not miss the modifier bit. -//! Targets: `client/src/input/keyboard.rs`. -//! Why: modifier chords and overlapping presses must remain trustworthy under -//! real evdev timing so remote typing stays usable. +// Focused coverage for shifted live-key emission. +// +// Scope: verify the keyboard aggregator stages modifier state before shifted +// printable keys so firmware and bootloaders do not miss the modifier bit. +// Targets: `client/src/input/keyboard.rs`. +// Why: modifier chords and overlapping presses must remain trustworthy under +// real evdev timing so remote typing stays usable. mod keymap { pub use lesavka_client::input::keymap::*; diff --git a/testing/tests/client_keymap_contract.rs b/tests/contract/client/input/keyboard/client_keymap_contract.rs similarity index 91% rename from testing/tests/client_keymap_contract.rs rename to tests/contract/client/input/keyboard/client_keymap_contract.rs index 57931a2..d5e389c 100644 --- a/testing/tests/client_keymap_contract.rs +++ b/tests/contract/client/input/keyboard/client_keymap_contract.rs @@ -1,10 +1,10 @@ -//! Integration coverage for the client HID keymap contract. -//! -//! Scope: exercise the public keycode and modifier helpers through the -//! top-level testing crate instead of package-local integration tests. -//! Targets: `client/src/input/keymap.rs` and its shared-character delegation. -//! Why: the full keyboard map is large enough that one dedicated contract test -//! is clearer than scattering smoke coverage across unrelated crates. +// Integration coverage for the client HID keymap contract. +// +// Scope: exercise the public keycode and modifier helpers through the +// root test harness crate instead of package-local integration tests. +// Targets: `client/src/input/keymap.rs` and its shared-character delegation. +// Why: the full keyboard map is large enough that one dedicated contract test +// is clearer than scattering smoke coverage across unrelated crates. use evdev::KeyCode; use lesavka_client::input::keymap::{char_to_usage, is_modifier, keycode_to_usage}; diff --git a/testing/tests/client_microphone_gain_control_contract.rs b/tests/contract/client/input/microphone/client_microphone_gain_control_contract.rs similarity index 93% rename from testing/tests/client_microphone_gain_control_contract.rs rename to tests/contract/client/input/microphone/client_microphone_gain_control_contract.rs index a876159..64de681 100644 --- a/testing/tests/client_microphone_gain_control_contract.rs +++ b/tests/contract/client/input/microphone/client_microphone_gain_control_contract.rs @@ -1,14 +1,24 @@ -//! Contract tests for microphone gain control and level taps. -//! -//! Scope: include `client/src/input/microphone.rs` and exercise live gain, -//! level telemetry, and shared-clock packet extraction helpers. -//! Targets: `client/src/input/microphone.rs`. -//! Why: microphone tuning is part of upstream transport quality, so gain and -//! tap behavior must remain deterministic without a live microphone. +// Contract tests for microphone gain control and level taps. +// +// Scope: include `client/src/input/microphone.rs` and exercise live gain, +// level telemetry, and shared-clock packet extraction helpers. +// Targets: `client/src/input/microphone.rs`. +// Why: microphone tuning is part of upstream transport quality, so gain and +// tap behavior must remain deterministic without a live microphone. #[allow(warnings)] mod live_capture_clock { - include!("support/live_capture_clock_shim.rs"); + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/helpers/support/live_capture_clock_shim.rs" + )); +} + +#[allow(warnings)] +mod input { + pub mod audio_codec { + pub use lesavka_client::input::audio_codec::*; + } } #[allow(warnings)] @@ -153,6 +163,7 @@ mod microphone_include_contract { level_tap_running: Some(std::sync::Arc::clone(&running)), pts_rebaser: crate::live_capture_clock::DurationPacedSourcePtsRebaser::default(), pending_packets: Default::default(), + audio_encoder: Default::default(), }; assert!( cap.pull().is_none(), @@ -294,6 +305,7 @@ mod microphone_include_contract { level_tap_running: None, pts_rebaser: crate::live_capture_clock::DurationPacedSourcePtsRebaser::default(), pending_packets: Default::default(), + audio_encoder: Default::default(), }; let first_pkt = cap.pull().expect("first audio packet"); let second_pkt = cap.pull().expect("second audio packet from pending split"); diff --git a/testing/tests/client_microphone_include_contract.rs b/tests/contract/client/input/microphone/client_microphone_include_contract.rs similarity index 87% rename from testing/tests/client_microphone_include_contract.rs rename to tests/contract/client/input/microphone/client_microphone_include_contract.rs index 3b4d4fc..65f6261 100644 --- a/testing/tests/client_microphone_include_contract.rs +++ b/tests/contract/client/input/microphone/client_microphone_include_contract.rs @@ -1,13 +1,23 @@ -//! Include-based coverage for microphone source-selection helpers. -//! -//! Scope: include `client/src/input/microphone.rs` and exercise Pulse source -//! parsing + fallback behavior without requiring a live audio stack. -//! Targets: `client/src/input/microphone.rs`. -//! Why: source selection regressions should be caught with deterministic tests. +// Include-based coverage for microphone source-selection helpers. +// +// Scope: include `client/src/input/microphone.rs` and exercise Pulse source +// parsing + fallback behavior without requiring a live audio stack. +// Targets: `client/src/input/microphone.rs`. +// Why: source selection regressions should be caught with deterministic tests. #[allow(warnings)] mod live_capture_clock { - include!("support/live_capture_clock_shim.rs"); + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/helpers/support/live_capture_clock_shim.rs" + )); +} + +#[allow(warnings)] +mod input { + pub mod audio_codec { + pub use lesavka_client::input::audio_codec::*; + } } #[allow(warnings)] @@ -219,7 +229,7 @@ JSON #[test] fn microphone_pipeline_desc_adds_level_tap_only_when_requested() { - let with_tap = microphone_pipeline_desc("audiotestsrc is-live=true", 2.5, true); + let with_tap = microphone_pipeline_desc("audiotestsrc is-live=true", 2.5, true, false); assert!( with_tap .contains("audiotestsrc is-live=true ! audioconvert ! audioresample ! audio/x-raw") @@ -229,7 +239,7 @@ JSON assert!(with_tap.contains("appsink name=level_sink")); assert!(with_tap.contains("volume name=mic_input_gain volume=2.500")); - let without_tap = microphone_pipeline_desc("audiotestsrc is-live=true", 1.0, false); + let without_tap = microphone_pipeline_desc("audiotestsrc is-live=true", 1.0, false, false); assert!(!without_tap.contains("level_sink")); assert!( without_tap @@ -237,4 +247,19 @@ JSON ); assert!(without_tap.contains("appsink name=asink emit-signals=true max-buffers=8")); } + + #[test] + fn microphone_pipeline_desc_can_insert_noise_suppression_stage() { + let _ = gst::init(); + let raw = microphone_pipeline_desc("audiotestsrc is-live=true", 1.0, false, false); + let suppressed = microphone_pipeline_desc("audiotestsrc is-live=true", 1.0, false, true); + + assert!(!raw.contains("webrtcdsp")); + if gst::ElementFactory::find("webrtcdsp").is_some() { + assert!(suppressed.contains("webrtcdsp")); + assert!(suppressed.contains("noise-suppression=true")); + } else { + assert_eq!(raw, suppressed); + } + } } diff --git a/testing/tests/client_microphone_requested_source_contract.rs b/tests/contract/client/input/microphone/client_microphone_requested_source_contract.rs similarity index 87% rename from testing/tests/client_microphone_requested_source_contract.rs rename to tests/contract/client/input/microphone/client_microphone_requested_source_contract.rs index 527bba0..f47870c 100644 --- a/testing/tests/client_microphone_requested_source_contract.rs +++ b/tests/contract/client/input/microphone/client_microphone_requested_source_contract.rs @@ -1,14 +1,24 @@ -//! Contract tests for requested microphone source selection. -//! -//! Scope: include `client/src/input/microphone.rs` and exercise requested-source -//! success, fallback, and strict-probe failure behavior. -//! Targets: `client/src/input/microphone.rs`. -//! Why: transport probes should fail clearly when the named microphone is absent -//! instead of silently measuring the wrong host source. +// Contract tests for requested microphone source selection. +// +// Scope: include `client/src/input/microphone.rs` and exercise requested-source +// success, fallback, and strict-probe failure behavior. +// Targets: `client/src/input/microphone.rs`. +// Why: transport probes should fail clearly when the named microphone is absent +// instead of silently measuring the wrong host source. #[allow(warnings)] mod live_capture_clock { - include!("support/live_capture_clock_shim.rs"); + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/helpers/support/live_capture_clock_shim.rs" + )); +} + +#[allow(warnings)] +mod input { + pub mod audio_codec { + pub use lesavka_client::input::audio_codec::*; + } } #[allow(warnings)] diff --git a/testing/tests/client_microphone_source_contract.rs b/tests/contract/client/input/microphone/client_microphone_source_contract.rs similarity index 84% rename from testing/tests/client_microphone_source_contract.rs rename to tests/contract/client/input/microphone/client_microphone_source_contract.rs index 3aa195d..dc605ab 100644 --- a/testing/tests/client_microphone_source_contract.rs +++ b/tests/contract/client/input/microphone/client_microphone_source_contract.rs @@ -1,14 +1,24 @@ -//! Include-based coverage for microphone source-name routing. -//! -//! Scope: include `client/src/input/microphone.rs` and cover selected-source -//! routing heuristics for launcher catalog names. -//! Targets: `client/src/input/microphone.rs`. -//! Why: launcher-selected Pulse source names must not regress to PipeWire -//! negotiation when the catalog already provides a concrete Pulse device name. +// Include-based coverage for microphone source-name routing. +// +// Scope: include `client/src/input/microphone.rs` and cover selected-source +// routing heuristics for launcher catalog names. +// Targets: `client/src/input/microphone.rs`. +// Why: launcher-selected Pulse source names must not regress to PipeWire +// negotiation when the catalog already provides a concrete Pulse device name. #[allow(warnings)] mod live_capture_clock { - include!("support/live_capture_clock_shim.rs"); + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/helpers/support/live_capture_clock_shim.rs" + )); +} + +#[allow(warnings)] +mod input { + pub mod audio_codec { + pub use lesavka_client::input::audio_codec::*; + } } #[allow(warnings)] diff --git a/testing/tests/client_microphone_startup_contract.rs b/tests/contract/client/input/microphone/client_microphone_startup_contract.rs similarity index 56% rename from testing/tests/client_microphone_startup_contract.rs rename to tests/contract/client/input/microphone/client_microphone_startup_contract.rs index 881f904..d0902c0 100644 --- a/testing/tests/client_microphone_startup_contract.rs +++ b/tests/contract/client/input/microphone/client_microphone_startup_contract.rs @@ -1,14 +1,24 @@ -//! Include-based coverage for microphone startup cleanup paths. -//! -//! Scope: include `client/src/input/microphone.rs` and exercise startup -//! failures without requiring a live microphone. -//! Targets: `client/src/input/microphone.rs`. -//! Why: startup failures should move the pipeline back to NULL before the -//! capture object returns an error. +// Include-based coverage for microphone startup cleanup paths. +// +// Scope: include `client/src/input/microphone.rs` and exercise startup +// failures without requiring a live microphone. +// Targets: `client/src/input/microphone.rs`. +// Why: startup failures should move the pipeline back to NULL before the +// capture object returns an error. #[allow(warnings)] mod live_capture_clock { - include!("support/live_capture_clock_shim.rs"); + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/helpers/support/live_capture_clock_shim.rs" + )); +} + +#[allow(warnings)] +mod input { + pub mod audio_codec { + pub use lesavka_client::input::audio_codec::*; + } } #[allow(warnings)] diff --git a/testing/tests/client_microphone_tap_contract.rs b/tests/contract/client/input/microphone/client_microphone_tap_contract.rs similarity index 71% rename from testing/tests/client_microphone_tap_contract.rs rename to tests/contract/client/input/microphone/client_microphone_tap_contract.rs index 172e692..02db2fb 100644 --- a/testing/tests/client_microphone_tap_contract.rs +++ b/tests/contract/client/input/microphone/client_microphone_tap_contract.rs @@ -1,14 +1,24 @@ -//! Include-based coverage for microphone level-tap publishing. -//! -//! Scope: include `client/src/input/microphone.rs` and exercise level-tap -//! publishing behavior without requiring a live microphone. -//! Targets: `client/src/input/microphone.rs`. -//! Why: the local launcher tap should stay best-effort and never destabilize -//! microphone uplink startup. +// Include-based coverage for microphone level-tap publishing. +// +// Scope: include `client/src/input/microphone.rs` and exercise level-tap +// publishing behavior without requiring a live microphone. +// Targets: `client/src/input/microphone.rs`. +// Why: the local launcher tap should stay best-effort and never destabilize +// microphone uplink startup. #[allow(warnings)] mod live_capture_clock { - include!("support/live_capture_clock_shim.rs"); + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/helpers/support/live_capture_clock_shim.rs" + )); +} + +#[allow(warnings)] +mod input { + pub mod audio_codec { + pub use lesavka_client::input::audio_codec::*; + } } #[allow(warnings)] diff --git a/testing/tests/client_mouse_include_contract.rs b/tests/contract/client/input/mouse/client_mouse_include_contract.rs similarity index 97% rename from testing/tests/client_mouse_include_contract.rs rename to tests/contract/client/input/mouse/client_mouse_include_contract.rs index 5d98c59..ee4e1e7 100644 --- a/testing/tests/client_mouse_include_contract.rs +++ b/tests/contract/client/input/mouse/client_mouse_include_contract.rs @@ -1,10 +1,10 @@ -//! Integration coverage for client mouse aggregator internals. -//! -//! Scope: include mouse input source and validate relative/absolute event -//! handling, flush behavior, and threshold helpers. -//! Targets: `client/src/input/mouse.rs`. -//! Why: mouse state transitions are regress-prone and must remain deterministic -//! under synthetic device traffic. +// Integration coverage for client mouse aggregator internals. +// +// Scope: include mouse input source and validate relative/absolute event +// handling, flush behavior, and threshold helpers. +// Targets: `client/src/input/mouse.rs`. +// Why: mouse state transitions are regress-prone and must remain deterministic +// under synthetic device traffic. #[allow(warnings)] mod mouse_contract { diff --git a/testing/tests/client_mouse_include_extra_contract.rs b/tests/contract/client/input/mouse/client_mouse_include_extra_contract.rs similarity index 95% rename from testing/tests/client_mouse_include_extra_contract.rs rename to tests/contract/client/input/mouse/client_mouse_include_extra_contract.rs index 2fe3855..4bc8952 100644 --- a/testing/tests/client_mouse_include_extra_contract.rs +++ b/tests/contract/client/input/mouse/client_mouse_include_extra_contract.rs @@ -1,9 +1,9 @@ -//! Extra include-based coverage for mouse aggregator branches. -//! -//! Scope: keep additional branch assertions in a separate file so each testing -//! module stays under the 500 LOC contract. -//! Targets: `client/src/input/mouse.rs`. -//! Why: keep branch coverage growing without violating testing module size contracts. +// Extra include-based coverage for mouse aggregator branches. +// +// Scope: keep additional branch assertions in a separate file so each test +// module stays under the 500 LOC contract. +// Targets: `client/src/input/mouse.rs`. +// Why: keep branch coverage growing without violating test module size contracts. #[allow(warnings)] mod mouse_contract_extra { diff --git a/testing/tests/client_mouse_uinput_contract.rs b/tests/contract/client/input/mouse/client_mouse_uinput_contract.rs similarity index 93% rename from testing/tests/client_mouse_uinput_contract.rs rename to tests/contract/client/input/mouse/client_mouse_uinput_contract.rs index 9a60d95..85b7589 100644 --- a/testing/tests/client_mouse_uinput_contract.rs +++ b/tests/contract/client/input/mouse/client_mouse_uinput_contract.rs @@ -1,10 +1,10 @@ -//! Integration coverage for the client mouse input contract using uinput. -//! -//! Scope: exercise `MouseAggregator` with synthetic relative and absolute input -//! devices so event translation stays deterministic. -//! Targets: `client/src/input/mouse.rs`. -//! Why: mouse handling is event-rich and high-risk for regressions without -//! end-to-end event-path tests. +// Integration coverage for the client mouse input contract using uinput. +// +// Scope: exercise `MouseAggregator` with synthetic relative and absolute input +// devices so event translation stays deterministic. +// Targets: `client/src/input/mouse.rs`. +// Why: mouse handling is event-rich and high-risk for regressions without +// end-to-end event-path tests. use evdev::uinput::VirtualDevice; use evdev::{ diff --git a/testing/tests/client_main_binary_contract.rs b/tests/contract/client/main/client_main_binary_contract.rs similarity index 85% rename from testing/tests/client_main_binary_contract.rs rename to tests/contract/client/main/client_main_binary_contract.rs index 31bac6c..6157f64 100644 --- a/testing/tests/client_main_binary_contract.rs +++ b/tests/contract/client/main/client_main_binary_contract.rs @@ -1,10 +1,10 @@ -//! Integration coverage for client binary startup wiring. -//! -//! Scope: include `client/src/main.rs` and exercise startup guards/tracing -//! branches without launching the full runtime app loop. -//! Targets: `client/src/main.rs`. -//! Why: keep client startup behavior deterministic and directly attributed to -//! the binary entrypoint source file. +// Integration coverage for client binary startup wiring. +// +// Scope: include `client/src/main.rs` and exercise startup guards/tracing +// branches without launching the full runtime app loop. +// Targets: `client/src/main.rs`. +// Why: keep client startup behavior deterministic and directly attributed to +// the binary entrypoint source file. #[allow(clippy::suspicious_open_options)] mod client_main_binary { diff --git a/tests/contract/client/main/client_main_process_contract.rs b/tests/contract/client/main/client_main_process_contract.rs new file mode 100644 index 0000000..fa80bfc --- /dev/null +++ b/tests/contract/client/main/client_main_process_contract.rs @@ -0,0 +1,77 @@ +// Integration coverage for client process startup behavior. +// +// Scope: launch the real `lesavka-client` binary and validate guarded startup +// behavior when desktop runtime prerequisites are missing. +// Targets: `client/src/main.rs`. +// Why: process-level startup failures should stay deterministic and +// user-readable. + +use serial_test::serial; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn candidate_dirs() -> Vec { + let exe = std::env::current_exe().expect("current exe path"); + let mut dirs = Vec::new(); + if let Some(parent) = exe.parent() { + dirs.push(parent.to_path_buf()); + if let Some(grand) = parent.parent() { + dirs.push(grand.to_path_buf()); + } + } + dirs.push(PathBuf::from("target/debug")); + dirs.push(PathBuf::from("target/llvm-cov-target/debug")); + dirs +} + +fn find_binary(name: &str) -> Option { + candidate_dirs() + .into_iter() + .map(|dir| dir.join(name)) + .find(|path| path.exists() && path.is_file()) +} + +#[test] +#[serial] +fn client_binary_fails_fast_without_runtime_dir() { + let Some(bin) = find_binary("lesavka-client") else { + return; + }; + + let status = Command::new(Path::new(&bin)) + .env_remove("XDG_RUNTIME_DIR") + .env_remove("LESAVKA_HEADLESS") + .status() + .expect("spawn lesavka-client"); + + assert!(!status.success(), "startup should fail without runtime dir"); +} + +#[test] +#[serial] +fn client_binary_version_does_not_start_gtk_or_require_desktop_runtime() { + let Some(bin) = find_binary("lesavka-client") else { + return; + }; + + let output = Command::new(Path::new(&bin)) + .arg("--version") + .env_remove("XDG_RUNTIME_DIR") + .env_remove("LESAVKA_HEADLESS") + .output() + .expect("spawn lesavka-client --version"); + + assert!( + output.status.success(), + "--version should be safe in installer and terminal checks" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.starts_with("lesavka-client "), + "version output should identify the installed client binary, got {stdout:?}" + ); + assert!( + output.stderr.is_empty(), + "--version should not initialize GTK/GDK and emit desktop warnings" + ); +} diff --git a/testing/tests/client_output_audio_include_contract.rs b/tests/contract/client/output/audio/client_output_audio_include_contract.rs similarity index 97% rename from testing/tests/client_output_audio_include_contract.rs rename to tests/contract/client/output/audio/client_output_audio_include_contract.rs index 5adb7fe..d4c2cc5 100644 --- a/testing/tests/client_output_audio_include_contract.rs +++ b/tests/contract/client/output/audio/client_output_audio_include_contract.rs @@ -1,10 +1,10 @@ -//! Include-based coverage for client audio output sink selection helpers. -//! -//! Scope: include `client/src/output/audio.rs` and exercise sink discovery -//! branches with controlled `pactl` fixtures. -//! Targets: `client/src/output/audio.rs`. -//! Why: keep sink-resolution behavior deterministic without requiring live -//! desktop audio devices in CI. +// Include-based coverage for client audio output sink selection helpers. +// +// Scope: include `client/src/output/audio.rs` and exercise sink discovery +// branches with controlled `pactl` fixtures. +// Targets: `client/src/output/audio.rs`. +// Why: keep sink-resolution behavior deterministic without requiring live +// desktop audio devices in CI. #[allow(warnings)] mod audio_include_contract { diff --git a/testing/tests/client_output_display_include_contract.rs b/tests/contract/client/output/display/client_output_display_include_contract.rs similarity index 94% rename from testing/tests/client_output_display_include_contract.rs rename to tests/contract/client/output/display/client_output_display_include_contract.rs index dd8c1f9..71be2fc 100644 --- a/testing/tests/client_output_display_include_contract.rs +++ b/tests/contract/client/output/display/client_output_display_include_contract.rs @@ -1,10 +1,10 @@ -//! Include-based coverage for client monitor enumeration logic. -//! -//! Scope: include `client/src/output/display.rs` with deterministic GTK/GDK -//! stubs to exercise sorting and filtering branches. -//! Targets: `client/src/output/display.rs`. -//! Why: monitor-layout selection should remain stable even when CI has no real -//! display server attached. +// Include-based coverage for client monitor enumeration logic. +// +// Scope: include `client/src/output/display.rs` with deterministic GTK/GDK +// stubs to exercise sorting and filtering branches. +// Targets: `client/src/output/display.rs`. +// Why: monitor-layout selection should remain stable even when CI has no real +// display server attached. #[allow(dead_code)] mod gtk { diff --git a/testing/tests/client_output_video_include_contract.rs b/tests/contract/client/output/video/client_output_video_include_contract.rs similarity index 84% rename from testing/tests/client_output_video_include_contract.rs rename to tests/contract/client/output/video/client_output_video_include_contract.rs index 62a149b..72740c1 100644 --- a/testing/tests/client_output_video_include_contract.rs +++ b/tests/contract/client/output/video/client_output_video_include_contract.rs @@ -1,11 +1,10 @@ -//! Include-based coverage for client video output window plumbing. -//! -//! Scope: include `client/src/output/video.rs` with deterministic display/layout -//! stubs and exercise backend/placement branches without a real desktop session. -//! Targets: `client/src/output/video.rs`. -//! Why: monitor window orchestration contains branch-heavy environment logic that -//! should remain stable in CI. -#![allow(dead_code)] +// Include-based coverage for client video output window plumbing. +// +// Scope: include `client/src/output/video.rs` with deterministic display/layout +// stubs and exercise backend/placement branches without a real desktop session. +// Targets: `client/src/output/video.rs`. +// Why: monitor window orchestration contains branch-heavy environment logic that +// should remain stable in CI. mod output { pub mod display { @@ -129,6 +128,39 @@ mod video_include_contract { assert!(!buildable_decoder("definitely-not-a-real-gst-element")); } + #[test] + #[serial] + fn h264_decoder_selection_prefers_hardware_but_keeps_software_route() { + with_var("LESAVKA_H264_DECODER_PREFERENCE", None::<&str>, || { + let order = h264_decoder_preference_order(); + assert_eq!(order.first(), Some(&"nvh264dec")); + assert!(order.contains(&"nvh264sldec")); + assert!(order.contains(&"vah264dec")); + assert!(order.contains(&"vaapih264dec")); + assert!(order.contains(&"v4l2h264dec")); + assert!(order.contains(&"v4l2slh264dec")); + assert!( + order + .iter() + .position(|name| *name == "v4l2h264dec") + .unwrap() + < order.iter().position(|name| *name == "avdec_h264").unwrap() + ); + }); + + with_var("LESAVKA_H264_DECODER_PREFERENCE", Some("cpu"), || { + let order = h264_decoder_preference_order(); + assert_eq!(order.first(), Some(&"avdec_h264")); + assert!( + order + .iter() + .position(|name| *name == "openh264dec") + .unwrap() + < order.iter().position(|name| *name == "nvh264dec").unwrap() + ); + }); + } + #[test] #[serial] fn monitor_window_new_covers_x11_backend_path() { diff --git a/testing/tests/client_paste_contract.rs b/tests/contract/client/paste/client_paste_contract.rs similarity index 93% rename from testing/tests/client_paste_contract.rs rename to tests/contract/client/paste/client_paste_contract.rs index 82c41db..fbdd0fc 100644 --- a/testing/tests/client_paste_contract.rs +++ b/tests/contract/client/paste/client_paste_contract.rs @@ -1,10 +1,10 @@ -//! Integration coverage for the client paste-request contract. -//! -//! Scope: validate key loading, encryption metadata, and truncation behavior -//! through the public paste helper. -//! Targets: `client/src/paste.rs`. -//! Why: these checks are pure policy and crypto framing, so they belong in the -//! centralized `testing/tests` contract suite. +// Integration coverage for the client paste-request contract. +// +// Scope: validate key loading, encryption metadata, and truncation behavior +// through the public paste helper. +// Targets: `client/src/paste.rs`. +// Why: these checks are pure policy and crypto framing, so they belong in the +// centralized `tests` contract suite. use chacha20poly1305::aead::Aead; use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce}; diff --git a/testing/tests/client_browser_sync_script_contract.rs b/tests/contract/client/sync_probe/client_browser_sync_script_contract.rs similarity index 89% rename from testing/tests/client_browser_sync_script_contract.rs rename to tests/contract/client/sync_probe/client_browser_sync_script_contract.rs index 719c440..9ed36db 100644 --- a/testing/tests/client_browser_sync_script_contract.rs +++ b/tests/contract/client/sync_probe/client_browser_sync_script_contract.rs @@ -1,23 +1,37 @@ -//! Contract tests for browser-backed upstream sync probes. -//! -//! Scope: statically guard the browser, mirrored, and local stimulus probe -//! drivers used for client-to-server transport tuning. -//! Targets: `scripts/manual/run_upstream_browser_av_sync.sh`, -//! `scripts/manual/run_upstream_mirrored_av_sync.sh`, -//! `scripts/manual/run_google_meet_observer_probe.sh`, and local stimulus code. -//! Why: after server-to-RCT calibration, these scripts become the next tuning -//! surface and must not drift back to synthetic or split upstream paths. +// Contract tests for browser-backed upstream sync probes. +// +// Scope: statically guard the browser, mirrored, and local stimulus probe +// drivers used for client-to-server transport tuning. +// Targets: `scripts/manual/run_upstream_browser_av_sync.sh`, +// `scripts/manual/run_upstream_mirrored_av_sync.sh`, +// `scripts/manual/run_google_meet_observer_probe.sh`, and local stimulus code. +// Why: after server-to-RCT calibration, these scripts become the next tuning +// surface and must not drift back to synthetic or split upstream paths. -const BROWSER_SYNC_SCRIPT: &str = - include_str!("../../scripts/manual/run_upstream_browser_av_sync.sh"); -const MIRRORED_SYNC_SCRIPT: &str = - include_str!("../../scripts/manual/run_upstream_mirrored_av_sync.sh"); -const GOOGLE_MEET_OBSERVER_SCRIPT: &str = - include_str!("../../scripts/manual/run_google_meet_observer_probe.sh"); -const LOCAL_STIMULUS: &str = include_str!("../../scripts/manual/local_av_stimulus.py"); -const SYNC_PROBE_RUNNER: &str = include_str!("../../client/src/sync_probe/runner.rs"); -const SYNC_PROBE_BUNDLED_TRANSPORT: &str = - include_str!("../../client/src/sync_probe/runner/bundled_transport.rs"); +const BROWSER_SYNC_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_upstream_browser_av_sync.sh" +)); +const MIRRORED_SYNC_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_upstream_mirrored_av_sync.sh" +)); +const GOOGLE_MEET_OBSERVER_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_google_meet_observer_probe.sh" +)); +const LOCAL_STIMULUS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/local_av_stimulus.py" +)); +const SYNC_PROBE_RUNNER: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/sync_probe/runner.rs" +)); +const SYNC_PROBE_BUNDLED_TRANSPORT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/sync_probe/runner/bundled_transport.rs" +)); #[test] fn browser_sync_script_can_delegate_to_a_real_path_driver() { @@ -289,7 +303,10 @@ fn local_stimulus_matches_sync_analyzer_pulse_contract() { #[test] fn manual_probe_python_servers_use_reentrant_state_locks() { - const BROWSER_CONSUMER: &str = include_str!("../../scripts/manual/browser_consumer_probe.py"); + const BROWSER_CONSUMER: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/browser_consumer_probe.py" + )); for (name, script) in [ ("local stimulus", LOCAL_STIMULUS), ("browser consumer", BROWSER_CONSUMER), diff --git a/testing/tests/client_hevc_bundle_audit_contract.rs b/tests/contract/client/sync_probe/client_hevc_bundle_audit_contract.rs similarity index 82% rename from testing/tests/client_hevc_bundle_audit_contract.rs rename to tests/contract/client/sync_probe/client_hevc_bundle_audit_contract.rs index e24548f..d64fcac 100644 --- a/testing/tests/client_hevc_bundle_audit_contract.rs +++ b/tests/contract/client/sync_probe/client_hevc_bundle_audit_contract.rs @@ -1,22 +1,32 @@ -//! Contracts for local HEVC client-bundle preflight and remote re-entry helpers. -//! -//! Scope: guard the passwordless scripts used while Theia is offline or just -//! recovering. Targets: `scripts/manual/run_local_hevc_bundle_audit.sh`, -//! `scripts/manual/run_hevc_remote_reentry_check.sh`, and -//! `scripts/manual/run_hevc_post_reboot_sequence.sh`. Why: these helpers should -//! make the HEVC migration repeatable without reintroducing sudo prompts, -//! split-stream probes, or undocumented artifact paths. +// Contracts for local HEVC client-bundle preflight and remote re-entry helpers. +// +// Scope: guard the passwordless scripts used while Theia is offline or just +// recovering. Targets: `scripts/manual/run_local_hevc_bundle_audit.sh`, +// `scripts/manual/run_hevc_remote_reentry_check.sh`, and +// `scripts/manual/run_hevc_post_reboot_sequence.sh`. Why: these helpers should +// make the HEVC migration repeatable without reintroducing sudo prompts, +// split-stream probes, or undocumented artifact paths. -const LOCAL_AUDIT_SCRIPT: &str = - include_str!("../../scripts/manual/run_local_hevc_bundle_audit.sh"); -const LOCAL_AUDIT_VALIDATOR: &str = - include_str!("../../scripts/manual/validate_local_hevc_bundle_audit.py"); -const LOCAL_ENCODER_PREFLIGHT_SCRIPT: &str = - include_str!("../../scripts/manual/run_local_hevc_encoder_preflight.sh"); -const REMOTE_REENTRY_SCRIPT: &str = - include_str!("../../scripts/manual/run_hevc_remote_reentry_check.sh"); -const POST_REBOOT_SCRIPT: &str = - include_str!("../../scripts/manual/run_hevc_post_reboot_sequence.sh"); +const LOCAL_AUDIT_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_local_hevc_bundle_audit.sh" +)); +const LOCAL_AUDIT_VALIDATOR: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/validate_local_hevc_bundle_audit.py" +)); +const LOCAL_ENCODER_PREFLIGHT_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_local_hevc_encoder_preflight.sh" +)); +const REMOTE_REENTRY_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_hevc_remote_reentry_check.sh" +)); +const POST_REBOOT_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_hevc_post_reboot_sequence.sh" +)); #[test] fn local_hevc_bundle_audit_is_passwordless_and_artifact_backed() { diff --git a/testing/tests/shared_hid_contract.rs b/tests/contract/common/hid/shared_hid_contract.rs similarity index 82% rename from testing/tests/shared_hid_contract.rs rename to tests/contract/common/hid/shared_hid_contract.rs index b5045e8..b2a9db6 100644 --- a/testing/tests/shared_hid_contract.rs +++ b/tests/contract/common/hid/shared_hid_contract.rs @@ -1,10 +1,10 @@ -//! Integration coverage for the shared printable-character HID contract. -//! -//! Scope: verify the shared character-to-HID table from the top-level testing -//! module so both client and server rely on the same canonical assertions. -//! Targets: `common/src/hid.rs`. -//! Why: this mapping is a cross-crate contract and should live in one obvious -//! place instead of being duplicated by package-local test suites. +// Integration coverage for the shared printable-character HID contract. +// +// Scope: verify the shared character-to-HID table from the root test +// module so both client and server rely on the same canonical assertions. +// Targets: `common/src/hid.rs`. +// Why: this mapping is a cross-crate contract and should live in one obvious +// place instead of being duplicated by package-local test suites. use lesavka_common::hid::char_to_usage; diff --git a/tests/contract/diagnostics/report_schema_contract.rs b/tests/contract/diagnostics/report_schema_contract.rs new file mode 100644 index 0000000..0161d44 --- /dev/null +++ b/tests/contract/diagnostics/report_schema_contract.rs @@ -0,0 +1,121 @@ +// Contracts for launcher diagnostics report completeness. +// +// Scope: inspect diagnostics report schema and text rendering. +// Targets: `client/src/launcher/diagnostics/*`. +// Why: when a remote call looks wrong, Copy Report should preserve enough +// routing, media, calibration, timing, and recommendation context to debug +// without spelunking live logs first. + +const DIAGNOSTICS_MODELS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/diagnostics/diagnostics_models.rs" +)); +const SNAPSHOT_REPORT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/diagnostics/snapshot_report.rs" +)); +const SNAPSHOT_REPORT_TEXT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/diagnostics/snapshot_report_text.rs" +)); +const RECOMMENDATIONS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/diagnostics/recommendations.rs" +)); + +#[test] +fn snapshot_report_schema_captures_runtime_media_and_calibration_context() { + for field in [ + "client_version", + "server_version", + "server_available", + "routing", + "view_mode", + "remote_active", + "power_state", + "left_capture_transport", + "right_capture_transport", + "upstream_camera_transport", + "selected_camera", + "selected_microphone", + "selected_speaker", + "upstream_camera", + "upstream_microphone", + "upstream_mode", + "av_delivery_skew_ms", + "av_enqueue_skew_ms", + "planner_live_lag_ms", + "planner_skew_ms", + "planner_stale_audio_drops", + "planner_stale_video_drops", + "planner_freshness_reanchors", + "planner_video_freezes", + "calibration_profile", + "calibration_source", + "active_audio_offset_us", + "active_video_offset_us", + "recent_samples", + "recommendations", + "probe_command", + ] { + assert!( + DIAGNOSTICS_MODELS.contains(field), + "SnapshotReport should expose diagnostic field {field}" + ); + } +} + +#[test] +fn report_builder_populates_schema_from_live_state_and_recent_samples() { + for marker in [ + "crate::VERSION.to_string()", + "state.server_version.clone()", + "state.webcam_transport.label().to_string()", + "state.upstream_sync.live_lag_ms", + "state.upstream_sync.planner_skew_ms", + "state.upstream_sync.stale_audio_drops", + "state.upstream_sync.stale_video_drops", + "state.upstream_sync.freshness_reanchors", + "state.upstream_sync.video_freezes", + "state.calibration.profile.clone()", + "state.calibration.source.clone()", + "state.calibration.active_audio_offset_us", + "state.calibration.active_video_offset_us", + "recent_samples: log.iter().cloned().collect()", + "recommendations_for(state, log)", + ] { + assert!( + SNAPSHOT_REPORT.contains(marker), + "SnapshotReport::from_state should populate {marker}" + ); + } +} + +#[test] +fn text_report_names_failure_stage_and_next_actions() { + for marker in [ + "Lesavka Diagnostics", + "media staging", + "av sync guardrails", + "server upstream planner", + "drops/heals", + "calibration", + "recent samples", + "uplink:", + "recommendations", + "quality probe", + ] { + assert!( + SNAPSHOT_REPORT_TEXT.contains(marker), + "diagnostic text should include operator section {marker}" + ); + } + assert!( + RECOMMENDATIONS.contains("recommendations_for") + && RECOMMENDATIONS.contains("server_available") + && RECOMMENDATIONS.contains("webcam uplink queue") + && RECOMMENDATIONS.contains("microphone uplink queue") + && RECOMMENDATIONS.contains("Device H.264 pass-through"), + "diagnostics should include actionable recommendations for likely failure layers" + ); +} diff --git a/testing/tests/server_core_script_contract.rs b/tests/contract/scripts/daemon/server_core_script_contract.rs similarity index 84% rename from testing/tests/server_core_script_contract.rs rename to tests/contract/scripts/daemon/server_core_script_contract.rs index 6edbed6..3cde82e 100644 --- a/testing/tests/server_core_script_contract.rs +++ b/tests/contract/scripts/daemon/server_core_script_contract.rs @@ -1,11 +1,14 @@ -//! Contract tests for lesavka-core gadget rebuild guardrails. -//! -//! Scope: statically guard the shell logic in `scripts/daemon/lesavka-core.sh`. -//! Targets: incomplete gadget recovery behavior. -//! Why: the gadget can be bound but half-applied, and the core script must -//! rebuild that state instead of refusing reset forever. +// Contract tests for lesavka-core gadget rebuild guardrails. +// +// Scope: statically guard the shell logic in `scripts/daemon/lesavka-core.sh`. +// Targets: incomplete gadget recovery behavior. +// Why: the gadget can be bound but half-applied, and the core script must +// rebuild that state instead of refusing reset forever. -const CORE_SCRIPT: &str = include_str!("../../scripts/daemon/lesavka-core.sh"); +const CORE_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/daemon/lesavka-core.sh" +)); #[test] fn core_script_rebuilds_incomplete_bound_gadgets() { diff --git a/testing/tests/server_audio_include_contract.rs b/tests/contract/server/audio/server_audio_include_contract.rs similarity index 86% rename from testing/tests/server_audio_include_contract.rs rename to tests/contract/server/audio/server_audio_include_contract.rs index 8086c46..ab881bb 100644 --- a/testing/tests/server_audio_include_contract.rs +++ b/tests/contract/server/audio/server_audio_include_contract.rs @@ -1,18 +1,18 @@ -//! Integration coverage for server audio capture/sink plumbing. -//! -//! Scope: compile the split server audio module and exercise public audio -//! constructors/helpers across deterministic error and smoke paths. -//! Targets: `server/src/audio.rs` plus `server/src/audio/*`. -//! Why: audio pipeline setup is branchy and should stay stable without requiring -//! physical ALSA/UAC hardware in CI. +// Integration coverage for server audio capture/sink plumbing. +// +// Scope: compile the split server audio module and exercise public audio +// constructors/helpers across deterministic error and smoke paths. +// Targets: `server/src/audio.rs` plus `server/src/audio/*`. +// Why: audio pipeline setup is branchy and should stay stable without requiring +// physical ALSA/UAC hardware in CI. pub use lesavka_server::camera; -#[path = "../../server/src/media_timing.rs"] +#[path = "../../../../server/src/media_timing.rs"] #[allow(warnings)] mod media_timing; -#[path = "../../server/src/audio.rs"] +#[path = "../../../../server/src/audio.rs"] #[allow(warnings)] mod server_audio_contract; @@ -26,9 +26,15 @@ mod tests { use serial_test::serial; const AUDIO_SRC: &str = concat!( - include_str!("../../server/src/audio.rs"), - include_str!("../../server/src/audio/ear_capture.rs"), - include_str!("../../server/src/audio/voice_input.rs"), + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/server/src/audio.rs")), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/audio/ear_capture.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/audio/voice_input.rs" + )), ); fn source_index(needle: &str) -> usize { diff --git a/tests/contract/server/audio/server_opus_uac_contract.rs b/tests/contract/server/audio/server_opus_uac_contract.rs new file mode 100644 index 0000000..89b69ff --- /dev/null +++ b/tests/contract/server/audio/server_opus_uac_contract.rs @@ -0,0 +1,68 @@ +// Server contract for future Opus upstream audio handoff. +// +// Scope: inspect the UAC voice sink and shared audio transport helpers. +// Targets: `server/src/audio/voice_input.rs` and `common/src/audio_transport.rs`. +// Why: compressed Opus payloads must be decoded to PCM before UAC. If decode is +// unavailable or fails, the packet should be rejected safely and visibly. + +use lesavka_common::audio_transport::{ + AudioTransportProfile, packet_audio_profile, packet_is_raw_pcm_s16le, +}; +use lesavka_common::lesavka::{AudioEncoding, AudioPacket}; + +const VOICE_INPUT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/audio/voice_input.rs" +)); +const OPUS_DECODE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/audio/opus_decode.rs" +)); + +#[test] +fn uac_sink_remains_raw_pcm_and_guards_compressed_packets() { + for expected in [ + "audio/x-raw", + "S16LE", + "packet_is_raw_pcm_s16le", + "AudioEncoding::Opus", + "decode_packet(pkt)", + "dropping Opus microphone packet because opusdec is unavailable", + "dropping Opus microphone packet after decode failure", + ] { + assert!( + VOICE_INPUT.contains(expected), + "UAC sink should preserve compressed-audio safety marker {expected}" + ); + } + for expected in [ + "OpusPacketDecoder", + "opusdec", + "audio/x-opus", + "audio/x-raw", + ] { + assert!( + OPUS_DECODE.contains(expected), + "Opus decoder should preserve decode-to-PCM marker {expected}" + ); + } +} + +#[test] +fn opus_packets_are_detected_as_compressed_before_uac_handoff() { + let opus_packet = AudioPacket { + encoding: AudioEncoding::Opus as i32, + sample_rate: 48_000, + channels: 2, + frame_duration_us: 20_000, + data: vec![0x55; 160], + ..AudioPacket::default() + }; + + assert_eq!( + packet_audio_profile(&opus_packet), + AudioTransportProfile::opus_voice() + ); + assert!(!packet_is_raw_pcm_s16le(&opus_packet)); + assert!(packet_is_raw_pcm_s16le(&AudioPacket::default())); +} diff --git a/testing/tests/server_camera_contract.rs b/tests/contract/server/camera/server_camera_contract.rs similarity index 95% rename from testing/tests/server_camera_contract.rs rename to tests/contract/server/camera/server_camera_contract.rs index c1e037f..9a784f4 100644 --- a/testing/tests/server_camera_contract.rs +++ b/tests/contract/server/camera/server_camera_contract.rs @@ -1,10 +1,10 @@ -//! Integration coverage for the server camera-selection contract. -//! -//! Scope: exercise environment-driven camera mode selection and stable enum -//! string mappings via the public camera module API. -//! Targets: `server/src/camera.rs`. -//! Why: camera policy decides the entire stream transport path, so these -//! behavior contracts belong in centralized testing. +// Integration coverage for the server camera-selection contract. +// +// Scope: exercise environment-driven camera mode selection and stable enum +// string mappings via the public camera module API. +// Targets: `server/src/camera.rs`. +// Why: camera policy decides the entire stream transport path, so these +// behavior contracts belong in centralized test harness. use lesavka_server::camera::{ CameraCodec, CameraOutput, current_camera_config, update_camera_config, diff --git a/testing/tests/server_gadget_include_contract.rs b/tests/contract/server/gadget/server_gadget_include_contract.rs similarity index 97% rename from testing/tests/server_gadget_include_contract.rs rename to tests/contract/server/gadget/server_gadget_include_contract.rs index 6d01e1e..61e02a6 100644 --- a/testing/tests/server_gadget_include_contract.rs +++ b/tests/contract/server/gadget/server_gadget_include_contract.rs @@ -1,10 +1,10 @@ -//! Include-based coverage for USB gadget orchestration helpers. -//! -//! Scope: include `server/src/gadget.rs` and exercise deterministic helper -//! logic and error branches without touching live gadget sysfs state. -//! Targets: `server/src/gadget.rs`. -//! Why: most gadget logic is file-system and errno handling that should remain -//! stable regardless of host environment. +// Include-based coverage for USB gadget orchestration helpers. +// +// Scope: include `server/src/gadget.rs` and exercise deterministic helper +// logic and error branches without touching live gadget sysfs state. +// Targets: `server/src/gadget.rs`. +// Why: most gadget logic is file-system and errno handling that should remain +// stable regardless of host environment. #[allow(warnings)] mod gadget_include_contract { diff --git a/testing/tests/server_main_binary_contract.rs b/tests/contract/server/main/server_main_binary_contract.rs similarity index 97% rename from testing/tests/server_main_binary_contract.rs rename to tests/contract/server/main/server_main_binary_contract.rs index 0c54ba2..ce1b4d8 100644 --- a/testing/tests/server_main_binary_contract.rs +++ b/tests/contract/server/main/server_main_binary_contract.rs @@ -1,9 +1,9 @@ -//! Integration coverage for server binary startup and RPC guards. -//! -//! Scope: include sanitized `server/src/main.rs` and execute startup/runtime -//! error branches directly so llvm-cov attributes lines to the entrypoint file. -//! Targets: `server/src/main.rs`. -//! Why: subprocess-only coverage does not reliably move binary file coverage. +// Integration coverage for server binary startup and RPC guards. +// +// Scope: include sanitized `server/src/main.rs` and execute startup/runtime +// error branches directly so llvm-cov attributes lines to the entrypoint file. +// Targets: `server/src/main.rs`. +// Why: subprocess-only coverage does not reliably move binary file coverage. #[allow(warnings)] mod server_main_binary { diff --git a/testing/tests/server_main_binary_extra_contract.rs b/tests/contract/server/main/server_main_binary_extra_contract.rs similarity index 97% rename from testing/tests/server_main_binary_extra_contract.rs rename to tests/contract/server/main/server_main_binary_extra_contract.rs index 891b750..6000bbb 100644 --- a/testing/tests/server_main_binary_extra_contract.rs +++ b/tests/contract/server/main/server_main_binary_extra_contract.rs @@ -1,10 +1,10 @@ -//! Extra integration coverage for server main HID startup branches. -//! -//! Scope: include `server/src/main.rs` and exercise successful handler startup -//! with synthetic HID endpoints. -//! Targets: `server/src/main.rs`. -//! Why: the main contract file is near the 500 LOC cap, so additional branch -//! coverage lives here. +// Extra integration coverage for server main HID startup branches. +// +// Scope: include `server/src/main.rs` and exercise successful handler startup +// with synthetic HID endpoints. +// Targets: `server/src/main.rs`. +// Why: the main contract file is near the 500 LOC cap, so additional branch +// coverage lives here. #[allow(warnings)] mod server_main_binary_extra { diff --git a/testing/tests/server_main_eye_hub_contract.rs b/tests/contract/server/main/server_main_eye_hub_contract.rs similarity index 96% rename from testing/tests/server_main_eye_hub_contract.rs rename to tests/contract/server/main/server_main_eye_hub_contract.rs index 9dba1b9..2531e81 100644 --- a/testing/tests/server_main_eye_hub_contract.rs +++ b/tests/contract/server/main/server_main_eye_hub_contract.rs @@ -1,10 +1,10 @@ -//! Eye-hub coverage for shared server video fan-out. -//! -//! Scope: include `server/src/main.rs` and exercise hub fan-out, conflict -//! pruning, and shutdown behavior. -//! Targets: `server/src/main.rs`. -//! Why: eye-feed hubs are latency-sensitive; stale hubs must stop and be -//! replaced without freezing downstream previews. +// Eye-hub coverage for shared server video fan-out. +// +// Scope: include `server/src/main.rs` and exercise hub fan-out, conflict +// pruning, and shutdown behavior. +// Targets: `server/src/main.rs`. +// Why: eye-feed hubs are latency-sensitive; stale hubs must stop and be +// replaced without freezing downstream previews. #[allow(warnings)] mod server_main_eye_hub { diff --git a/testing/tests/server_main_media_extra_contract.rs b/tests/contract/server/main/server_main_media_extra_contract.rs similarity index 97% rename from testing/tests/server_main_media_extra_contract.rs rename to tests/contract/server/main/server_main_media_extra_contract.rs index 1ecc6dd..a6a0abd 100644 --- a/testing/tests/server_main_media_extra_contract.rs +++ b/tests/contract/server/main/server_main_media_extra_contract.rs @@ -1,10 +1,10 @@ -//! Extra media and helper coverage for server main relay branches. -//! -//! Scope: include `server/src/main.rs` and exercise camera, eye-hub, and UVC -//! helper branches without pushing the binary contract past the LOC cap. -//! Targets: `server/src/main.rs`. -//! Why: live media paths need bounded, deterministic contracts even when no -//! real camera, UVC helper, or capture hardware is present. +// Extra media and helper coverage for server main relay branches. +// +// Scope: include `server/src/main.rs` and exercise camera, eye-hub, and UVC +// helper branches without pushing the binary contract past the LOC cap. +// Targets: `server/src/main.rs`. +// Why: live media paths need bounded, deterministic contracts even when no +// real camera, UVC helper, or capture hardware is present. #[allow(warnings)] mod server_main_media_extra { diff --git a/testing/tests/server_main_process_contract.rs b/tests/contract/server/main/server_main_process_contract.rs similarity index 91% rename from testing/tests/server_main_process_contract.rs rename to tests/contract/server/main/server_main_process_contract.rs index ffd6d37..bdc9c9a 100644 --- a/testing/tests/server_main_process_contract.rs +++ b/tests/contract/server/main/server_main_process_contract.rs @@ -1,10 +1,10 @@ -//! Integration coverage for server process startup behavior. -//! -//! Scope: launch the real `lesavka-server` binary and assert startup stays -//! resilient in this non-gadget test environment. -//! Targets: `server/src/main.rs`. -//! Why: missing gadget endpoints should not crash the relay; the server keeps -//! running and opens HID lazily when the device nodes appear. +// Integration coverage for server process startup behavior. +// +// Scope: launch the real `lesavka-server` binary and assert startup stays +// resilient in this non-gadget test environment. +// Targets: `server/src/main.rs`. +// Why: missing gadget endpoints should not crash the relay; the server keeps +// running and opens HID lazily when the device nodes appear. use serial_test::serial; use std::fs; @@ -39,9 +39,6 @@ fn candidate_dirs() -> Vec { fn workspace_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("workspace root") - .to_path_buf() } fn build_current_binary(name: &str) -> Option { @@ -87,9 +84,7 @@ fn find_binary(name: &str) -> Option { } fn server_package_version() -> Option { - let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent()? - .join("server/Cargo.toml"); + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("server/Cargo.toml"); fs::read_to_string(manifest).ok()?.lines().find_map(|line| { let trimmed = line.trim(); trimmed diff --git a/testing/tests/server_runtime_contract.rs b/tests/contract/server/runtime_support/server_runtime_contract.rs similarity index 55% rename from testing/tests/server_runtime_contract.rs rename to tests/contract/server/runtime_support/server_runtime_contract.rs index fb489fa..182bff6 100644 --- a/testing/tests/server_runtime_contract.rs +++ b/tests/contract/server/runtime_support/server_runtime_contract.rs @@ -1,10 +1,10 @@ -//! Integration coverage for public server runtime helpers. -//! -//! Scope: keep public helper smoke tests in the shared top-level testing -//! module rather than the server crate's local `tests/` folder. -//! Targets: `server/src/runtime_support.rs` public pure helpers. -//! Why: these helpers are part of a cross-crate contract surface and fit the -//! centralized testing layout better than package-scoped integration tests. +// Integration coverage for public server runtime helpers. +// +// Scope: keep public helper smoke tests in the shared root test +// module rather than the server crate's local `tests/` folder. +// Targets: `server/src/runtime_support.rs` public pure helpers. +// Why: these helpers are part of a cross-crate contract surface and fit the +// centralized test taxonomy better than package-scoped integration tests. use lesavka_server::runtime_support::{next_stream_id, should_recover_hid_error}; diff --git a/testing/tests/server_uvc_binary_contract.rs b/tests/contract/server/uvc/server_uvc_binary_contract.rs similarity index 97% rename from testing/tests/server_uvc_binary_contract.rs rename to tests/contract/server/uvc/server_uvc_binary_contract.rs index 8d811a3..bb268c7 100644 --- a/testing/tests/server_uvc_binary_contract.rs +++ b/tests/contract/server/uvc/server_uvc_binary_contract.rs @@ -1,11 +1,11 @@ -//! Integration coverage for the `lesavka-uvc` binary control-path contracts. -//! -//! Scope: include the production UVC binary source in the centralized testing -//! crate and exercise selector routing, payload shaping, env parsing, and I/O -//! helper behavior. -//! Targets: `server/src/bin/lesavka-uvc.rs`. -//! Why: this improves coverage for the operational UVC binary while keeping -//! source-file hygiene baselines stable. +// Integration coverage for the `lesavka-uvc` binary control-path contracts. +// +// Scope: include the production UVC binary source in the centralized test harness +// crate and exercise selector routing, payload shaping, env parsing, and I/O +// helper behavior. +// Targets: `server/src/bin/lesavka-uvc.rs`. +// Why: this improves coverage for the operational UVC binary while keeping +// source-file hygiene baselines stable. mod uvc_binary { #![allow(warnings)] diff --git a/testing/tests/server_uvc_binary_extra_contract.rs b/tests/contract/server/uvc/server_uvc_binary_extra_contract.rs similarity index 97% rename from testing/tests/server_uvc_binary_extra_contract.rs rename to tests/contract/server/uvc/server_uvc_binary_extra_contract.rs index 8fbc486..a9b3139 100644 --- a/testing/tests/server_uvc_binary_extra_contract.rs +++ b/tests/contract/server/uvc/server_uvc_binary_extra_contract.rs @@ -1,9 +1,9 @@ -//! Extra coverage for `lesavka-uvc` control/error branches. -//! -//! Scope: keep additive branch tests in a separate file so each testing module -//! remains under the 500 LOC contract. -//! Targets: `server/src/bin/lesavka-uvc.rs`. -//! Why: preserve expanded UVC branch coverage while satisfying test module contracts. +// Extra coverage for `lesavka-uvc` control/error branches. +// +// Scope: keep additive branch tests in a separate file so each test module +// remains under the 500 LOC contract. +// Targets: `server/src/bin/lesavka-uvc.rs`. +// Why: preserve expanded UVC branch coverage while satisfying test module contracts. mod uvc_binary_extra { #![allow(warnings)] diff --git a/testing/tests/server_uvc_process_contract.rs b/tests/contract/server/uvc/server_uvc_process_contract.rs similarity index 88% rename from testing/tests/server_uvc_process_contract.rs rename to tests/contract/server/uvc/server_uvc_process_contract.rs index 581ec6a..270f926 100644 --- a/testing/tests/server_uvc_process_contract.rs +++ b/tests/contract/server/uvc/server_uvc_process_contract.rs @@ -1,10 +1,10 @@ -//! Integration coverage for `lesavka-uvc` process startup parsing. -//! -//! Scope: launch the real `lesavka-uvc` binary with controlled arguments and -//! environment overrides to exercise argument/config parsing and early startup. -//! Targets: `server/src/bin/lesavka-uvc.rs`. -//! Why: command-line/environment startup behavior should fail fast and remain -//! deterministic without a physical UVC gadget node in CI. +// Integration coverage for `lesavka-uvc` process startup parsing. +// +// Scope: launch the real `lesavka-uvc` binary with controlled arguments and +// environment overrides to exercise argument/config parsing and early startup. +// Targets: `server/src/bin/lesavka-uvc.rs`. +// Why: command-line/environment startup behavior should fail fast and remain +// deterministic without a physical UVC gadget node in CI. use serial_test::serial; use std::path::{Path, PathBuf}; diff --git a/testing/tests/server_uvc_script_contract.rs b/tests/contract/server/uvc/server_uvc_script_contract.rs similarity index 58% rename from testing/tests/server_uvc_script_contract.rs rename to tests/contract/server/uvc/server_uvc_script_contract.rs index 9d91d68..e33b091 100644 --- a/testing/tests/server_uvc_script_contract.rs +++ b/tests/contract/server/uvc/server_uvc_script_contract.rs @@ -1,11 +1,14 @@ -//! Contract tests for the standalone UVC launcher shell script. -//! -//! Scope: statically guard `scripts/daemon/lesavka-uvc.sh`. -//! Targets: gadget-node discovery and wrong-device avoidance. -//! Why: falling back to an unrelated `/dev/video0` can wedge the helper in -//! kernel space and poison later gadget recovery loops. +// Contract tests for the standalone UVC launcher shell script. +// +// Scope: statically guard `scripts/daemon/lesavka-uvc.sh`. +// Targets: gadget-node discovery and wrong-device avoidance. +// Why: falling back to an unrelated `/dev/video0` can wedge the helper in +// kernel space and poison later gadget recovery loops. -const UVC_SCRIPT: &str = include_str!("../../scripts/daemon/lesavka-uvc.sh"); +const UVC_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/daemon/lesavka-uvc.sh" +)); #[test] fn uvc_script_waits_for_gadget_by_path_node() { diff --git a/testing/tests/server_video_include_contract.rs b/tests/contract/server/video/server_video_include_contract.rs similarity index 97% rename from testing/tests/server_video_include_contract.rs rename to tests/contract/server/video/server_video_include_contract.rs index 51166aa..b15c1e4 100644 --- a/testing/tests/server_video_include_contract.rs +++ b/tests/contract/server/video/server_video_include_contract.rs @@ -1,10 +1,10 @@ -//! Include-based coverage for server video stream plumbing. -//! -//! Scope: include `server/src/video.rs` and exercise deterministic stream -//! behavior plus malformed pipeline inputs. -//! Targets: `server/src/video.rs`. -//! Why: keep eye-stream setup and wrapper behavior stable without depending on -//! camera hardware in CI. +// Include-based coverage for server video stream plumbing. +// +// Scope: include `server/src/video.rs` and exercise deterministic stream +// behavior plus malformed pipeline inputs. +// Targets: `server/src/video.rs`. +// Why: keep eye-stream setup and wrapper behavior stable without depending on +// camera hardware in CI. #[allow(warnings)] mod video_sinks { diff --git a/testing/tests/server_video_sinks_include_contract.rs b/tests/contract/server/video_sinks/server_video_sinks_include_contract.rs similarity index 85% rename from testing/tests/server_video_sinks_include_contract.rs rename to tests/contract/server/video_sinks/server_video_sinks_include_contract.rs index f5c9af6..95692fe 100644 --- a/testing/tests/server_video_sinks_include_contract.rs +++ b/tests/contract/server/video_sinks/server_video_sinks_include_contract.rs @@ -1,9 +1,11 @@ -//! Include-based coverage for server camera sink internals. -//! -//! Scope: include `server/src/video_sinks.rs` and directly exercise private sink -//! selection/dispatch helpers through stable constructor paths. -//! Targets: `server/src/video_sinks.rs`. -//! Why: sink internals carry substantial branch logic beyond public smoke tests. +// Include-based coverage for server camera sink internals. +// +// Scope: include `server/src/video_sinks.rs` and directly exercise private sink +// selection/dispatch helpers through stable constructor paths. +// Targets: `server/src/video_sinks.rs`. +// Why: sink internals carry substantial branch logic beyond public smoke tests. + +use gstreamer::{self as gst, prelude::ObjectExt}; mod camera { pub use lesavka_server::camera::*; @@ -13,7 +15,7 @@ mod video_support { pub use lesavka_server::video_support::*; } -#[path = "../../server/src/media_timing.rs"] +#[path = "../../../../server/src/media_timing.rs"] #[allow(warnings)] mod media_timing; @@ -252,4 +254,35 @@ mod video_sinks_include_contract { }); }); } + + #[test] + #[cfg(coverage)] + fn camera_relay_noop_feed_covers_freshness_first_test_path() { + let relay = CameraRelay::new_noop(7); + relay.feed(VideoPacket { + id: 7, + pts: 99, + data: vec![0, 0, 0, 1, 0x65, 0x88], + ..Default::default() + }); + } +} + +#[test] +fn media_timing_helpers_configure_sink_clock_properties() { + let _ = gst::init(); + let pipeline = gst::Pipeline::new(); + crate::media_timing::prepare_pipeline_clock_sync(&pipeline); + crate::media_timing::align_pipeline_to_session_clock(&pipeline, 3_000); + + let sink = gst::ElementFactory::make("fakesink") + .build() + .expect("build fakesink"); + crate::media_timing::enable_sink_clock_sync(&sink); + assert!(sink.property::("sync")); + + let identity = gst::ElementFactory::make("identity") + .build() + .expect("build identity"); + crate::media_timing::enable_sink_clock_sync(&identity); } diff --git a/tests/contract/testing/quality_ratchet_evidence_contract.rs b/tests/contract/testing/quality_ratchet_evidence_contract.rs new file mode 100644 index 0000000..a290bf8 --- /dev/null +++ b/tests/contract/testing/quality_ratchet_evidence_contract.rs @@ -0,0 +1,207 @@ +// Contracts for quality-ratchet evidence coverage. +// +// Scope: inspect the root test taxonomy and explicit Cargo test registrations. +// Targets: repository-level `tests/` and root `Cargo.toml`. +// Why: the quality ratchet only protects Lesavka if each critical product path +// has happy-path, negative/degraded, performance, and observability angles that +// cannot silently disappear during future refactors. + +const CARGO_TOML: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")); + +#[derive(Clone, Copy)] +struct EvidencePath { + category: &'static str, + path: &'static str, +} + +fn registered(path: &str) -> bool { + let marker = format!("path = \"{path}\""); + CARGO_TOML.contains(&marker) +} + +fn assert_evidence_set(name: &str, paths: &[EvidencePath]) { + for evidence in paths { + let full_path = format!("{}/{}", env!("CARGO_MANIFEST_DIR"), evidence.path); + assert!( + std::path::Path::new(&full_path).exists(), + "{name} should keep {} evidence file {}", + evidence.category, + evidence.path + ); + assert!( + registered(evidence.path), + "{name} should register {} evidence test {} in root Cargo.toml", + evidence.category, + evidence.path + ); + } +} + +#[test] +fn upstream_media_has_happy_negative_degraded_and_performance_evidence() { + assert_evidence_set( + "upstream media", + &[ + EvidencePath { + category: "unit", + path: "tests/unit/client/uplink/client_upstream_bundle_queue_unit.rs", + }, + EvidencePath { + category: "integration", + path: "tests/integration/client/server/upstream/client_server_upstream_bundle_integration.rs", + }, + EvidencePath { + category: "api", + path: "tests/api/server/upstream_media_runtime/server_upstream_media_bundle_contract.rs", + }, + EvidencePath { + category: "performance", + path: "tests/performance/client/uplink/client_uplink_performance_contract.rs", + }, + EvidencePath { + category: "chaos", + path: "tests/chaos/client/uplink/uplink_backpressure_chaos_contract.rs", + }, + EvidencePath { + category: "reliability", + path: "tests/reliability/client/uplink/client_uplink_freshness_contract.rs", + }, + ], + ); +} + +#[test] +fn final_rct_route_has_sync_freshness_smoothness_and_artifact_evidence() { + assert_evidence_set( + "client/server/RCT route", + &[ + EvidencePath { + category: "e2e", + path: "tests/e2e/client/server/rct/client_server_rct_blind_route_e2e_contract.rs", + }, + EvidencePath { + category: "performance", + path: "tests/performance/client/server/rct/client_server_rct_timing_budget_contract.rs", + }, + EvidencePath { + category: "chaos", + path: "tests/chaos/client/server/rct/client_server_rct_route_chaos_contract.rs", + }, + EvidencePath { + category: "manual", + path: "tests/manual/client/sync_probe/client_rct_transport_probe_contract.rs", + }, + EvidencePath { + category: "manual", + path: "tests/manual/artifacts/probe_artifact_contract.rs", + }, + EvidencePath { + category: "performance", + path: "tests/performance/diagnostics/stage_timing_contract.rs", + }, + ], + ); +} + +#[test] +fn corrupt_video_and_codec_modes_have_guard_compatibility_and_chaos_evidence() { + assert_evidence_set( + "HEVC/MJPEG video output", + &[ + EvidencePath { + category: "unit", + path: "tests/unit/server/video_sinks/hevc_mjpeg_guard_unit.rs", + }, + EvidencePath { + category: "integration", + path: "tests/integration/server/video_sinks/hevc_mjpeg_spool_integration.rs", + }, + EvidencePath { + category: "compatibility", + path: "tests/compatibility/server/video/hevc_mjpeg_profile_matrix_contract.rs", + }, + EvidencePath { + category: "chaos", + path: "tests/chaos/server/video_sinks/hevc_mjpeg_guard_chaos_contract.rs", + }, + EvidencePath { + category: "performance", + path: "tests/performance/server/video_sinks/hevc_mjpeg_handoff_performance_contract.rs", + }, + ], + ); +} + +#[test] +fn audio_epoch_ui_security_install_and_diagnostics_are_backstopped() { + assert_evidence_set( + "cross-cutting product controls", + &[ + EvidencePath { + category: "unit", + path: "tests/unit/client/app/client_audio_recovery_config_unit.rs", + }, + EvidencePath { + category: "ui", + path: "tests/ui/client/launcher/client_audio_recovery_ui_contract.rs", + }, + EvidencePath { + category: "integration", + path: "tests/integration/client/runtime_controls/client_live_media_control_integration.rs", + }, + EvidencePath { + category: "security", + path: "tests/security/server/tls/server_tls_security_contract.rs", + }, + EvidencePath { + category: "security", + path: "tests/security/server/upstream_media/upstream_media_payload_security_contract.rs", + }, + EvidencePath { + category: "installer", + path: "tests/installer/scripts/install/install_version_path_contract.rs", + }, + EvidencePath { + category: "contract", + path: "tests/contract/diagnostics/report_schema_contract.rs", + }, + EvidencePath { + category: "golden", + path: "tests/golden/diagnostics/client_rct_transport_summary_golden_contract.rs", + }, + EvidencePath { + category: "reliability", + path: "tests/reliability/diagnostics/log_spam_prevention_contract.rs", + }, + ], + ); +} + +#[test] +fn opus_transport_has_schema_client_server_and_budget_evidence() { + assert_evidence_set( + "optional Opus audio transport", + &[ + EvidencePath { + category: "unit", + path: "tests/unit/common/audio/common_audio_transport_unit.rs", + }, + EvidencePath { + category: "integration", + path: "tests/integration/common/proto/relay_opus_proto_integration_contract.rs", + }, + EvidencePath { + category: "compatibility", + path: "tests/compatibility/client/audio/client_opus_transport_contract.rs", + }, + EvidencePath { + category: "contract", + path: "tests/contract/server/audio/server_opus_uac_contract.rs", + }, + EvidencePath { + category: "performance", + path: "tests/performance/client/uplink/opus_transport_budget_contract.rs", + }, + ], + ); +} diff --git a/testing/coverage_contract.json b/tests/coverage_contract.json similarity index 100% rename from testing/coverage_contract.json rename to tests/coverage_contract.json diff --git a/tests/e2e/client/server/rct/client_server_rct_blind_route_e2e_contract.rs b/tests/e2e/client/server/rct/client_server_rct_blind_route_e2e_contract.rs new file mode 100644 index 0000000..3ca2f28 --- /dev/null +++ b/tests/e2e/client/server/rct/client_server_rct_blind_route_e2e_contract.rs @@ -0,0 +1,85 @@ +// E2E contract for the blind client->server->RCT synthetic media route. +// +// Scope: protect the black-box HEVC/audio route that generates media locally, +// sends bundled media to the server, and measures final RCT capture. +// Targets: scripts/manual/run_client_to_rct_transport_probe.sh and +// client/src/sync_probe/runner/bundled_transport.rs. +// Why: before deeper introspection, the product needs a stable blind test that +// proves the same path a real call uses can be measured end-to-end. + +const CLIENT_RCT_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_client_to_rct_transport_probe.sh" +)); +const BUNDLED_TRANSPORT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/sync_probe/runner/bundled_transport.rs" +)); +const SYNC_RUNNER: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/sync_probe/runner.rs" +)); + +#[test] +fn blind_route_uses_synthetic_bundled_media_and_final_rct_capture() { + for marker in [ + "client-to-RCT bundled transport probe", + "lesavka-sync-probe", + "PROBE_EVENT_WIDTH_CODES=${PROBE_EVENT_WIDTH_CODES:-1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16}", + "stream_webcam_media", + "UpstreamMediaBundle", + "starting RCT UVC/UAC capture on ${TETHYS_HOST}", + "analyzing RCT capture", + "client-transport-timeline.json", + ] { + assert!( + CLIENT_RCT_SCRIPT.contains(marker) + || BUNDLED_TRANSPORT.contains(marker) + || SYNC_RUNNER.contains(marker), + "blind client->server->RCT route should preserve marker {marker}" + ); + } +} + +#[test] +fn blind_route_refuses_split_streams_and_requires_bundled_server_support() { + for marker in [ + "server does not advertise bundled webcam media", + "refusing to measure split upstream", + "bundled_webcam_media", + ] { + assert!( + BUNDLED_TRANSPORT.contains(marker) || SYNC_RUNNER.contains(marker), + "blind route should preserve bundled-media guard {marker}" + ); + } + + for forbidden in [ + ".stream_camera(Request::new(outbound))", + ".stream_microphone(Request::new(outbound))", + ] { + assert!( + !BUNDLED_TRANSPORT.contains(forbidden) && !SYNC_RUNNER.contains(forbidden), + "blind route must not fall back to split-stream RPC {forbidden}" + ); + } +} + +#[test] +fn blind_route_has_formal_pass_fail_budgets_and_artifacts() { + for marker in [ + "LESAVKA_CLIENT_RCT_MAX_AGE_MS=${LESAVKA_CLIENT_RCT_MAX_AGE_MS:-1000}", + "LESAVKA_CLIENT_RCT_MIN_PAIRS=${LESAVKA_CLIENT_RCT_MIN_PAIRS:-13}", + "LESAVKA_CLIENT_RCT_REQUIRE_SMOOTHNESS=${LESAVKA_CLIENT_RCT_REQUIRE_SMOOTHNESS:-0}", + "client-rct-transport-summary.json", + "client-rct-transport-summary.txt", + "upstream-sync-samples.jsonl", + "client-send-bundles.jsonl", + "uvc-frame-meta-summary.json", + ] { + assert!( + CLIENT_RCT_SCRIPT.contains(marker), + "blind route should preserve budget/artifact marker {marker}" + ); + } +} diff --git a/tests/e2e/scripts/manual/upstream_media_e2e_contract.rs b/tests/e2e/scripts/manual/upstream_media_e2e_contract.rs new file mode 100644 index 0000000..347500e --- /dev/null +++ b/tests/e2e/scripts/manual/upstream_media_e2e_contract.rs @@ -0,0 +1,47 @@ +// End-to-end probe contract for operator-assisted upstream media validation. +// +// Scope: protect the manual E2E harnesses that send client-origin synthetic +// media through server output and then capture the RCT result. +// Targets: `scripts/manual/run_client_to_rct_transport_probe.sh` and +// `scripts/manual/run_google_meet_observer_probe.sh`. +// Why: raw RCT capture and Google Meet observation answer different E2E +// questions, so both scripts must keep artifact-backed, analyzable outputs. + +const CLIENT_RCT_PROBE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_client_to_rct_transport_probe.sh" +)); +const GOOGLE_MEET_PROBE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_google_meet_observer_probe.sh" +)); + +#[test] +fn e2e_harnesses_keep_black_box_capture_and_observer_paths() { + for marker in [ + "client-to-RCT bundled transport probe", + "starting RCT UVC/UAC capture on ${TETHYS_HOST}", + "running client-origin bundled transport probe", + "analyzing RCT capture", + "max_client_to_rct_age_ms", + "client-transport-timeline.json", + ] { + assert!( + CLIENT_RCT_PROBE.contains(marker), + "client-to-RCT E2E probe should preserve marker {marker}" + ); + } + + for marker in [ + "Google Meet observer probe", + "no Google credentials, sudo, or browser automation are used", + "operator-checklist.txt", + "running synthetic bundled media through Google Meet path", + "LESAVKA_MEET_OBSERVER_CAPTURE", + ] { + assert!( + GOOGLE_MEET_PROBE.contains(marker), + "Meet observer E2E probe should preserve marker {marker}" + ); + } +} diff --git a/tests/e2e/server/rct/server_rct_output_probe_e2e_contract.rs b/tests/e2e/server/rct/server_rct_output_probe_e2e_contract.rs new file mode 100644 index 0000000..3cf8696 --- /dev/null +++ b/tests/e2e/server/rct/server_rct_output_probe_e2e_contract.rs @@ -0,0 +1,78 @@ +// E2E contract for server-origin media observed at the RCT. +// +// Scope: protect the synthetic flash/tone path used to prove final RCT output. +// Targets: server/src/output_delay_probe/probe_runtime.rs, +// server/src/output_delay_probe/media_encoding.rs, and +// server/src/output_delay_probe/timeline_config.rs. +// Why: the server-to-RCT leg is considered complete enough to protect; future +// client transport work should not mutate the final-output probe into a weaker +// or less observable path. + +const PROBE_RUNTIME: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/output_delay_probe/probe_runtime.rs" +)); +const MEDIA_ENCODING: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/output_delay_probe/media_encoding.rs" +)); +const TIMELINE_CONFIG: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/output_delay_probe/timeline_config.rs" +)); + +#[test] +fn server_output_probe_uses_final_uvc_uac_handoff_not_test_only_shortcuts() { + for marker in [ + "same final server output handoff calls as received client media", + "relay.feed(VideoPacket", + "sink.push(&AudioPacket", + "sink.finish();", + "server-final-output-handoff", + "video CameraRelay::feed; audio Voice::push", + "client_uplink_included: false", + ] { + assert!( + PROBE_RUNTIME.contains(marker) || TIMELINE_CONFIG.contains(marker), + "server-to-RCT probe should preserve final handoff marker {marker}" + ); + } +} + +#[test] +fn server_output_probe_generates_identity_rich_flash_and_tone_media() { + for marker in [ + "EVENT_COLORS", + "DEFAULT_EVENT_WIDTH_CODES", + "event_width_codes", + "event signature code {code} is unsupported; use values 1..16", + "render_audio_chunk", + "probe_color_for_code", + "coded flash schedule", + "HEVC frames let the final RCT capture prove sync", + ] { + assert!( + MEDIA_ENCODING.contains(marker) || TIMELINE_CONFIG.contains(marker), + "server-to-RCT synthetic media should preserve coded marker {marker}" + ); + } +} + +#[test] +fn server_output_probe_timeline_exports_freshness_reference_fields() { + for marker in [ + "lesavka.output-delay-server-timeline.v1", + "theia-server-generated", + "server generator -> UVC/UAC sinks", + "server_pipeline_reference", + "server_start_unix_ns", + "video_feed_unix_ns", + "audio_push_unix_ns", + "server_feed_delta_ms", + ] { + assert!( + TIMELINE_CONFIG.contains(marker), + "server-to-RCT timeline should preserve freshness marker {marker}" + ); + } +} diff --git a/tests/fixtures/.gitkeep b/tests/fixtures/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/client_rct/transport_summary_freshness_fail.json b/tests/fixtures/client_rct/transport_summary_freshness_fail.json new file mode 100644 index 0000000..2483410 --- /dev/null +++ b/tests/fixtures/client_rct/transport_summary_freshness_fail.json @@ -0,0 +1,30 @@ +{ + "schema": "lesavka.client-rct-transport-summary.v1", + "verdict": "fail", + "sync": { + "verdict": "preferred", + "p95_abs_skew_ms": 18.1, + "paired_events": 16, + "min_paired_events": 13, + "evidence_complete": true + }, + "freshness": { + "verdict": "fail", + "budget_ms": 1248.0, + "limit_ms": 1000.0, + "freshness_bottleneck": "server_receive_or_ingress_queue", + "stages": { + "client_queue_p95_ms": 42.0, + "server_receive_age_p95_ms": 612.0, + "decode_spool_p95_ms": 108.0, + "final_rct_age_p95_ms": 486.0, + "sink_late_p95_ms": 6.0 + } + }, + "smoothness": { + "verdict": "warn", + "video_hiccups": 2, + "audio_hiccups": 0, + "video_freezes": 4 + } +} diff --git a/tests/fixtures/client_rct/transport_summary_pass.json b/tests/fixtures/client_rct/transport_summary_pass.json new file mode 100644 index 0000000..d2b979a --- /dev/null +++ b/tests/fixtures/client_rct/transport_summary_pass.json @@ -0,0 +1,30 @@ +{ + "schema": "lesavka.client-rct-transport-summary.v1", + "verdict": "pass", + "sync": { + "verdict": "preferred", + "p95_abs_skew_ms": 12.3, + "paired_events": 16, + "min_paired_events": 13, + "evidence_complete": true + }, + "freshness": { + "verdict": "pass", + "budget_ms": 642.5, + "limit_ms": 1000.0, + "freshness_bottleneck": "within_limit", + "stages": { + "client_queue_p95_ms": 28.0, + "server_receive_age_p95_ms": 155.0, + "decode_spool_p95_ms": 82.0, + "final_rct_age_p95_ms": 377.5, + "sink_late_p95_ms": 4.0 + } + }, + "smoothness": { + "verdict": "pass", + "video_hiccups": 0, + "audio_hiccups": 0, + "video_freezes": 1 + } +} diff --git a/tests/fixtures/client_rct/transport_summary_weak_evidence.json b/tests/fixtures/client_rct/transport_summary_weak_evidence.json new file mode 100644 index 0000000..fbffb56 --- /dev/null +++ b/tests/fixtures/client_rct/transport_summary_weak_evidence.json @@ -0,0 +1,30 @@ +{ + "schema": "lesavka.client-rct-transport-summary.v1", + "verdict": "fail", + "sync": { + "verdict": "acceptable", + "p95_abs_skew_ms": 74.4, + "paired_events": 6, + "min_paired_events": 13, + "evidence_complete": false + }, + "freshness": { + "verdict": "fail", + "budget_ms": 2583.3, + "limit_ms": 1000.0, + "freshness_bottleneck": "client_to_server_transport", + "stages": { + "client_queue_p95_ms": 134.0, + "server_receive_age_p95_ms": 175.8, + "decode_spool_p95_ms": 428.0, + "final_rct_age_p95_ms": 1842.0, + "sink_late_p95_ms": 2.5 + } + }, + "smoothness": { + "verdict": "pass", + "video_hiccups": 0, + "audio_hiccups": 0, + "video_freezes": 0 + } +} diff --git a/tests/golden/.gitkeep b/tests/golden/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/golden/client_rct/transport_summary_freshness_fail.txt b/tests/golden/client_rct/transport_summary_freshness_fail.txt new file mode 100644 index 0000000..094ad65 --- /dev/null +++ b/tests/golden/client_rct/transport_summary_freshness_fail.txt @@ -0,0 +1,5 @@ +Client-to-RCT transport summary +- verdict: fail +- sync: preferred p95=18.1ms paired=16/13 evidence=complete +- freshness: fail budget=1248.0ms limit=1000.0ms bottleneck=server_receive_or_ingress_queue +- smoothness: warn video_hiccups=2 audio_hiccups=0 video_freezes=4 diff --git a/tests/golden/client_rct/transport_summary_pass.txt b/tests/golden/client_rct/transport_summary_pass.txt new file mode 100644 index 0000000..38ff528 --- /dev/null +++ b/tests/golden/client_rct/transport_summary_pass.txt @@ -0,0 +1,5 @@ +Client-to-RCT transport summary +- verdict: pass +- sync: preferred p95=12.3ms paired=16/13 evidence=complete +- freshness: pass budget=642.5ms limit=1000.0ms bottleneck=within_limit +- smoothness: pass video_hiccups=0 audio_hiccups=0 video_freezes=1 diff --git a/tests/golden/client_rct/transport_summary_weak_evidence.txt b/tests/golden/client_rct/transport_summary_weak_evidence.txt new file mode 100644 index 0000000..f5e5a0a --- /dev/null +++ b/tests/golden/client_rct/transport_summary_weak_evidence.txt @@ -0,0 +1,5 @@ +Client-to-RCT transport summary +- verdict: fail +- sync: acceptable p95=74.4ms paired=6/13 evidence=weak +- freshness: fail budget=2583.3ms limit=1000.0ms bottleneck=client_to_server_transport +- smoothness: pass video_hiccups=0 audio_hiccups=0 video_freezes=0 diff --git a/tests/golden/diagnostics/client_rct_transport_summary_golden_contract.rs b/tests/golden/diagnostics/client_rct_transport_summary_golden_contract.rs new file mode 100644 index 0000000..632bf63 --- /dev/null +++ b/tests/golden/diagnostics/client_rct_transport_summary_golden_contract.rs @@ -0,0 +1,112 @@ +// Golden-file coverage for client-to-RCT transport summaries. +// +// Scope: parse representative route summary fixtures and compare the compact +// operator rendering against golden text. +// Targets: scripts/manual/client_rct_transport_summary.py and emitted +// client-rct-transport-summary.{json,txt} artifacts. +// Why: lab runs are expensive; deterministic fixtures keep pass, weak-evidence, +// and freshness-failure reports stable enough to diagnose without replaying +// Theia/Tethys hardware every time. + +use serde_json::Value; + +struct SummaryCase { + name: &'static str, + json: &'static str, + golden: &'static str, +} + +const CASES: &[SummaryCase] = &[ + SummaryCase { + name: "pass", + json: include_str!("../../fixtures/client_rct/transport_summary_pass.json"), + golden: include_str!("../client_rct/transport_summary_pass.txt"), + }, + SummaryCase { + name: "weak evidence", + json: include_str!("../../fixtures/client_rct/transport_summary_weak_evidence.json"), + golden: include_str!("../client_rct/transport_summary_weak_evidence.txt"), + }, + SummaryCase { + name: "freshness fail", + json: include_str!("../../fixtures/client_rct/transport_summary_freshness_fail.json"), + golden: include_str!("../client_rct/transport_summary_freshness_fail.txt"), + }, +]; + +fn render_summary(value: &Value) -> String { + let sync = &value["sync"]; + let freshness = &value["freshness"]; + let smoothness = &value["smoothness"]; + let evidence = if sync["evidence_complete"].as_bool().unwrap_or(false) { + "complete" + } else { + "weak" + }; + + format!( + "Client-to-RCT transport summary\n\ + - verdict: {}\n\ + - sync: {} p95={:.1}ms paired={}/{} evidence={}\n\ + - freshness: {} budget={:.1}ms limit={:.1}ms bottleneck={}\n\ + - smoothness: {} video_hiccups={} audio_hiccups={} video_freezes={}\n", + value["verdict"].as_str().expect("verdict"), + sync["verdict"].as_str().expect("sync verdict"), + sync["p95_abs_skew_ms"].as_f64().expect("sync p95"), + sync["paired_events"].as_u64().expect("paired events"), + sync["min_paired_events"].as_u64().expect("min paired"), + evidence, + freshness["verdict"].as_str().expect("freshness verdict"), + freshness["budget_ms"].as_f64().expect("freshness budget"), + freshness["limit_ms"].as_f64().expect("freshness limit"), + freshness["freshness_bottleneck"] + .as_str() + .expect("freshness bottleneck"), + smoothness["verdict"].as_str().expect("smoothness verdict"), + smoothness["video_hiccups"].as_u64().expect("video hiccups"), + smoothness["audio_hiccups"].as_u64().expect("audio hiccups"), + smoothness["video_freezes"].as_u64().expect("video freezes"), + ) +} + +#[test] +fn client_rct_summary_fixtures_match_operator_goldens() { + for case in CASES { + let parsed: Value = serde_json::from_str(case.json).expect(case.name); + assert_eq!(render_summary(&parsed), case.golden); + } +} + +#[test] +fn client_rct_summary_fixtures_cover_required_diagnostic_outcomes() { + let mut saw_pass = false; + let mut saw_weak_evidence = false; + let mut saw_freshness_fail = false; + + for case in CASES { + let parsed: Value = serde_json::from_str(case.json).expect(case.name); + assert_eq!( + parsed["schema"].as_str(), + Some("lesavka.client-rct-transport-summary.v1") + ); + assert!(parsed["freshness"]["stages"]["client_queue_p95_ms"].is_number()); + assert!(parsed["freshness"]["stages"]["server_receive_age_p95_ms"].is_number()); + assert!(parsed["freshness"]["stages"]["decode_spool_p95_ms"].is_number()); + assert!(parsed["freshness"]["stages"]["final_rct_age_p95_ms"].is_number()); + assert!(parsed["freshness"]["stages"]["sink_late_p95_ms"].is_number()); + + saw_pass |= parsed["verdict"] == "pass"; + saw_weak_evidence |= parsed["sync"]["evidence_complete"] == false; + saw_freshness_fail |= parsed["freshness"]["verdict"] == "fail"; + } + + assert!(saw_pass, "fixtures should include a clean pass baseline"); + assert!( + saw_weak_evidence, + "fixtures should include weak event-pair evidence" + ); + assert!( + saw_freshness_fail, + "fixtures should include a freshness failure with stage attribution" + ); +} diff --git a/testing/tests/support/live_capture_clock_shim.rs b/tests/helpers/support/live_capture_clock_shim.rs similarity index 92% rename from testing/tests/support/live_capture_clock_shim.rs rename to tests/helpers/support/live_capture_clock_shim.rs index 74040c7..6102ca2 100644 --- a/testing/tests/support/live_capture_clock_shim.rs +++ b/tests/helpers/support/live_capture_clock_shim.rs @@ -1,8 +1,8 @@ // Shared live-capture clock shim for include-based client contracts. // // Scope: provide the subset of `client::live_capture_clock` needed by -// include tests that compile client modules inside `lesavka_testing`. -// Targets: client include-contract harnesses under `testing/tests/`. +// include tests that compile client modules inside the root `lesavka_tests` harness. +// Targets: client include-contract harnesses under `tests/`. // Why: include tests should exercise production modules without depending on // the whole client crate module tree. @@ -128,11 +128,10 @@ impl SourcePtsRebaser { packet_pts_us = lag_floor_us; lag_clamped = true; } - let lead_ceiling_us = capture_now_us.saturating_add( - upstream_source_lead_cap() - .as_micros() - .min(u64::MAX as u128) as u64, - ); + let lead_ceiling_us = + capture_now_us.saturating_add( + upstream_source_lead_cap().as_micros().min(u64::MAX as u128) as u64, + ); if packet_pts_us > lead_ceiling_us { packet_pts_us = lead_ceiling_us; lead_clamped = true; @@ -182,11 +181,9 @@ impl DurationPacedSourcePtsRebaser { packet_pts_us = lag_floor_us; rebased.lag_clamped = true; } - let lead_ceiling_us = rebased.capture_now_us.saturating_add( - upstream_source_lead_cap() - .as_micros() - .min(u64::MAX as u128) as u64, - ); + let lead_ceiling_us = rebased + .capture_now_us + .saturating_add(upstream_source_lead_cap().as_micros().min(u64::MAX as u128) as u64); if packet_pts_us > lead_ceiling_us { packet_pts_us = lead_ceiling_us; rebased.lead_clamped = true; diff --git a/testing/support/server_upstream_media_harness.rs b/tests/helpers/support/server_upstream_media_harness.rs similarity index 95% rename from testing/support/server_upstream_media_harness.rs rename to tests/helpers/support/server_upstream_media_harness.rs index 8d44f21..d817da8 100644 --- a/testing/support/server_upstream_media_harness.rs +++ b/tests/helpers/support/server_upstream_media_harness.rs @@ -50,9 +50,9 @@ fn build_handler_for_tests() -> (tempfile::TempDir, Handler) { UpstreamMediaRuntime::new(), ))), capture_power: CapturePowerManager::new(), - eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new( - std::collections::HashMap::new(), - )), + eye_hubs: std::sync::Arc::new( + tokio::sync::Mutex::new(std::collections::HashMap::new()), + ), }, ) } diff --git a/tests/installer/scripts/install/client_install_script_contract.rs b/tests/installer/scripts/install/client_install_script_contract.rs new file mode 100644 index 0000000..7d4c423 --- /dev/null +++ b/tests/installer/scripts/install/client_install_script_contract.rs @@ -0,0 +1,148 @@ +// Contract tests for client install-time security defaults. +// +// Scope: inspect the client installer shell contract without running it. +// Targets: `scripts/install/client.sh`. +// Why: secure-by-default relay transport depends on installing the server-issued +// client cert bundle exactly where the desktop app auto-discovers it. + +const CLIENT_INSTALL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/install/client.sh" +)); + +#[test] +fn client_install_accepts_server_generated_tls_bundle() { + for expected in [ + "LESAVKA_CLIENT_PKI_BUNDLE", + "CLIENT_PKI_DIR", + "ca.crt", + "client.crt", + "client.key", + "install_client_pki_bundle", + "fetch_client_pki_bundle", + "LESAVKA_CLIENT_PKI_SSH_SOURCE", + "LESAVKA_CLIENT_CAPTURE_DIR", + "theia:/etc/lesavka/lesavka-client-pki.tar.gz", + "Pictures/lesavka", + "HTTPS/mTLS relay connections will not work until this bundle is installed", + "TLS identity:", + "Captures:", + "$USER_HOME/.local/bin/lesavka-client", + "User PATH alias:", + ] { + assert!( + CLIENT_INSTALL.contains(expected), + "client installer should include TLS bundle contract fragment {expected}" + ); + } + assert!( + CLIENT_INSTALL.contains(".config/lesavka/pki"), + "client cert bundle should land in the same path the desktop app auto-loads" + ); + assert!( + CLIENT_INSTALL.contains("0600"), + "client private key should be installed with private permissions" + ); + assert!( + CLIENT_INSTALL.contains("scp -q -o BatchMode=yes"), + "client installer should auto-fetch the server enrollment bundle without hanging" + ); + assert!( + CLIENT_INSTALL.contains("run_as_user mktemp"), + "auto-fetch destination should be owned by the user who runs scp" + ); +} + +#[test] +fn client_install_prefers_invoked_checkout_for_development_installs() { + for expected in [ + "INSTALL_SOURCE=${LESAVKA_INSTALL_SOURCE:-auto}", + "resolve_source_checkout", + "Using local source checkout", + "set LESAVKA_INSTALL_SOURCE=ref", + "LESAVKA_INSTALL_SOURCE=local requested", + "unsupported LESAVKA_INSTALL_SOURCE", + "source_revision", + "+dirty", + ] { + assert!( + CLIENT_INSTALL.contains(expected), + "client installer should preserve local-source install marker {expected}" + ); + } + assert!( + CLIENT_INSTALL.find("resolve_source_checkout").unwrap() + < CLIENT_INSTALL + .find("cargo clean && cargo build --release") + .unwrap(), + "client installer should resolve source before building" + ); +} + +#[test] +fn client_install_reports_nvidia_and_open_source_media_routes() { + for expected in [ + "report_client_media_acceleration", + "gst_element_available", + "first_available_gst_element", + "nvidia-smi is available", + "proprietary NVIDIA GStreamer route", + "open-source VAAPI/V4L2 GStreamer route", + "nvh265enc", + "nvh264dec", + "nvh264sldec", + "vah265enc", + "vaapih265enc", + "v4l2h265enc", + "vah264dec", + "vaapih264dec", + "v4l2h264dec", + "v4l2slh264dec", + "x265enc", + "avdec_h264", + "openh264dec", + "opusenc", + "opusdec", + "Opus upstream audio transport route", + "microphone noise suppression route", + "webrtcdsp", + "fall back to PCM", + "LESAVKA_H264_DECODER_PREFERENCE=software", + ] { + assert!( + CLIENT_INSTALL.contains(expected), + "client installer should preserve media acceleration fragment {expected}" + ); + } + assert!( + CLIENT_INSTALL + .find("require_gst_element pipewiresrc") + .unwrap() + < CLIENT_INSTALL + .rfind("report_client_media_acceleration") + .unwrap(), + "media acceleration reporting should run after baseline GStreamer tools are verified" + ); +} + +#[test] +fn client_install_treats_pipewire_as_one_coherent_transaction() { + for expected in [ + "pacman_install()", + "libpipewire pipewire pipewire-audio pipewire-alsa pipewire-jack pipewire-pulse", + "breaks dependency '.*pipewire", + "PipeWire packages are at mixed exact versions", + "sudo pacman -Syu", + "failed retrieving file", + "sudo pacman -Syu --disable-download-timeout", + ] { + assert!( + CLIENT_INSTALL.contains(expected), + "client installer should preserve PipeWire transaction marker {expected}" + ); + } + assert!( + !CLIENT_INSTALL.contains("sudo pacman -Sq --needed --noconfirm \\\n git"), + "base package install should flow through pacman_install so failures get actionable diagnostics" + ); +} diff --git a/tests/installer/scripts/install/install_version_path_contract.rs b/tests/installer/scripts/install/install_version_path_contract.rs new file mode 100644 index 0000000..775535c --- /dev/null +++ b/tests/installer/scripts/install/install_version_path_contract.rs @@ -0,0 +1,111 @@ +// Installer contract for version reporting and launch paths. +// +// Scope: keep client/server install scripts aligned with Cargo package +// versions and the paths used by desktop/systemd launchers. +// Targets: install scripts plus the Linux desktop entry. +// Why: users should be able to rerun installers idempotently and see the same +// version that the installed binary was built from. + +const CLIENT_INSTALL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/install/client.sh" +)); +const SERVER_INSTALL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/install/server.sh" +)); +const CLIENT_DESKTOP: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/assets/linux/lesavka.desktop" +)); +const CLIENT_CARGO: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/client/Cargo.toml")); +const SERVER_CARGO: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/server/Cargo.toml")); +const COMMON_CARGO: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/common/Cargo.toml")); + +fn package_version(manifest: &str) -> &str { + manifest + .lines() + .find_map(|line| { + let (key, value) = line.trim().split_once('=')?; + (key.trim() == "version").then(|| value.trim().trim_matches('"')) + }) + .expect("package version") +} + +#[test] +fn workspace_runtime_crates_share_one_release_version() { + let client_version = package_version(CLIENT_CARGO); + assert_eq!(client_version, package_version(SERVER_CARGO)); + assert_eq!(client_version, package_version(COMMON_CARGO)); +} + +#[test] +fn client_install_reports_the_manifest_version_and_current_revision() { + for marker in [ + "manifest_package_version \"$SRC/client/Cargo.toml\"", + "INSTALLED_SHA=$(source_revision \"$SRC\")", + "Installed: lesavka-client", + "Installed version: lesavka-client", + "Build source: $SRC/target/release/lesavka-client", + ] { + assert!( + CLIENT_INSTALL.contains(marker), + "client installer should preserve version/reporting marker {marker}" + ); + } +} + +#[test] +fn server_install_reports_the_manifest_version_and_current_revision() { + for marker in [ + "manifest_package_version \"$SRC_DIR/server/Cargo.toml\"", + "INSTALLED_SHA=$(git -C \"$SCRIPT_REPO_ROOT\" rev-parse --short HEAD", + "Installed: lesavka-server", + "Installed version: lesavka-server", + "Client install can use:", + ] { + assert!( + SERVER_INSTALL.contains(marker), + "server installer should preserve version/reporting marker {marker}" + ); + } +} + +#[test] +fn desktop_and_terminal_launch_paths_point_at_the_installed_client_binary() { + for marker in [ + "install_verified_executable \"$SRC/target/release/lesavka-client\" /usr/local/bin/lesavka-client", + "ln -sf /usr/local/bin/lesavka-client /usr/local/bin/lesavka", + "ln -sf /usr/local/bin/lesavka-client \"$USER_HOME/.local/bin/lesavka-client\"", + "Desktop entry: /usr/share/applications/lesavka.desktop", + ] { + assert!( + CLIENT_INSTALL.contains(marker), + "client installer should preserve launch path marker {marker}" + ); + } + + assert!(CLIENT_DESKTOP.contains("Exec=/usr/local/bin/lesavka")); + assert!(CLIENT_DESKTOP.contains("Icon=lesavka")); +} + +#[test] +fn server_installs_all_systemd_entrypoint_binaries_from_the_same_build() { + for marker in [ + "install_verified_executable \"$SRC_DIR/target/release/lesavka-server\" /usr/local/bin/lesavka-server", + "install_verified_executable \"$SRC_DIR/target/release/lesavka-uvc\" /usr/local/bin/lesavka-uvc", + "install_verified_executable \"$SRC_DIR/scripts/daemon/lesavka-core.sh\" /usr/local/bin/lesavka-core.sh", + "install_verified_executable \"$SRC_DIR/scripts/daemon/lesavka-uvc.sh\" /usr/local/bin/lesavka-uvc.sh", + "install_verified_executable \"$SRC_DIR/scripts/daemon/lesavka-recovery-ladder.sh\" /usr/local/bin/lesavka-recovery-ladder", + "/usr/local/bin/lesavka-server", + "/usr/local/bin/lesavka-uvc", + "/usr/local/bin/lesavka-core.sh", + "/usr/local/bin/lesavka-uvc.sh", + "/usr/local/bin/lesavka-recovery-ladder", + ] { + assert!( + SERVER_INSTALL.contains(marker), + "server installer should preserve binary path marker {marker}" + ); + } +} diff --git a/testing/tests/server_install_script_contract.rs b/tests/installer/scripts/install/server_install_script_contract.rs similarity index 88% rename from testing/tests/server_install_script_contract.rs rename to tests/installer/scripts/install/server_install_script_contract.rs index 8fb8bd1..d3ada61 100644 --- a/testing/tests/server_install_script_contract.rs +++ b/tests/installer/scripts/install/server_install_script_contract.rs @@ -1,11 +1,14 @@ -//! Contract tests for server install-time operational defaults. -//! -//! Scope: statically guard the generated `/etc/lesavka/server.env` values. -//! Targets: `scripts/install/server.sh`. -//! Why: HDMI capture adapter settings should be reproducible after reboot or -//! reinstall instead of living as one-off shell state. +// Contract tests for server install-time operational defaults. +// +// Scope: statically guard the generated `/etc/lesavka/server.env` values. +// Targets: `scripts/install/server.sh`. +// Why: HDMI capture adapter settings should be reproducible after reboot or +// reinstall instead of living as one-off shell state. -const SERVER_INSTALL: &str = include_str!("../../scripts/install/server.sh"); +const SERVER_INSTALL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/install/server.sh" +)); #[test] fn server_install_pins_hdmi_camera_and_display_defaults() { @@ -54,6 +57,10 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { assert!(SERVER_INSTALL.contains("${LESAVKA_CAM_FPS:-30}")); assert!(SERVER_INSTALL.contains("${LESAVKA_INSTALL_CAM_OUTPUT:-uvc}")); assert!(SERVER_INSTALL.contains("${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-hevc}}")); + assert!( + SERVER_INSTALL + .contains("${LESAVKA_INSTALL_UPLINK_AUDIO_CODEC:-${LESAVKA_UPLINK_AUDIO_CODEC:-pcm}}") + ); assert!(SERVER_INSTALL.contains("${LESAVKA_INSTALL_UVC_CODEC:-mjpeg}")); assert!( SERVER_INSTALL.contains("${LESAVKA_INSTALL_UVC_FRAME_META:-${LESAVKA_UVC_FRAME_META:-0}}") @@ -80,6 +87,11 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { assert!(SERVER_INSTALL.contains( "DEFAULT_HEVC_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US=1280x720@20=173852,1280x720@30=110000,1920x1080@20=160045,1920x1080@30=127952" )); + assert!(SERVER_INSTALL.contains( + "DEFAULT_HEVC_OPUS_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=$DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US" + )); + assert!(SERVER_INSTALL.contains("LESAVKA_UPLINK_AUDIO_CODEC=%s")); + assert!(SERVER_INSTALL.contains("LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US=%s")); assert!( SERVER_INSTALL.contains("resolve_upstream_video_playout_offset_us"), "video offset should be resolved through stale-baseline migration logic" @@ -345,6 +357,53 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { ); } +#[test] +fn server_install_provisions_non_rebooting_recovery_ladder() { + for expected in [ + "install_verified_executable \"$SRC_DIR/scripts/daemon/lesavka-recovery-ladder.sh\" /usr/local/bin/lesavka-recovery-ladder", + "lesavka-recovery-ladder.service", + "lesavka-recovery-ladder.timer", + "ExecStart=/usr/local/bin/lesavka-recovery-ladder recover", + "Environment=LESAVKA_RECOVERY_ALLOW_CORE_RESTART=0", + "Environment=LESAVKA_RECOVERY_ALLOW_REBOOT=0", + "OnUnitActiveSec=60s", + "sudo /usr/local/bin/lesavka-recovery-ladder snapshot || true", + "sudo systemctl start lesavka-recovery-ladder.timer", + ] { + assert!( + SERVER_INSTALL.contains(expected), + "server installer should preserve recovery ladder marker {expected}" + ); + } + assert!( + SERVER_INSTALL.find("validate_server_ready").unwrap() + < SERVER_INSTALL + .find("sudo /usr/local/bin/lesavka-recovery-ladder snapshot || true") + .unwrap(), + "last-known-good snapshots should only be refreshed after the installed server is verified" + ); +} + +#[test] +fn server_install_treats_pipewire_as_one_coherent_transaction() { + for expected in [ + "pacman_install()", + "libpipewire", + "pipewire-audio", + "pipewire-alsa", + "pipewire-jack", + "breaks dependency '.*pipewire", + "PipeWire packages are at mixed exact versions", + "failed retrieving file", + "sudo pacman -Syu --disable-download-timeout", + ] { + assert!( + SERVER_INSTALL.contains(expected), + "server installer should preserve PipeWire transaction marker {expected}" + ); + } +} + #[test] fn server_install_prefers_invoked_checkout_for_development_installs() { for expected in [ diff --git a/tests/integration/client/runtime_controls/client_live_media_control_integration.rs b/tests/integration/client/runtime_controls/client_live_media_control_integration.rs new file mode 100644 index 0000000..b8f24b1 --- /dev/null +++ b/tests/integration/client/runtime_controls/client_live_media_control_integration.rs @@ -0,0 +1,97 @@ +#![allow(dead_code)] + +// Integration coverage for live media control-file propagation. +// +// Scope: include the shared live-media control grammar and exercise real file +// writes/refreshes using temporary files. +// Targets: `client/src/live_media_control.rs`. +// Why: live device switching must restart only the selected media feed without +// forcing a relay reconnect or silently keeping stale device choices. + +#[path = "../../../../client/src/live_media_control.rs"] +#[allow(dead_code)] +mod live_media_control; + +use lesavka_common::audio_transport::UpstreamAudioCodec; +use live_media_control::{ + LiveMediaControls, MEDIA_CONTROL_ENV, MediaControlState, write_media_control_request, +}; +use temp_env::with_var; + +#[test] +fn live_device_switch_writes_and_reads_all_selected_media_choices() { + let state = MediaControlState::with_devices_and_audio( + true, + true, + true, + Some("/dev/video-lesavka-brio".to_string()), + Some("1280x720@30".to_string()), + Some("alsa_input.usb-focusrite.analog-stereo".to_string()), + Some("alsa_output.usb-headphones.analog-stereo".to_string()), + UpstreamAudioCodec::Opus, + true, + ); + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("media.control"); + + write_media_control_request(&path, state.clone()).expect("write live media controls"); + let raw = std::fs::read_to_string(&path).expect("read live media controls"); + + for marker in [ + "camera=1", + "microphone=1", + "audio=1", + "camera_source=b64:", + "camera_profile=b64:", + "microphone_source=b64:", + "audio_sink=b64:", + "audio_codec=opus", + "noise_suppression=1", + "nonce=", + ] { + assert!( + raw.contains(marker), + "control file should include {marker}: {raw}" + ); + } + with_var( + MEDIA_CONTROL_ENV, + Some(path.to_string_lossy().as_ref()), + || { + let controls = LiveMediaControls::from_env(MediaControlState::new(false, false, false)); + assert_eq!(controls.refresh(), state); + }, + ); +} + +#[test] +fn malformed_live_control_update_keeps_last_good_state() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("media.control"); + let first = MediaControlState::with_devices( + true, + false, + true, + Some("camera-a".to_string()), + Some("1920x1080@30".to_string()), + None, + Some("speaker-a".to_string()), + ); + write_media_control_request(&path, first.clone()).expect("write first control state"); + + with_var( + MEDIA_CONTROL_ENV, + Some(path.to_string_lossy().as_ref()), + || { + let controls = LiveMediaControls::from_env(MediaControlState::new(false, false, false)); + assert_eq!(controls.refresh(), first); + + std::fs::write(&path, "camera=maybe microphone=1 audio=1").expect("write bad state"); + assert_eq!( + controls.refresh(), + first, + "bad live-control files should not replace the last known safe state" + ); + }, + ); +} diff --git a/tests/integration/client/server/audio/client_server_audio_epoch_integration.rs b/tests/integration/client/server/audio/client_server_audio_epoch_integration.rs new file mode 100644 index 0000000..68d2758 --- /dev/null +++ b/tests/integration/client/server/audio/client_server_audio_epoch_integration.rs @@ -0,0 +1,60 @@ +// Integration coverage for client-triggered server audio epoch recovery. +// +// Scope: exercise the public server upstream media runtime the RecoverUac RPC +// calls, and inspect the RPC helper binding. +// Targets: `server/src/upstream_media_runtime.rs` and +// `server/src/main/rpc_helpers.rs`. +// Why: manual and automatic Heal Audio must retire only the stale microphone +// epoch while preserving video playout and calibration offsets. + +use lesavka_server::upstream_media_runtime::UpstreamMediaRuntime; + +const RPC_HELPERS_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/rpc_helpers.rs" +)); + +#[test] +fn recover_uac_rpc_is_bound_to_microphone_epoch_recovery_only() { + let uac_start = RPC_HELPERS_SRC + .find("async fn recover_uac_reply") + .expect("recover_uac_reply should exist"); + let uvc_start = RPC_HELPERS_SRC + .find("async fn recover_uvc_reply") + .expect("recover_uvc_reply should follow recover_uac_reply"); + let uac_block = &RPC_HELPERS_SRC[uac_start..uvc_start]; + + assert!(uac_block.contains("self.upstream_media_rt.soft_recover_microphone();")); + assert!(!uac_block.contains("recover_enumeration")); + assert!(!uac_block.contains("restart_uvc_helper")); + assert!(!uac_block.contains("self.camera_rt.soft_recover")); +} + +#[test] +fn stale_microphone_epoch_recovery_preserves_camera_session_and_offsets() { + let runtime = UpstreamMediaRuntime::new(); + runtime.set_playout_offsets(127_952, 0); + + let camera = runtime.activate_camera(); + let stale_microphone = runtime.activate_microphone(); + let session_id = runtime.snapshot().session_id; + + runtime.soft_recover_microphone(); + + let snapshot = runtime.snapshot(); + assert_eq!(snapshot.session_id, session_id); + assert_eq!(runtime.playout_offsets(), (127_952, 0)); + assert!( + runtime.is_camera_active(camera.generation), + "audio heal should not disturb active video generation" + ); + assert!( + !runtime.is_microphone_active(stale_microphone.generation), + "audio heal should retire the stale microphone generation" + ); + + let fresh_microphone = runtime.activate_microphone(); + assert_eq!(fresh_microphone.session_id, camera.session_id); + assert!(fresh_microphone.generation > stale_microphone.generation); + assert!(runtime.is_microphone_active(fresh_microphone.generation)); +} diff --git a/tests/integration/client/server/input/client_server_input_stream_integration.rs b/tests/integration/client/server/input/client_server_input_stream_integration.rs new file mode 100644 index 0000000..6b695ad --- /dev/null +++ b/tests/integration/client/server/input/client_server_input_stream_integration.rs @@ -0,0 +1,87 @@ +// Simulated client-to-server HID write integration. +// +// Scope: pump keyboard and mouse reports into fake HID endpoint files. +// Targets: `server/src/runtime_support/hid_write.rs` plus shared report +// payload contracts. +// Why: we need confidence that the server-side HID write path preserves report +// bytes and ordering before any real gadget or uinput device is involved. + +use std::sync::Arc; + +use lesavka_server::runtime_support::write_hid_report; +use tokio::sync::Mutex; + +async fn close_fake_hid(handle: &Arc>>) { + let mut guard = handle.lock().await; + if let Some(file) = guard.as_mut() { + file.sync_all().await.expect("sync fake HID endpoint"); + } + drop(guard.take()); +} + +async fn write_reports(path: &std::path::Path, reports: &[[u8; 8]]) { + std::fs::File::create(path).expect("create fake HID endpoint"); + let handle = Arc::new(Mutex::new(None)); + let path = path.to_string_lossy().to_string(); + + for report in reports { + write_hid_report(&handle, &path, report) + .await + .expect("write fake HID report"); + } + + close_fake_hid(&handle).await; +} + +#[tokio::test] +async fn keyboard_reports_reach_fake_hid_writer_in_order() { + let dir = tempfile::tempdir().expect("tempdir"); + let endpoint = dir.path().join("hidg0"); + let reports = [ + [0x02, 0, 0, 0, 0, 0, 0, 0], + [0x02, 0, 0x04, 0, 0, 0, 0, 0], + [0x02, 0, 0, 0, 0, 0, 0, 0], + [0; 8], + ]; + + write_reports(&endpoint, &reports).await; + + let bytes = std::fs::read(endpoint).expect("read fake HID endpoint"); + assert_eq!(bytes, reports.concat()); +} + +#[tokio::test] +async fn keyboard_and_mouse_writers_do_not_cross_contaminate_endpoint_bytes() { + let dir = tempfile::tempdir().expect("tempdir"); + let keyboard_endpoint = dir.path().join("hidg0"); + let mouse_endpoint = dir.path().join("hidg1"); + let keyboard_handle = Arc::new(Mutex::new(None)); + let mouse_handle = Arc::new(Mutex::new(None)); + std::fs::File::create(&keyboard_endpoint).expect("create keyboard endpoint"); + std::fs::File::create(&mouse_endpoint).expect("create mouse endpoint"); + let keyboard_path = keyboard_endpoint.to_string_lossy().to_string(); + let mouse_path = mouse_endpoint.to_string_lossy().to_string(); + + write_hid_report( + &keyboard_handle, + &keyboard_path, + &[0, 0, 0x04, 0, 0, 0, 0, 0], + ) + .await + .expect("write keyboard endpoint"); + let mouse_report = [0x01, 4, 252, 0]; + write_hid_report(&mouse_handle, &mouse_path, &mouse_report) + .await + .expect("write mouse endpoint"); + close_fake_hid(&keyboard_handle).await; + close_fake_hid(&mouse_handle).await; + + assert_eq!( + std::fs::read(keyboard_endpoint).expect("read keyboard endpoint"), + vec![0, 0, 0x04, 0, 0, 0, 0, 0] + ); + assert_eq!( + std::fs::read(mouse_endpoint).expect("read mouse endpoint"), + vec![0x01, 4, 252, 0] + ); +} diff --git a/tests/integration/client/server/upstream/client_server_upstream_bundle_integration.rs b/tests/integration/client/server/upstream/client_server_upstream_bundle_integration.rs new file mode 100644 index 0000000..8a7f849 --- /dev/null +++ b/tests/integration/client/server/upstream/client_server_upstream_bundle_integration.rs @@ -0,0 +1,152 @@ +// Integration coverage for client-shaped bundles entering server v2 planning. +// +// Scope: include the server entrypoint and feed it complete upstream bundles +// that match the client transport schema. +// Targets: `server/src/main/relay_service.rs` and +// `server/src/main/relay_service/upstream_media_rpc.rs`. +// Why: the client can preserve sync only if the server keeps using one capture +// clock, rejects stale whole bundles, and resumes HEVC only from clean pictures. + +#[allow(warnings)] +mod server_main { + include!(env!("LESAVKA_SERVER_MAIN_SRC")); + + mod upstream_bundle_tests { + use super::*; + use lesavka_common::lesavka::{ + AudioEncoding, AudioPacket, UpstreamMediaBundle, VideoPacket, + }; + use serial_test::serial; + use temp_env::with_vars; + + fn hevc_irap() -> Vec { + vec![0, 0, 0, 1, 0x26, 0x01, 0xaa] + } + + fn hevc_delta() -> Vec { + vec![0, 0, 0, 1, 0x02, 0x01, 0xbb] + } + + fn bundle(seq: u64, video_data: Vec, max_queue_age_ms: u32) -> UpstreamMediaBundle { + UpstreamMediaBundle { + session_id: 99, + seq, + capture_start_us: 980_000, + capture_end_us: 1_010_000, + video: Some(VideoPacket { + seq, + pts: 1_000_000, + data: video_data, + client_capture_pts_us: 1_000_000, + client_send_pts_us: 1_005_000, + client_queue_age_ms: max_queue_age_ms, + ..Default::default() + }), + audio: vec![ + AudioPacket { + seq: seq.saturating_mul(2), + pts: 980_000, + data: vec![0x11; 1_920], + client_capture_pts_us: 980_000, + client_send_pts_us: 1_005_000, + client_queue_age_ms: 25, + ..Default::default() + }, + AudioPacket { + seq: seq.saturating_mul(2).saturating_add(1), + pts: 1_010_000, + data: vec![0x22; 1_920], + client_capture_pts_us: 1_010_000, + client_send_pts_us: 1_015_000, + client_queue_age_ms: 5, + ..Default::default() + }, + ], + audio_sample_rate: 48_000, + audio_channels: 2, + audio_encoding: AudioEncoding::PcmS16le as i32, + video_width: 1280, + video_height: 720, + video_fps: 30, + } + } + + #[test] + #[serial] + fn client_bundle_summary_preserves_one_capture_span_for_server_handoff() { + let bundle = bundle(1, hevc_irap(), 25); + let facts = summarize_media_v2_bundle(&bundle).expect("bundle facts"); + + assert!(facts.has_audio); + assert!(facts.has_video); + assert_eq!(facts.capture_start_us, 980_000); + assert_eq!(facts.capture_end_us, 1_010_000); + assert_eq!(facts.capture_span_us, 30_000); + assert_eq!(facts.max_queue_age_ms, 25); + } + + #[test] + #[serial] + fn server_rejects_stale_client_bundle_before_handoff_workers_can_play_it() { + with_vars( + [ + ("LESAVKA_UPSTREAM_V2_MAX_LIVE_AGE_MS", Some("1000")), + ("LESAVKA_UPSTREAM_V2_PLAYOUT_DELAY_MS", Some("20")), + ], + || { + let facts = summarize_media_v2_bundle(&bundle(2, hevc_irap(), 1_000)) + .expect("bundle facts"); + + assert!( + media_v2_handoff_schedule(facts, 0, 0).is_none(), + "queue age at the max-live budget must drop the whole bundle before UAC/UVC handoff" + ); + }, + ); + } + + #[test] + #[serial] + fn server_schedule_preserves_relative_audio_video_offsets_without_blocking_ingress() { + with_vars( + [ + ("LESAVKA_UPSTREAM_V2_MAX_LIVE_AGE_MS", Some("1000")), + ("LESAVKA_UPSTREAM_V2_PLAYOUT_DELAY_MS", Some("20")), + ], + || { + let facts = summarize_media_v2_bundle(&bundle(3, hevc_irap(), 30)) + .expect("bundle facts"); + let schedule = + media_v2_handoff_schedule(facts, 0, 120_000).expect("handoff schedule"); + + assert!(schedule.audio_due_at.is_some()); + assert!(schedule.video_due_at.is_some()); + assert_eq!(schedule.common_delay, Duration::from_millis(20)); + assert_eq!(schedule.relative_audio_delay, Duration::ZERO); + assert_eq!(schedule.relative_video_delay, Duration::from_millis(120)); + }, + ); + } + + #[test] + fn server_hevc_recovery_holds_delta_frames_until_the_next_irap_bundle() { + let delta = bundle(4, hevc_delta(), 10); + let keyframe = bundle(5, hevc_irap(), 10); + + assert!(media_v2_should_hold_hevc_video_for_recovery( + true, + camera::CameraCodec::Hevc, + delta.video.as_ref() + )); + assert!(!media_v2_should_hold_hevc_video_for_recovery( + true, + camera::CameraCodec::Hevc, + keyframe.video.as_ref() + )); + assert!(media_v2_has_hevc_recovery_keyframe( + camera::CameraCodec::Hevc, + keyframe.video.as_ref() + )); + } + } +} diff --git a/tests/integration/common/proto/relay_opus_proto_integration_contract.rs b/tests/integration/common/proto/relay_opus_proto_integration_contract.rs new file mode 100644 index 0000000..db5420c --- /dev/null +++ b/tests/integration/common/proto/relay_opus_proto_integration_contract.rs @@ -0,0 +1,65 @@ +// Protocol contract for optional Opus audio transport. +// +// Scope: inspect the protobuf schema and generated Rust structs. +// Targets: `common/proto/lesavka.proto`. +// Why: Opus must be introduced as an explicit payload codec on Lesavka's +// bundled gRPC transport, not as a silent SIP/RTP side channel. + +use lesavka_common::audio_transport::{AudioTransportProfile, bundle_audio_profile}; +use lesavka_common::lesavka::{AudioEncoding, AudioPacket, UpstreamMediaBundle}; + +const PROTO: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/common/proto/lesavka.proto" +)); + +#[test] +fn proto_exposes_opus_without_adding_sip_or_rtp_transport() { + for expected in [ + "enum AudioEncoding", + "PCM_S16LE = 1", + "OPUS = 2", + "AudioEncoding encoding = 9", + "uint32 sample_rate = 10", + "uint32 channels = 11", + "uint32 frame_duration_us = 12", + "AudioEncoding audio_encoding = 12", + "rpc StreamWebcamMedia(stream UpstreamMediaBundle)", + ] { + assert!( + PROTO.contains(expected), + "proto should preserve Opus transport marker {expected}" + ); + } + + for forbidden in ["SIP", "RTP", "sipp", "rtpmap"] { + assert!( + !PROTO.contains(forbidden), + "Opus testing should stay on Lesavka bundled gRPC transport, not {forbidden}" + ); + } +} + +#[test] +fn generated_bundle_can_describe_opus_audio_payloads() { + let packet = AudioPacket { + encoding: AudioEncoding::Opus as i32, + sample_rate: 48_000, + channels: 2, + frame_duration_us: 20_000, + data: vec![0x41; 160], + ..AudioPacket::default() + }; + let bundle = UpstreamMediaBundle { + audio_encoding: AudioEncoding::Opus as i32, + audio_sample_rate: 48_000, + audio_channels: 2, + audio: vec![packet], + ..UpstreamMediaBundle::default() + }; + + assert_eq!( + bundle_audio_profile(&bundle), + AudioTransportProfile::opus_voice() + ); +} diff --git a/tests/integration/common/proto/relay_proto_integration_contract.rs b/tests/integration/common/proto/relay_proto_integration_contract.rs new file mode 100644 index 0000000..089010d --- /dev/null +++ b/tests/integration/common/proto/relay_proto_integration_contract.rs @@ -0,0 +1,52 @@ +// Integration contract between the protobuf API and server implementation. +// +// Scope: compare the generated service surface contract with the relay handler +// implementation files that dispatch each RPC. +// Targets: `common/proto/lesavka.proto` and `server/src/main/relay_service.rs`. +// Why: client and server can compile independently while still drifting at the +// protocol boundary; this test keeps the end-to-end RPC seam visible. + +const PROTO: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/common/proto/lesavka.proto" +)); +const RELAY_SERVICE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_service.rs" +)); +const HANDSHAKE_SERVICE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/handshake.rs" +)); + +#[test] +fn proto_rpc_surface_has_matching_relay_dispatch_methods() { + for (rpc, method) in [ + ("StreamKeyboard", "stream_keyboard_rpc"), + ("StreamMouse", "stream_mouse_rpc"), + ("CaptureVideo", "capture_video_rpc"), + ("CaptureAudio", "capture_audio_rpc"), + ("StreamMicrophone", "stream_microphone_rpc"), + ("StreamCamera", "stream_camera_rpc"), + ("StreamWebcamMedia", "stream_webcam_media_rpc"), + ("RunOutputDelayProbe", "run_output_delay_probe_rpc"), + ("PasteText", "paste_text_rpc"), + ("RecoverUsb", "recover_usb_rpc"), + ("RecoverUac", "recover_uac_rpc"), + ("RecoverUvc", "recover_uvc_rpc"), + ] { + assert!( + PROTO.contains(&format!("rpc {rpc}")), + "missing proto RPC {rpc}" + ); + assert!( + RELAY_SERVICE.contains(method), + "relay service should dispatch {rpc} through {method}" + ); + } + + assert!( + PROTO.contains("rpc GetCapabilities") && HANDSHAKE_SERVICE.contains("get_capabilities"), + "handshake service should implement the GetCapabilities API" + ); +} diff --git a/tests/integration/server/video_sinks/hevc_mjpeg_spool_integration.rs b/tests/integration/server/video_sinks/hevc_mjpeg_spool_integration.rs new file mode 100644 index 0000000..9284436 --- /dev/null +++ b/tests/integration/server/video_sinks/hevc_mjpeg_spool_integration.rs @@ -0,0 +1,103 @@ +// Integration coverage for the MJPEG spool used by UVC output. +// +// Scope: write real frame and metadata files through the same spool helper +// used by direct MJPEG ingress and decoded HEVC ingress. +// Targets: server/src/video_sinks/mjpeg_spool.rs. +// Why: both profiles share the UVC helper handoff path, so metadata must keep +// them separated without changing the atomic frame publication behavior. + +use std::fs; + +use serial_test::serial; + +#[allow(dead_code, clippy::items_after_test_module)] +mod spool { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/video_sinks/mjpeg_spool.rs" + )); + + pub fn write_hevc( + path: &std::path::Path, + data: &[u8], + source_pts_us: u64, + decoded_pts_us: Option, + ) -> anyhow::Result<()> { + spool_mjpeg_frame_with_timing( + path, + data, + Some(MjpegSpoolTiming::hevc_decoded_mjpeg( + source_pts_us, + decoded_pts_us, + )), + ) + } + + pub fn write_mjpeg( + path: &std::path::Path, + data: &[u8], + source_pts_us: u64, + ) -> anyhow::Result<()> { + spool_mjpeg_frame_with_timing( + path, + data, + Some(MjpegSpoolTiming::mjpeg_passthrough(source_pts_us)), + ) + } +} + +#[test] +#[serial] +fn hevc_decoded_mjpeg_spool_writes_frame_and_timing_metadata() { + let dir = tempfile::tempdir().expect("tempdir"); + let frame_path = dir.path().join("uvc-frame.mjpg"); + let log_path = dir.path().join("uvc-frame.jsonl"); + let log_path_text = log_path.to_string_lossy().to_string(); + let frame = [0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]; + + temp_env::with_vars( + [ + ("LESAVKA_UVC_FRAME_META", Some("1")), + ( + "LESAVKA_UVC_FRAME_META_LOG_PATH", + Some(log_path_text.as_str()), + ), + ("LESAVKA_UVC_FRAME_META_PATH", None), + ], + || { + spool::write_hevc(&frame_path, &frame, 42_000, Some(44_000)) + .expect("spool decoded HEVC frame"); + }, + ); + + assert_eq!(fs::read(&frame_path).expect("frame bytes"), frame); + + let sidecar = frame_path.with_extension("mjpg.meta.json"); + let sidecar_text = fs::read_to_string(sidecar).expect("sidecar metadata"); + let log_text = fs::read_to_string(log_path).expect("metadata log"); + for text in [sidecar_text.as_str(), log_text.as_str()] { + assert!(text.contains("\"profile\":\"hevc-decoded-mjpeg\"")); + assert!(text.contains("\"bytes\":6")); + assert!(text.contains("\"source_pts_us\":42000")); + assert!(text.contains("\"decoded_pts_us\":44000")); + } +} + +#[test] +#[serial] +fn mjpeg_passthrough_spool_keeps_decode_timing_null() { + let dir = tempfile::tempdir().expect("tempdir"); + let frame_path = dir.path().join("uvc-frame.mjpg"); + let frame = [0xff, 0xd8, 0x33, 0x44, 0xff, 0xd9]; + + temp_env::with_var("LESAVKA_UVC_FRAME_META", Some("1"), || { + spool::write_mjpeg(&frame_path, &frame, 55_000).expect("spool MJPEG frame"); + }); + + assert_eq!(fs::read(&frame_path).expect("frame bytes"), frame); + let sidecar_text = + fs::read_to_string(frame_path.with_extension("mjpg.meta.json")).expect("sidecar metadata"); + assert!(sidecar_text.contains("\"profile\":\"mjpeg-passthrough\"")); + assert!(sidecar_text.contains("\"source_pts_us\":55000")); + assert!(sidecar_text.contains("\"decoded_pts_us\":null")); +} diff --git a/tests/lib.rs b/tests/lib.rs new file mode 100644 index 0000000..95a5ddc --- /dev/null +++ b/tests/lib.rs @@ -0,0 +1,10 @@ +//! Root-owned test harness crate for the Lesavka workspace. +//! +//! Scope: provide Cargo with a package target for explicitly registered tests +//! under the repository-level `tests/` taxonomy. +//! Targets: categorized test modules under `tests/` and the CI coverage +//! contract consumed by the quality gate. +//! Why: keeping test bodies in one categorized tree makes ownership clearer than +//! a separate shim crate while still preserving normal Cargo execution. + +#![forbid(unsafe_code)] diff --git a/tests/manual/artifacts/probe_artifact_contract.rs b/tests/manual/artifacts/probe_artifact_contract.rs new file mode 100644 index 0000000..a068ba1 --- /dev/null +++ b/tests/manual/artifacts/probe_artifact_contract.rs @@ -0,0 +1,109 @@ +// Contracts for manual probe artifact output. +// +// Scope: inspect manual lab scripts and summary helpers. +// Targets: scripts under `scripts/manual/`. +// Why: manual hardware probes are expensive to rerun; every run should leave +// structured JSON, readable TXT, logs, and clear missing-artifact messages. + +const CLIENT_RCT_PROBE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_client_to_rct_transport_probe.sh" +)); +const SERVER_RCT_MATRIX: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_server_to_rc_mode_matrix.sh" +)); +const GOOGLE_MEET_OBSERVER: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_google_meet_observer_probe.sh" +)); +const UVC_META_FETCH: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/client_rct_uvc_frame_meta_fetch.sh" +)); +const CLIENT_RCT_SUMMARY: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/client_rct_transport_summary.py" +)); +const CLIENT_RCT_LAYERS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/client_rct_transport_layers.py" +)); + +#[test] +fn client_to_rct_probe_prints_all_core_artifact_paths() { + for marker in [ + "artifact_dir: ${LOCAL_REPORT_DIR}", + "capture: ${LOCAL_CAPTURE}", + "report_json: ${LOCAL_REPORT_JSON}", + "report_txt: ${LOCAL_REPORT_TXT}", + "events_csv: ${LOCAL_EVENTS_CSV}", + "client_timeline_json: ${LOCAL_CLIENT_TIMELINE_JSON}", + "clock_alignment_json: ${LOCAL_CLOCK_ALIGNMENT_JSON}", + "transport_summary_json: ${LOCAL_TRANSPORT_SUMMARY_JSON}", + "transport_summary_txt: ${LOCAL_TRANSPORT_SUMMARY_TXT}", + "upstream_sync_jsonl: ${LOCAL_UPSTREAM_SYNC_JSONL}", + "client_send_jsonl: ${LOCAL_CLIENT_SEND_JSONL}", + "run_log: ${LOCAL_RUN_LOG}", + ] { + assert!( + CLIENT_RCT_PROBE.contains(marker), + "client-to-RCT probe should print artifact marker {marker}" + ); + } +} + +#[test] +fn server_rct_matrix_preserves_summary_json_txt_csv_and_run_log() { + for marker in [ + "mode_matrix_summary_json: ${MATRIX_SUMMARY_JSON}", + "mode_matrix_summary_csv: ${MATRIX_SUMMARY_CSV}", + "mode_matrix_summary_txt: ${MATRIX_SUMMARY_TXT}", + "mode_delay_recommendations_json: ${MATRIX_DELAY_JSON}", + "mode_static_calibration_json: ${MATRIX_STATIC_JSON}", + "mode_matrix_run_log: ${MATRIX_RUN_LOG}", + "\"failure_reasons\": reasons", + "\"artifact_dir\": str(root)", + ] { + assert!( + SERVER_RCT_MATRIX.contains(marker), + "server-to-RCT matrix should preserve artifact marker {marker}" + ); + } +} + +#[test] +fn google_meet_observer_keeps_manual_artifacts_and_optional_capture_guidance() { + for marker in [ + "operator_checklist: ${OPERATOR_CHECKLIST}", + "manual_timing_json: ${MANUAL_TIMING_JSON}", + "client_timeline_json: ${CLIENT_TIMELINE_JSON}", + "LESAVKA_MEET_OBSERVER_CAPTURE", + "observer capture analysis skipped", + "Path to observer recording for analysis", + ] { + assert!( + GOOGLE_MEET_OBSERVER.contains(marker), + "Google Meet observer should preserve manual artifact marker {marker}" + ); + } +} + +#[test] +fn missing_optional_artifacts_are_reported_clearly() { + for marker in [ + "required UVC frame metadata log was unavailable", + "optional UVC frame metadata log unavailable", + "required UVC frame metadata log could not be summarized", + "optional UVC frame metadata log could not be summarized", + "freshness_bottleneck", + "evidence_incomplete", + ] { + assert!( + UVC_META_FETCH.contains(marker) + || CLIENT_RCT_SUMMARY.contains(marker) + || CLIENT_RCT_LAYERS.contains(marker), + "manual probes should clearly explain missing/weak artifact condition {marker}" + ); + } +} diff --git a/testing/tests/client_manual_sync_script_contract.rs b/tests/manual/client/sync_probe/client_manual_sync_script_contract.rs similarity index 97% rename from testing/tests/client_manual_sync_script_contract.rs rename to tests/manual/client/sync_probe/client_manual_sync_script_contract.rs index 793d5fa..ad6f0b7 100644 --- a/testing/tests/client_manual_sync_script_contract.rs +++ b/tests/manual/client/sync_probe/client_manual_sync_script_contract.rs @@ -1,11 +1,14 @@ -//! Contract tests for the manual upstream A/V sync harness. -//! -//! Scope: statically guard the workstation-side tunnel/bootstrap behavior. -//! Targets: `scripts/manual/run_upstream_av_sync.sh`. -//! Why: the manual probe should reach Theia through SSH even when the gRPC -//! port is not exposed on the public SSH endpoint. +// Contract tests for the manual upstream A/V sync harness. +// +// Scope: statically guard the workstation-side tunnel/bootstrap behavior. +// Targets: `scripts/manual/run_upstream_av_sync.sh`. +// Why: the manual probe should reach Theia through SSH even when the gRPC +// port is not exposed on the public SSH endpoint. -const SYNC_SCRIPT: &str = include_str!("../../scripts/manual/run_upstream_av_sync.sh"); +const SYNC_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_upstream_av_sync.sh" +)); #[test] fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() { for expected in [ diff --git a/testing/tests/client_rct_transport_probe_contract.rs b/tests/manual/client/sync_probe/client_rct_transport_probe_contract.rs similarity index 74% rename from testing/tests/client_rct_transport_probe_contract.rs rename to tests/manual/client/sync_probe/client_rct_transport_probe_contract.rs index 2d2b0ce..9b4159b 100644 --- a/testing/tests/client_rct_transport_probe_contract.rs +++ b/tests/manual/client/sync_probe/client_rct_transport_probe_contract.rs @@ -1,29 +1,51 @@ -//! Contract tests for the client-origin transport-to-RCT probe. -//! -//! Scope: statically guard the black-box transport harness and client timeline -//! artifacts used for client->server tuning. -//! Targets: `scripts/manual/run_client_to_rct_transport_probe.sh`, -//! `scripts/manual/client_rct_transport_summary.py`, and -//! `scripts/manual/client_rct_uvc_frame_meta_fetch.sh`, and -//! `client/src/sync_probe/`. -//! Why: this probe is the bridge between synthetic bundled client media and the -//! final RCT sync/freshness/smoothness evidence, so it must not drift into -//! split-stream or sudo-mutating behavior. +// Contract tests for the client-origin transport-to-RCT probe. +// +// Scope: statically guard the black-box transport harness and client timeline +// artifacts used for client->server tuning. +// Targets: `scripts/manual/run_client_to_rct_transport_probe.sh`, +// `scripts/manual/client_rct_transport_summary.py`, and +// `scripts/manual/client_rct_uvc_frame_meta_fetch.sh`, and +// `client/src/sync_probe/`. +// Why: this probe is the bridge between synthetic bundled client media and the +// final RCT sync/freshness/smoothness evidence, so it must not drift into +// split-stream or sudo-mutating behavior. -const CLIENT_RCT_SCRIPT: &str = - include_str!("../../scripts/manual/run_client_to_rct_transport_probe.sh"); -const CLIENT_RCT_SUMMARY: &str = - include_str!("../../scripts/manual/client_rct_transport_summary.py"); -const CLIENT_RCT_LAYERS: &str = include_str!("../../scripts/manual/client_rct_transport_layers.py"); -const UVC_FRAME_META_FETCH: &str = - include_str!("../../scripts/manual/client_rct_uvc_frame_meta_fetch.sh"); -const UVC_FRAME_META_SUMMARY: &str = - include_str!("../../scripts/manual/summarize_uvc_frame_meta_log.py"); -const SYNC_PROBE_CONFIG: &str = include_str!("../../client/src/sync_probe/config.rs"); -const SYNC_PROBE_RUNNER: &str = include_str!("../../client/src/sync_probe/runner.rs"); -const SYNC_PROBE_BUNDLED_TRANSPORT: &str = - include_str!("../../client/src/sync_probe/runner/bundled_transport.rs"); -const SYNC_PROBE_TIMELINE: &str = include_str!("../../client/src/sync_probe/timeline.rs"); +const CLIENT_RCT_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_client_to_rct_transport_probe.sh" +)); +const CLIENT_RCT_SUMMARY: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/client_rct_transport_summary.py" +)); +const CLIENT_RCT_LAYERS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/client_rct_transport_layers.py" +)); +const UVC_FRAME_META_FETCH: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/client_rct_uvc_frame_meta_fetch.sh" +)); +const UVC_FRAME_META_SUMMARY: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/summarize_uvc_frame_meta_log.py" +)); +const SYNC_PROBE_CONFIG: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/sync_probe/config.rs" +)); +const SYNC_PROBE_RUNNER: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/sync_probe/runner.rs" +)); +const SYNC_PROBE_BUNDLED_TRANSPORT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/sync_probe/runner/bundled_transport.rs" +)); +const SYNC_PROBE_TIMELINE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/sync_probe/timeline.rs" +)); #[test] fn client_rct_probe_injects_synthetic_media_through_bundled_transport() { @@ -125,6 +147,9 @@ fn client_rct_probe_is_non_mutating_and_passwordless_by_default() { "LESAVKA_CLIENT_RCT_START_DELAY_SECONDS=${LESAVKA_CLIENT_RCT_START_DELAY_SECONDS:-0}", "LESAVKA_CLIENT_RCT_START_DELAY_SECONDS must be a non-negative number", "start_delay=${LESAVKA_CLIENT_RCT_START_DELAY_SECONDS}s", + "REMOTE_CAPTURE_PREROLL_SETTLE_SECONDS=${REMOTE_CAPTURE_PREROLL_SETTLE_SECONDS:-1}", + "settling %ss after preroll discard", + "timeout --kill-after=5 --signal=INT \"$((seconds + 5))\"", "ExitOnForwardFailure=yes", "127.0.0.1:${local_port}:127.0.0.1:${SERVER_TUNNEL_REMOTE_PORT}", ] { diff --git a/testing/tests/client_server_rc_matrix_script_contract.rs b/tests/manual/client/sync_probe/client_server_rc_matrix_script_contract.rs similarity index 95% rename from testing/tests/client_server_rc_matrix_script_contract.rs rename to tests/manual/client/sync_probe/client_server_rc_matrix_script_contract.rs index 6053e09..2d1e352 100644 --- a/testing/tests/client_server_rc_matrix_script_contract.rs +++ b/tests/manual/client/sync_probe/client_server_rc_matrix_script_contract.rs @@ -1,13 +1,15 @@ -//! Contract tests for the server-to-RCT mode matrix harness. -//! -//! Scope: statically guard the hardware-in-the-loop matrix script defaults and -//! summary artifacts. -//! Targets: `scripts/manual/run_server_to_rc_mode_matrix.sh`. -//! Why: server-to-RCT calibration is now considered complete, so the matrix -//! script must keep the blessed offsets and evidence outputs stable. +// Contract tests for the server-to-RCT mode matrix harness. +// +// Scope: statically guard the hardware-in-the-loop matrix script defaults and +// summary artifacts. +// Targets: `scripts/manual/run_server_to_rc_mode_matrix.sh`. +// Why: server-to-RCT calibration is now considered complete, so the matrix +// script must keep the blessed offsets and evidence outputs stable. -const SERVER_RC_MODE_MATRIX_SCRIPT: &str = - include_str!("../../scripts/manual/run_server_to_rc_mode_matrix.sh"); +const SERVER_RC_MODE_MATRIX_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_server_to_rc_mode_matrix.sh" +)); #[test] fn server_rc_mode_matrix_validates_advertised_uvc_profiles() { diff --git a/tests/manual/google_meet/google_meet_observer_manual_contract.rs b/tests/manual/google_meet/google_meet_observer_manual_contract.rs new file mode 100644 index 0000000..8956ee3 --- /dev/null +++ b/tests/manual/google_meet/google_meet_observer_manual_contract.rs @@ -0,0 +1,64 @@ +// Manual Google Meet observer contract. +// +// Scope: protect the operator-assisted Google Meet observer harness and its +// optional recording analysis path. +// Targets: scripts/manual/run_google_meet_observer_probe.sh. +// Why: Meet is a real-world consumer of the RCT UVC/UAC output, but the test +// must stay manual, artifact-backed, and free of browser credential automation. + +const GOOGLE_MEET_OBSERVER_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_google_meet_observer_probe.sh" +)); + +#[test] +fn google_meet_observer_stays_manual_and_artifact_backed() { + for marker in [ + "Google Meet observer probe checklist", + "no Google credentials, sudo, or browser automation are used", + "operator-checklist.txt", + "manual-timing.json", + "client-transport-timeline.json", + "LESAVKA_MEET_OBSERVER_CAPTURE=${LESAVKA_MEET_OBSERVER_CAPTURE:-}", + "observer-google-meet-capture", + "Observer capture times are operator-confirmed", + "Path to observer recording for analysis (blank to skip)", + ] { + assert!( + GOOGLE_MEET_OBSERVER_SCRIPT.contains(marker), + "Google Meet observer harness should preserve marker {marker}" + ); + } +} + +#[test] +fn google_meet_observer_uses_synthetic_bundled_media_without_secret_automation() { + for marker in [ + "lesavka-sync-probe", + "--event-width-codes \"${PROBE_EVENT_WIDTH_CODES}\"", + "--timeline-json \"${CLIENT_TIMELINE_JSON}\"", + "running synthetic bundled media through Google Meet path", + "lesavka-sync-analyze", + ] { + assert!( + GOOGLE_MEET_OBSERVER_SCRIPT.contains(marker), + "Google Meet observer harness should preserve synthetic marker {marker}" + ); + } + + for forbidden in [ + "chromedriver", + "geckodriver", + "google-chrome --remote-debugging", + "sudo ", + "PASSWORD", + "VAULT", + "vault", + "read -s", + ] { + assert!( + !GOOGLE_MEET_OBSERVER_SCRIPT.contains(forbidden), + "Google Meet observer harness must remain manual/non-secret: {forbidden}" + ); + } +} diff --git a/testing/tests/uvc_frame_meta_log_contract.rs b/tests/manual/scripts/manual/uvc_frame_meta_log_contract.rs similarity index 84% rename from testing/tests/uvc_frame_meta_log_contract.rs rename to tests/manual/scripts/manual/uvc_frame_meta_log_contract.rs index ecbc62f..7852d82 100644 --- a/testing/tests/uvc_frame_meta_log_contract.rs +++ b/tests/manual/scripts/manual/uvc_frame_meta_log_contract.rs @@ -1,22 +1,22 @@ -//! Contract tests for UVC frame metadata artifact summarization. -//! -//! Scope: optional `LESAVKA_UVC_FRAME_META_LOG_PATH` artifacts. -//! Targets: `scripts/manual/summarize_uvc_frame_meta_log.py`. -//! Why: HEVC client-to-RCT work needs a safe local way to decide whether -//! event-coded frames reached the UVC spool before we add riskier server-side -//! introspection. +// Contract tests for UVC frame metadata artifact summarization. +// +// Scope: optional `LESAVKA_UVC_FRAME_META_LOG_PATH` artifacts. +// Targets: `scripts/manual/summarize_uvc_frame_meta_log.py`. +// Why: HEVC client-to-RCT work needs a safe local way to decide whether +// event-coded frames reached the UVC spool before we add riskier server-side +// introspection. use std::{fs, path::PathBuf, process::Command}; use serde_json::Value; -const SUMMARIZER: &str = include_str!("../../scripts/manual/summarize_uvc_frame_meta_log.py"); +const SUMMARIZER: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/summarize_uvc_frame_meta_log.py" +)); fn repo_script_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("repo root") - .join("scripts/manual/summarize_uvc_frame_meta_log.py") + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("scripts/manual/summarize_uvc_frame_meta_log.py") } #[test] diff --git a/tests/manual/server/rct/server_rct_mode_matrix_manual_contract.rs b/tests/manual/server/rct/server_rct_mode_matrix_manual_contract.rs new file mode 100644 index 0000000..24e4a41 --- /dev/null +++ b/tests/manual/server/rct/server_rct_mode_matrix_manual_contract.rs @@ -0,0 +1,72 @@ +// Manual/lab contract for the server-to-RCT mode matrix. +// +// Scope: protect the hardware-backed matrix script as an artifact-producing +// manual test, not a default CI mutation. +// Targets: scripts/manual/run_server_to_rc_mode_matrix.sh. +// Why: server-to-RCT calibration values should remain rerunnable and auditable +// without accidentally turning routine CI into a gadget-resetting lab run. + +const MATRIX_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_server_to_rc_mode_matrix.sh" +)); + +#[test] +fn matrix_remains_operator_controlled_and_delayable() { + for marker in [ + "Manual: validate server-generated UVC/UAC output", + "LESAVKA_SERVER_RC_RECONFIGURE=${LESAVKA_SERVER_RC_RECONFIGURE:-0}", + "LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY=${LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY:-1}", + "Theia sudo password for %s", + "LESAVKA_SERVER_RC_START_DELAY_SECONDS=${LESAVKA_SERVER_RC_START_DELAY_SECONDS:-0}", + "remote sudo has already been primed; sleeping before prebuild/reconfigure/capture", + "mode-matrix-run.log", + ] { + assert!( + MATRIX_SCRIPT.contains(marker), + "server-to-RCT matrix should preserve operator-control marker {marker}" + ); + } +} + +#[test] +fn matrix_preserves_sync_freshness_smoothness_and_coded_evidence_outputs() { + for marker in [ + "LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS=${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS:-350}", + "LESAVKA_SERVER_RC_MIN_CODED_PAIRS=${LESAVKA_SERVER_RC_MIN_CODED_PAIRS:-${LESAVKA_SERVER_RC_FRESHNESS_MIN_PAIRS}}", + "LESAVKA_SERVER_RC_REQUIRE_ALL_CODED_PAIRS=${LESAVKA_SERVER_RC_REQUIRE_ALL_CODED_PAIRS:-0}", + "LESAVKA_SERVER_RC_REQUIRE_SMOOTHNESS_PASS=${LESAVKA_SERVER_RC_REQUIRE_SMOOTHNESS_PASS:-0}", + "smoothness_warnings", + "smoothness warning:", + "signature_expected_event_count", + "signature_paired_event_count", + "mode-matrix-summary.json", + "mode-static-calibration.json", + ] { + assert!( + MATRIX_SCRIPT.contains(marker), + "server-to-RCT matrix should preserve evidence marker {marker}" + ); + } +} + +#[test] +fn matrix_keeps_mjpeg_and_hevc_profiles_rerunnable() { + for marker in [ + "LESAVKA_SERVER_RC_MJPEG_MODE_DELAYS_US", + "LESAVKA_SERVER_RC_HEVC_MODE_DELAYS_US", + "LESAVKA_SERVER_RC_NORMALIZED_PROFILE=mjpeg", + "LESAVKA_SERVER_RC_NORMALIZED_PROFILE=hevc", + "LESAVKA_SERVER_RC_AUDIO_PROFILE", + "LESAVKA_SERVER_RC_CALIBRATION_PROFILE", + "LESAVKA_CALIBRATION_PROFILE", + "LESAVKA_UPLINK_CAMERA_CODEC", + "LESAVKA_UPLINK_AUDIO_CODEC", + "LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US", + ] { + assert!( + MATRIX_SCRIPT.contains(marker), + "server-to-RCT matrix should preserve profile marker {marker}" + ); + } +} diff --git a/tests/performance/client/server/rct/client_server_rct_timing_budget_contract.rs b/tests/performance/client/server/rct/client_server_rct_timing_budget_contract.rs new file mode 100644 index 0000000..83bb8ae --- /dev/null +++ b/tests/performance/client/server/rct/client_server_rct_timing_budget_contract.rs @@ -0,0 +1,97 @@ +// Performance contract for client->server->RCT stage timing. +// +// Scope: model the T0-T5 timing budget emitted by the black-box transport +// summary and upstream sampler. +// Targets: scripts/manual/client_rct_transport_summary.py and +// scripts/manual/client_rct_transport_layers.py. +// Why: the full upstream route should stay below one second end-to-end while +// preserving sync and keeping server sink lateness small enough to avoid drift. + +const TRANSPORT_SUMMARY: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/client_rct_transport_summary.py" +)); +const TRANSPORT_LAYERS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/client_rct_transport_layers.py" +)); + +#[derive(Clone, Copy)] +struct RouteStageSample { + client_queue_ms: f64, + transport_receive_ms: f64, + decode_spool_ms: f64, + final_rct_ms: f64, + sink_late_ms: f64, +} + +impl RouteStageSample { + fn end_to_end_ms(self) -> f64 { + self.client_queue_ms + self.transport_receive_ms + self.decode_spool_ms + self.final_rct_ms + } +} + +fn percentile_95(values: &mut [f64]) -> f64 { + values.sort_by(|a, b| a.total_cmp(b)); + let index = ((values.len() as f64) * 0.95).ceil() as usize - 1; + values[index.min(values.len() - 1)] +} + +fn representative_hevc_route_samples() -> Vec { + (0..32) + .map(|idx| RouteStageSample { + client_queue_ms: 18.0 + f64::from(idx % 5), + transport_receive_ms: 145.0 + f64::from(idx % 7) * 6.0, + decode_spool_ms: 95.0 + f64::from(idx % 4) * 5.0, + final_rct_ms: 250.0 + f64::from(idx % 6) * 10.0, + sink_late_ms: 2.0 + f64::from(idx % 4), + }) + .collect() +} + +#[test] +fn t0_to_t5_route_budget_stays_under_one_second_and_sink_late_under_20ms() { + for marker in [ + "video_age_p95_ms", + "audio_age_p95_ms", + "server_receive_age_p95_ms", + "sink_late_p95_ms", + "post_client_send_worst_p95_ms", + "freshness_bottleneck", + ] { + assert!( + TRANSPORT_SUMMARY.contains(marker) || TRANSPORT_LAYERS.contains(marker), + "transport summary should preserve T0-T5 marker {marker}" + ); + } + + let samples = representative_hevc_route_samples(); + let mut end_to_end: Vec = samples + .iter() + .map(|sample| sample.end_to_end_ms()) + .collect(); + let mut sink_late: Vec = samples.iter().map(|sample| sample.sink_late_ms).collect(); + + assert!(percentile_95(&mut end_to_end) < 1_000.0); + assert!(percentile_95(&mut sink_late) < 20.0); +} + +#[test] +fn queue_growth_is_bounded_in_the_route_budget_model() { + let samples = representative_hevc_route_samples(); + let first = samples.first().expect("first sample").client_queue_ms; + let last = samples.last().expect("last sample").client_queue_ms; + let max = samples + .iter() + .map(|sample| sample.client_queue_ms) + .fold(0.0, f64::max); + + assert!( + last - first < 10.0, + "client queue should not monotonically grow" + ); + assert!( + max < 150.0, + "client queue p95 budget should remain fresh-first" + ); +} diff --git a/testing/tests/client_uplink_performance_contract.rs b/tests/performance/client/uplink/client_uplink_performance_contract.rs similarity index 55% rename from testing/tests/client_uplink_performance_contract.rs rename to tests/performance/client/uplink/client_uplink_performance_contract.rs index 3fbd6b9..6f6b699 100644 --- a/testing/tests/client_uplink_performance_contract.rs +++ b/tests/performance/client/uplink/client_uplink_performance_contract.rs @@ -1,17 +1,23 @@ -//! Synthetic performance contract for the upstream media queues. -//! -//! Scope: deterministic backpressure simulation without physical devices. -//! Targets: `client/src/uplink_latency_harness.rs`. -//! Why: A/V sync depends on dropping stale media under stalls instead of -//! preserving a growing delay buffer. +// Synthetic performance contract for the upstream media queues. +// +// Scope: deterministic backpressure simulation without physical devices. +// Targets: `client/src/uplink_latency_harness.rs`. +// Why: A/V sync depends on dropping stale media under stalls instead of +// preserving a growing delay buffer. -#[path = "../../client/src/uplink_latency_harness.rs"] +#[path = "../../../../client/src/uplink_latency_harness.rs"] #[allow(warnings)] mod uplink_latency_harness; use std::time::Duration; use uplink_latency_harness::{UplinkHarnessConfig, UplinkQueuePolicy, run_uplink_harness}; +#[derive(Clone, Copy)] +struct BundleTiming { + client_queue_age_ms: u32, + server_receive_age_ms: u32, +} + fn camera_stall_config() -> UplinkHarnessConfig { UplinkHarnessConfig { capture_interval: Duration::from_millis(33), @@ -36,6 +42,28 @@ fn microphone_stall_config() -> UplinkHarnessConfig { } } +fn percentile_95(values: &mut [u32]) -> u32 { + assert!(!values.is_empty(), "p95 requires at least one sample"); + values.sort_unstable(); + let index = ((values.len() - 1) * 95).div_ceil(100); + values[index] +} + +fn synthetic_bundle_timings() -> Vec { + (0..180) + .map(|frame| { + let encode_ms = 18 + (frame % 5) * 3; + let queue_jitter_ms = if frame % 47 == 0 { 95 } else { frame % 7 }; + let network_ms = 58 + (frame % 11) * 4; + let client_queue_age_ms = encode_ms + queue_jitter_ms; + BundleTiming { + client_queue_age_ms, + server_receive_age_ms: client_queue_age_ms + network_ms, + } + }) + .collect() +} + #[test] fn freshness_first_camera_policy_keeps_video_delivery_age_bounded_under_stall() { let result = run_uplink_harness(camera_stall_config(), UplinkQueuePolicy::DropOldestWhenFull); @@ -69,6 +97,38 @@ fn freshness_first_microphone_policy_keeps_audio_delivery_age_bounded_under_stal ); } +#[test] +fn bundled_upstream_queue_p95_stays_inside_phase_one_budget() { + let timings = synthetic_bundle_timings(); + let mut queue_ages = timings + .iter() + .map(|timing| timing.client_queue_age_ms) + .collect::>(); + + let p95 = percentile_95(&mut queue_ages); + + assert!( + p95 < 150, + "client bundle queue p95 should stay under 150ms, got {p95}ms" + ); +} + +#[test] +fn server_receive_age_p95_stays_inside_phase_one_budget() { + let timings = synthetic_bundle_timings(); + let mut receive_ages = timings + .iter() + .map(|timing| timing.server_receive_age_ms) + .collect::>(); + + let p95 = percentile_95(&mut receive_ages); + + assert!( + p95 < 250, + "server receive age p95 should stay under 250ms, got {p95}ms" + ); +} + #[test] fn preserve_backlog_policy_would_violate_the_lip_sync_budget() { let mut config = camera_stall_config(); diff --git a/tests/performance/client/uplink/opus_transport_budget_contract.rs b/tests/performance/client/uplink/opus_transport_budget_contract.rs new file mode 100644 index 0000000..2a77097 --- /dev/null +++ b/tests/performance/client/uplink/opus_transport_budget_contract.rs @@ -0,0 +1,42 @@ +// Performance budget model for optional Opus upstream audio transport. +// +// Scope: compare transport payload budgets without invoking hardware or live +// network paths. +// Targets: `common/src/audio_transport.rs`. +// Why: Opus is only worth exploring if it materially reduces upstream bytes +// while preserving the 20 ms audio cadence needed for sync and freshness. + +use lesavka_common::audio_transport::AudioTransportProfile; + +#[test] +fn opus_transport_budget_is_small_enough_to_be_worth_testing() { + let pcm = AudioTransportProfile::pcm_s16le(); + let opus = AudioTransportProfile::opus_voice(); + + assert_eq!(pcm.frame_duration_us, opus.frame_duration_us); + assert_eq!(pcm.sample_rate, opus.sample_rate); + assert_eq!(pcm.channels, opus.channels); + assert!( + opus.expected_payload_bytes() <= 200, + "64 kbps, 20 ms Opus packets should stay near 160 bytes" + ); + assert!( + pcm.expected_payload_bytes() >= opus.expected_payload_bytes() * 20, + "Opus should remove at least 95% of raw PCM uplink byte pressure" + ); +} + +#[test] +fn opus_latency_budget_keeps_packetization_below_voice_call_thresholds() { + let opus = AudioTransportProfile::opus_voice(); + + assert!( + opus.frame_duration_us <= 20_000, + "Opus frames longer than 20 ms would eat into the upstream freshness budget" + ); + assert_eq!( + 1_000_000 / opus.frame_duration_us, + 50, + "20 ms Opus frames should produce 50 audio opportunities per second" + ); +} diff --git a/tests/performance/diagnostics/stage_timing_contract.rs b/tests/performance/diagnostics/stage_timing_contract.rs new file mode 100644 index 0000000..289dbfc --- /dev/null +++ b/tests/performance/diagnostics/stage_timing_contract.rs @@ -0,0 +1,76 @@ +// Contracts for T0-T5 freshness stage timing. +// +// Scope: inspect client-to-RCT black-box summary helpers. +// Targets: `scripts/manual/client_rct_transport_summary.py` and +// `scripts/manual/client_rct_transport_layers.py`. +// Why: upstream freshness failures should point to a stage, not just say the +// final RCT media was late. + +const TRANSPORT_SUMMARY: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/client_rct_transport_summary.py" +)); +const TRANSPORT_LAYERS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/client_rct_transport_layers.py" +)); + +#[test] +fn freshness_summary_exposes_t0_to_t5_stage_markers() { + for marker in [ + "client_start_unix_ns", + "client_planned_start_us", + "local_bundle_age_p95_ms", + "camera_client_queue_age_p95_ms", + "microphone_client_queue_age_p95_ms", + "server_receive_age_p95_ms", + "sink_late_p95_ms", + "video_age_p95_ms", + "audio_age_p95_ms", + "post_client_send_worst_p95_ms", + ] { + assert!( + TRANSPORT_SUMMARY.contains(marker) || TRANSPORT_LAYERS.contains(marker), + "transport diagnostics should preserve T0-T5 marker {marker}" + ); + } +} + +#[test] +fn freshness_failures_are_classified_by_likely_bottleneck() { + for marker in [ + "freshness_bottleneck", + "within_limit", + "evidence_incomplete", + "client_queue_or_bundle_generation", + "server_receive_or_ingress_queue", + "client_to_server_transport", + "post_client_send_to_rct_path", + "needs_deeper_introspection", + ] { + assert!( + TRANSPORT_SUMMARY.contains(marker) || TRANSPORT_LAYERS.contains(marker), + "freshness failure should preserve bottleneck label {marker}" + ); + } +} + +#[test] +fn sync_and_smoothness_reports_keep_evidence_counts_near_stage_timing() { + for marker in [ + "paired_event_count", + "min_paired_events", + "expected_event_count", + "p95_abs_skew_ms", + "video_hiccups", + "audio_hiccups", + "video_p95_jitter_ms", + "synthetic evidence", + "upstream sampler", + ] { + assert!( + TRANSPORT_SUMMARY.contains(marker), + "transport summary should preserve sync/smoothness marker {marker}" + ); + } +} diff --git a/tests/performance/downstream/video/downstream_video_latency_budget_contract.rs b/tests/performance/downstream/video/downstream_video_latency_budget_contract.rs new file mode 100644 index 0000000..34909f7 --- /dev/null +++ b/tests/performance/downstream/video/downstream_video_latency_budget_contract.rs @@ -0,0 +1,100 @@ +// Downstream video latency budget contracts. +// +// Scope: verify that both server capture and client display paths are biased +// toward bounded freshness rather than unbounded smoothness debt. +// Targets: `server/src/video/eye_capture.rs` and +// `client/src/output/video/monitor_window.rs`. +// Why: downstream desktop usability depends on seeing input effects quickly; +// stale-but-perfect frames are worse than bounded drops. + +const SERVER_EYE_CAPTURE: &str = include_str!("../../../../server/src/video/eye_capture.rs"); +const CLIENT_MONITOR: &str = include_str!("../../../../client/src/output/video/monitor_window.rs"); +const CLIENT_UNIFIED_MONITOR: &str = + include_str!("../../../../client/src/output/video/unified_monitor.rs"); + +fn percentile_95(mut values: Vec) -> u64 { + values.sort_unstable(); + values[((values.len() - 1) as f64 * 0.95).ceil() as usize] +} + +fn simulate_leaky_display_queue( + source_interval_ms: u64, + display_interval_ms: u64, + frames: u64, + queue_capacity: usize, +) -> (u64, u64) { + let mut queue = std::collections::VecDeque::new(); + let mut displayed_ages = Vec::new(); + let mut dropped = 0_u64; + let mut next_display_ms = 0_u64; + + for frame in 0..frames { + let now_ms = frame * source_interval_ms; + while queue.len() >= queue_capacity { + queue.pop_front(); + dropped += 1; + } + queue.push_back(now_ms); + + if now_ms >= next_display_ms { + if let Some(captured_ms) = queue.pop_back() { + queue.clear(); + displayed_ages.push(now_ms.saturating_sub(captured_ms)); + } + next_display_ms = now_ms + display_interval_ms; + } + } + + (percentile_95(displayed_ages), dropped) +} + +#[test] +fn downstream_display_queue_model_keeps_p95_age_under_interactive_budget() { + let (p95_age_ms, dropped) = simulate_leaky_display_queue(16, 33, 600, 2); + + assert!( + p95_age_ms < 150, + "downstream frame age p95 should stay below 150ms, got {p95_age_ms}ms" + ); + assert!( + dropped > 0, + "the model should prove stale frames are dropped instead of queued forever" + ); +} + +#[test] +fn server_capture_path_uses_bounded_leaky_queues_and_try_send() { + for marker in [ + "queue max-size-buffers={queue_buffers} max-size-time=0 max-size-bytes=0 leaky=downstream", + "appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true", + "LESAVKA_EYE_CHAN_CAPACITY", + "let chan_capacity = env_usize(\"LESAVKA_EYE_CHAN_CAPACITY\", 32).max(8);", + "tx.try_send(Ok(pkt))", + "TrySendError::Full", + "queue_peak_depth_for_cb.fetch_max", + ] { + assert!( + SERVER_EYE_CAPTURE.contains(marker), + "server downstream path should preserve latency marker {marker}" + ); + } +} + +#[test] +fn client_display_path_is_live_nonblocking_and_leaky() { + for source in [CLIENT_MONITOR, CLIENT_UNIFIED_MONITOR] { + for marker in [ + "appsrc name=src", + "is-live=true", + "do-timestamp=true", + "block=false", + "queue max-size-buffers=2 max-size-time=0 max-size-bytes=0 leaky=downstream", + "sync=false", + ] { + assert!( + source.contains(marker), + "client downstream display should preserve marker {marker}" + ); + } + } +} diff --git a/tests/performance/input/input_latency_budget_contract.rs b/tests/performance/input/input_latency_budget_contract.rs new file mode 100644 index 0000000..451c659 --- /dev/null +++ b/tests/performance/input/input_latency_budget_contract.rs @@ -0,0 +1,44 @@ +// Non-disruptive HID latency budget coverage. +// +// Scope: measure fake endpoint write latency on local temp files only. +// Targets: server HID handoff path and Phase 7 latency budget. +// Why: input responsiveness needs a concrete p95 number; this keeps us from +// tuning by vibes while staying safe on the active desktop. + +use std::{sync::Arc, time::Instant}; + +use lesavka_server::runtime_support::write_hid_report; +use tokio::sync::Mutex; + +fn percentile_95(mut values: Vec) -> u128 { + values.sort_unstable(); + let index = ((values.len() - 1) as f64 * 0.95).ceil() as usize; + values[index] +} + +#[tokio::test] +async fn fake_hid_loopback_p95_stays_under_interactive_budget() { + let dir = tempfile::tempdir().expect("tempdir"); + let endpoint = dir.path().join("hidg0"); + std::fs::File::create(&endpoint).expect("create fake HID endpoint"); + let endpoint = endpoint.to_string_lossy().to_string(); + let handle = Arc::new(Mutex::new(None)); + let reports: Vec<[u8; 8]> = (0..96) + .map(|idx| [0, 0, 0x04 + (idx % 26) as u8, 0, 0, 0, 0, 0]) + .collect(); + let mut samples_ms = Vec::with_capacity(reports.len()); + + for report in reports { + let started = Instant::now(); + write_hid_report(&handle, &endpoint, &report) + .await + .expect("write fake HID report"); + samples_ms.push(started.elapsed().as_millis()); + } + + let p95_ms = percentile_95(samples_ms); + assert!( + p95_ms < 150, + "fake HID write p95 should stay below 150ms, got {p95_ms}ms" + ); +} diff --git a/testing/tests/performance_gate_script_contract.rs b/tests/performance/scripts/ci/performance_gate_script_contract.rs similarity index 68% rename from testing/tests/performance_gate_script_contract.rs rename to tests/performance/scripts/ci/performance_gate_script_contract.rs index 4b9234a..d37c388 100644 --- a/testing/tests/performance_gate_script_contract.rs +++ b/tests/performance/scripts/ci/performance_gate_script_contract.rs @@ -1,13 +1,22 @@ -//! Contract tests for the deterministic performance gate. -//! -//! Scope: inspect CI gate scripts for latency-sensitive Lesavka checks. -//! Targets: `scripts/ci/performance_gate.sh`, `scripts/ci/platform_quality_gate.sh`. -//! Why: A/V sync and remote-control tightness are product behavior, so the main -//! quality gate must fail before release when their contracts regress. +// Contract tests for the deterministic performance gate. +// +// Scope: inspect CI gate scripts for latency-sensitive Lesavka checks. +// Targets: `scripts/ci/performance_gate.sh`, `scripts/ci/platform_quality_gate.sh`. +// Why: A/V sync and remote-control tightness are product behavior, so the main +// quality gate must fail before release when their contracts regress. -const PERFORMANCE_GATE: &str = include_str!("../../scripts/ci/performance_gate.sh"); -const PLATFORM_GATE: &str = include_str!("../../scripts/ci/platform_quality_gate.sh"); -const MEDIA_GATE: &str = include_str!("../../scripts/ci/media_reliability_gate.sh"); +const PERFORMANCE_GATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/ci/performance_gate.sh" +)); +const PLATFORM_GATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/ci/platform_quality_gate.sh" +)); +const MEDIA_GATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/ci/media_reliability_gate.sh" +)); #[test] fn performance_gate_tracks_av_and_interaction_latency_contracts() { diff --git a/tests/performance/server/rct/server_rct_quality_budget_contract.rs b/tests/performance/server/rct/server_rct_quality_budget_contract.rs new file mode 100644 index 0000000..ce3a44d --- /dev/null +++ b/tests/performance/server/rct/server_rct_quality_budget_contract.rs @@ -0,0 +1,95 @@ +// Performance budget contract for final server-to-RCT observations. +// +// Scope: model the accepted server-origin RCT result envelope as deterministic +// thresholds used by lab summaries. +// Targets: scripts/manual/run_server_to_rc_mode_matrix.sh and +// client/src/sync_probe/analyze/report.rs. +// Why: sync and freshness budgets are product commitments, not just prose from +// one successful run, and future tuning should ratchet them from here. + +const MATRIX_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_server_to_rc_mode_matrix.sh" +)); +const SYNC_REPORT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/sync_probe/analyze/report.rs" +)); + +#[derive(Clone, Copy)] +struct ServerRctObservation { + p95_skew_ms: f64, + freshness_p95_ms: f64, + coded_pairs: u32, + expected_pairs: u32, + smoothness_warnings: u32, +} + +fn representative_good_server_rct_observations() -> Vec { + vec![ + ServerRctObservation { + p95_skew_ms: 18.1, + freshness_p95_ms: 229.9, + coded_pairs: 16, + expected_pairs: 16, + smoothness_warnings: 1, + }, + ServerRctObservation { + p95_skew_ms: 20.1, + freshness_p95_ms: 284.0, + coded_pairs: 16, + expected_pairs: 16, + smoothness_warnings: 2, + }, + ServerRctObservation { + p95_skew_ms: 7.3, + freshness_p95_ms: 277.8, + coded_pairs: 16, + expected_pairs: 16, + smoothness_warnings: 0, + }, + ServerRctObservation { + p95_skew_ms: 4.1, + freshness_p95_ms: 200.4, + coded_pairs: 16, + expected_pairs: 16, + smoothness_warnings: 1, + }, + ] +} + +#[test] +fn server_rct_budget_thresholds_remain_preferred_when_possible_and_acceptable_when_needed() { + assert!(SYNC_REPORT.contains("VERDICT_PREFERRED_P95_ABS_SKEW_MS")); + assert!(SYNC_REPORT.contains("VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS")); + assert!(MATRIX_SCRIPT.contains("LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS")); + + for observation in representative_good_server_rct_observations() { + assert!( + observation.p95_skew_ms < 35.0, + "preferred server-to-RCT sync should stay under 35ms where possible" + ); + assert!( + observation.p95_skew_ms < 80.0, + "acceptable server-to-RCT sync must stay under 80ms" + ); + assert!( + observation.freshness_p95_ms < 350.0, + "server-origin RCT freshness must stay under 350ms" + ); + } +} + +#[test] +fn server_rct_budget_tracks_coded_completeness_and_smoothness_warnings() { + for observation in representative_good_server_rct_observations() { + assert_eq!( + observation.coded_pairs, observation.expected_pairs, + "server-origin probe should target 16/16 coded event completeness" + ); + assert!( + observation.smoothness_warnings <= 2, + "smoothness warnings stay tracked and bounded, not ignored" + ); + } +} diff --git a/tests/performance/server/video_sinks/hevc_mjpeg_handoff_performance_contract.rs b/tests/performance/server/video_sinks/hevc_mjpeg_handoff_performance_contract.rs new file mode 100644 index 0000000..dee078f --- /dev/null +++ b/tests/performance/server/video_sinks/hevc_mjpeg_handoff_performance_contract.rs @@ -0,0 +1,56 @@ +// Performance contract for HEVC decode-to-MJPEG handoff timing. +// +// Scope: bound the synthetic decode/spool handoff model used by server-to-RCT +// calibration. +// Targets: server/src/video_sinks/webcam_sink.rs and +// server/src/video_sinks/mjpeg_spool.rs. +// Why: HEVC can save WAN bandwidth only if the server handoff stays +// comfortably below the freshness budget and does not create backlog. + +#[derive(Clone, Copy)] +struct DecodeHandoffSample { + decode_ms: f64, + spool_ms: f64, + guard_ms: f64, +} + +impl DecodeHandoffSample { + fn total_ms(self) -> f64 { + self.decode_ms + self.spool_ms + self.guard_ms + } +} + +fn percentile_95(values: &mut [f64]) -> f64 { + values.sort_by(|a, b| a.total_cmp(b)); + let index = ((values.len() as f64) * 0.95).ceil() as usize - 1; + values[index.min(values.len() - 1)] +} + +fn synthetic_rpi5_hevc_mjpeg_handoff_samples() -> Vec { + (0..120) + .map(|idx| DecodeHandoffSample { + decode_ms: 72.0 + f64::from(idx % 7) * 3.0, + spool_ms: 3.0 + f64::from(idx % 3), + guard_ms: 0.5, + }) + .chain([DecodeHandoffSample { + decode_ms: 126.0, + spool_ms: 8.0, + guard_ms: 1.0, + }]) + .collect() +} + +#[test] +fn hevc_decode_to_mjpeg_handoff_p95_stays_under_phase_two_budget() { + let mut totals: Vec = synthetic_rpi5_hevc_mjpeg_handoff_samples() + .into_iter() + .map(DecodeHandoffSample::total_ms) + .collect(); + let p95 = percentile_95(&mut totals); + + assert!( + p95 < 150.0, + "HEVC decode + MJPEG handoff p95 should stay under 150ms, saw {p95:.1}ms" + ); +} diff --git a/testing/tests/client_inputs_toggle_contract.rs b/tests/regression/client/input/inputs/client_inputs_toggle_contract.rs similarity index 87% rename from testing/tests/client_inputs_toggle_contract.rs rename to tests/regression/client/input/inputs/client_inputs_toggle_contract.rs index 4126e9c..9fcaaeb 100644 --- a/testing/tests/client_inputs_toggle_contract.rs +++ b/tests/regression/client/input/inputs/client_inputs_toggle_contract.rs @@ -1,9 +1,9 @@ -//! Quick-toggle routing coverage for client input aggregation. -//! -//! Scope: include the input aggregator source and exercise swap-key edge -//! detection without creating real input devices. -//! Targets: `client/src/input/inputs.rs`. -//! Why: the swap key must not flap routing when held or bounced. +// Quick-toggle routing coverage for client input aggregation. +// +// Scope: include the input aggregator source and exercise swap-key edge +// detection without creating real input devices. +// Targets: `client/src/input/inputs.rs`. +// Why: the swap key must not flap routing when held or bounced. mod layout { pub use lesavka_client::layout::*; diff --git a/testing/tests/client_keyboard_activation_contract.rs b/tests/regression/client/input/keyboard/client_keyboard_activation_contract.rs similarity index 90% rename from testing/tests/client_keyboard_activation_contract.rs rename to tests/regression/client/input/keyboard/client_keyboard_activation_contract.rs index 97ebe39..94fb38b 100644 --- a/testing/tests/client_keyboard_activation_contract.rs +++ b/tests/regression/client/input/keyboard/client_keyboard_activation_contract.rs @@ -1,9 +1,9 @@ -//! Focused coverage for keyboard quick-toggle activation edges. -//! -//! Scope: exercise the keyboard aggregator's recent-press tracking directly. -//! Targets: `client/src/input/keyboard.rs`. -//! Why: the swap key needs to stay reliable even when a tap begins and ends -//! before the next launcher/input poll cycle observes the key state. +// Focused coverage for keyboard quick-toggle activation edges. +// +// Scope: exercise the keyboard aggregator's recent-press tracking directly. +// Targets: `client/src/input/keyboard.rs`. +// Why: the swap key needs to stay reliable even when a tap begins and ends +// before the next launcher/input poll cycle observes the key state. mod keymap { pub use lesavka_client::input::keymap::*; diff --git a/tests/regression/client/ui/client_live_controls_regression_contract.rs b/tests/regression/client/ui/client_live_controls_regression_contract.rs new file mode 100644 index 0000000..613e23c --- /dev/null +++ b/tests/regression/client/ui/client_live_controls_regression_contract.rs @@ -0,0 +1,104 @@ +// Regression contracts for live launcher control behavior. +// +// Scope: source-level assertions around reconnect cooldown, live device +// switching, and gain/control-file propagation. +// Targets: `client/src/launcher/ui/*` and `client/src/launcher/ui_runtime/*`. +// Why: these are the small UI paths operators touch during real calls; stale +// labels or delayed controls make the system feel broken even when transport is healthy. + +const UI_SRC: &str = concat!( + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/media_device_bindings.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/control_requests.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/relay_input_bindings.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/runtime_poll.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/message_and_network_state.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui_runtime/control_paths.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/eye_capture_bindings.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/utility_button_bindings.rs" + )), +); + +#[test] +fn live_device_and_gain_controls_write_runtime_files_while_relay_is_active() { + for marker in [ + "apply_live_media_device_change(", + "{feed_label} selection applied to the live relay; the stream is restarting.", + "write_media_control_request(&path, state_snapshot)", + "write_audio_gain_request(&path, percent)", + "write_mic_gain_request(&path, percent)", + "Remote audio gain set to {label}.", + "Mic gain set to {label}.", + ] { + assert!( + UI_SRC.contains(marker), + "live controls should preserve marker {marker}" + ); + } +} + +#[test] +fn disconnect_button_stays_locked_during_startup_audio_refresh() { + for marker in [ + "disconnect_cooldown_remaining(", + "disconnect_cooldown_status(remaining)", + "Upstream audio is refreshing; disconnect unlocks in {seconds}s.", + "disconnect_cooldown_until.set(Some(", + "widgets.start_button.set_sensitive(false);", + ".start_button\n .set_tooltip_text(Some(&disconnect_cooldown_status(remaining)));", + ] { + assert!( + UI_SRC.contains(marker), + "disconnect cooldown should preserve marker {marker}" + ); + } +} + +#[test] +fn eye_clip_record_save_and_calibration_buttons_remain_bound_to_actions() { + for marker in [ + "pane.clip_button.connect_clicked", + "clip saved to", + "record_button.connect_clicked", + "recording saved to", + "pane.save_button.connect_clicked", + "Choose Eye Capture Folder", + "widgets.calibration_default_button.connect_clicked", + "restore_default_calibration(server_addr)", + "widgets.calibration_factory_button.connect_clicked", + "restore_factory_calibration(server_addr)", + "widgets.calibration_minus_button.connect_clicked", + "nudge_audio_calibration(server_addr, -5_000)", + "widgets.calibration_plus_button.connect_clicked", + "nudge_audio_calibration(server_addr, 5_000)", + "widgets.calibration_blind_button.connect_clicked", + "blind_calibration_estimate(", + ] { + assert!( + UI_SRC.contains(marker), + "button action should preserve marker {marker}" + ); + } +} diff --git a/tests/regression/install/install_preserves_calibration_contract.rs b/tests/regression/install/install_preserves_calibration_contract.rs new file mode 100644 index 0000000..1581a2c --- /dev/null +++ b/tests/regression/install/install_preserves_calibration_contract.rs @@ -0,0 +1,87 @@ +// Regression contract for preserving calibration across installs. +// +// Scope: guard the shipped calibration maps and ensure the installer does not +// overwrite site calibration state during upgrades. +// Targets: `scripts/install/server.sh` and server calibration modules. +// Why: the server-to-RCT timing values are hard-won hardware facts; installer +// reruns should refresh defaults without erasing local calibration decisions. + +const SERVER_INSTALL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/install/server.sh" +)); +const CALIBRATION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/calibration.rs" +)); +const PROFILE_OFFSETS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/calibration/profile_offsets.rs" +)); + +#[test] +fn installer_persists_both_mjpeg_and_hevc_factory_offset_maps() { + for marker in [ + "DEFAULT_MJPEG_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US=1280x720@20=162659,1280x720@30=135090,1920x1080@20=160045,1920x1080@30=127952", + "DEFAULT_HEVC_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US=1280x720@20=173852,1280x720@30=110000,1920x1080@20=160045,1920x1080@30=127952", + "DEFAULT_HEVC_OPUS_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=$DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US", + "LESAVKA_UPSTREAM_MJPEG_VIDEO_PLAYOUT_MODE_OFFSETS_US=%s", + "LESAVKA_UPSTREAM_HEVC_VIDEO_PLAYOUT_MODE_OFFSETS_US=%s", + "LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US=%s", + "LESAVKA_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US=%s", + ] { + assert!( + SERVER_INSTALL.contains(marker), + "server installer should preserve calibration map marker {marker}" + ); + } +} + +#[test] +fn explicit_install_offsets_win_over_stale_ambient_runtime_values() { + for marker in [ + "LESAVKA_INSTALL_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US", + "LESAVKA_INSTALL_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US", + "migrating stale upstream audio playout offset", + "migrating stale upstream video playout offset", + "Use LESAVKA_INSTALL_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US to intentionally keep an older value.", + ] { + assert!( + SERVER_INSTALL.contains(marker), + "installer should preserve stale-baseline migration marker {marker}" + ); + } +} + +#[test] +fn installer_does_not_delete_or_rewrite_site_calibration_file() { + assert!( + !SERVER_INSTALL.contains("calibration.toml"), + "installer should not write the persisted site calibration file directly" + ); + assert!( + !SERVER_INSTALL.contains("/var/lib/lesavka/calibration"), + "installer should leave calibration storage to the runtime store" + ); + assert!(CALIBRATION.contains("/var/lib/lesavka/calibration.toml")); + assert!(CALIBRATION.contains("persist_snapshot")); +} + +#[test] +fn runtime_calibration_selects_profile_from_ingress_codec() { + for marker in [ + "LESAVKA_CALIBRATION_PROFILE", + "LESAVKA_UPLINK_AUDIO_CODEC", + "format!(\"{camera_profile}+{audio_profile}\")", + "LESAVKA_CALIBRATION_AUDIO_CODEC", + "LESAVKA_CAM_CODEC", + "mjpeg", + "hevc", + "factory_video_mode_offsets_us", + ] { + assert!( + PROFILE_OFFSETS.contains(marker), + "profile offset code should preserve marker {marker}" + ); + } +} diff --git a/tests/regression/install/install_preserves_codec_settings_contract.rs b/tests/regression/install/install_preserves_codec_settings_contract.rs new file mode 100644 index 0000000..577668e --- /dev/null +++ b/tests/regression/install/install_preserves_codec_settings_contract.rs @@ -0,0 +1,79 @@ +// Regression contract for preserving codec settings across upgrades. +// +// Scope: keep HEVC ingress and MJPEG UVC output defaults explicit, while still +// allowing operator-provided install overrides. +// Targets: server/client install scripts and client camera capture defaults. +// Why: Lesavka now supports both MJPEG and HEVC upstream media, and installer +// reruns must not silently revert the working profile. + +const SERVER_INSTALL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/install/server.sh" +)); +const CLIENT_CAMERA: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/input/camera/capture_pipeline.rs" +)); + +#[test] +fn server_install_defaults_to_hevc_ingress_and_mjpeg_uvc_output() { + for marker in [ + "INSTALL_UVC_CODEC=${LESAVKA_INSTALL_UVC_CODEC:-mjpeg}", + "INSTALL_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-hevc}}", + "printf 'LESAVKA_CAM_CODEC=%s\\n' \"${INSTALL_CAM_CODEC}\"", + "printf 'LESAVKA_UVC_CODEC=%s\\n' \"${INSTALL_UVC_CODEC}\"", + "\"LESAVKA_UVC_CODEC=${INSTALL_UVC_CODEC}\"", + ] { + assert!( + SERVER_INSTALL.contains(marker), + "server installer should preserve codec marker {marker}" + ); + } +} + +#[test] +fn server_install_does_not_let_ambient_uvc_codec_override_persisted_default() { + assert!( + !SERVER_INSTALL.contains("LESAVKA_UVC_CODEC=${LESAVKA_UVC_CODEC:-mjpeg}"), + "ambient runtime UVC codec should not leak into install defaults" + ); + assert!( + SERVER_INSTALL.contains("LESAVKA_INSTALL_UVC_CODEC"), + "operator install override should remain available" + ); +} + +#[test] +fn client_camera_can_emit_hevc_or_mjpeg_from_live_capture() { + for marker in [ + "LESAVKA_CAM_CODEC", + "CameraCodec::Hevc", + "CameraCodec::Mjpeg", + "choose_hevc_encoder", + "hevc_keyframe_interval", + "image/jpeg", + "video/x-h265", + ] { + assert!( + CLIENT_CAMERA.contains(marker), + "client camera pipeline should preserve codec marker {marker}" + ); + } +} + +#[test] +fn hevc_prerequisites_are_rechecked_idempotently() { + for marker in [ + "ensure_hevc_decode_support", + "pacman -Sq --needed --noconfirm", + "modprobe rpi_hevc_dec", + "/etc/modules-load.d/lesavka-hevc.conf", + "gst-inspect-1.0 v4l2slh265dec", + "gst-inspect-1.0 avdec_h265", + ] { + assert!( + SERVER_INSTALL.contains(marker), + "HEVC prerequisite path should preserve marker {marker}" + ); + } +} diff --git a/testing/tests/server_gadget_recovery_contract.rs b/tests/regression/server/gadget/server_gadget_recovery_contract.rs similarity index 96% rename from testing/tests/server_gadget_recovery_contract.rs rename to tests/regression/server/gadget/server_gadget_recovery_contract.rs index 4040c3b..ba6d330 100644 --- a/testing/tests/server_gadget_recovery_contract.rs +++ b/tests/regression/server/gadget/server_gadget_recovery_contract.rs @@ -1,9 +1,9 @@ -//! Include-based coverage for aggressive USB gadget recovery helpers. -//! -//! Scope: exercise forced Lesavka core rebuild and fake UDC recovery branches. -//! Targets: `server/src/gadget.rs`. -//! Why: recovery is the fragile path that protects UVC enumeration after host -//! or gadget bumps, so it needs focused regression coverage. +// Include-based coverage for aggressive USB gadget recovery helpers. +// +// Scope: exercise forced Lesavka core rebuild and fake UDC recovery branches. +// Targets: `server/src/gadget.rs`. +// Why: recovery is the fragile path that protects UVC enumeration after host +// or gadget bumps, so it needs focused regression coverage. #[allow(warnings)] mod gadget_recovery_contract { diff --git a/testing/tests/server_main_usb_recovery_contract.rs b/tests/regression/server/main/server_main_usb_recovery_contract.rs similarity index 97% rename from testing/tests/server_main_usb_recovery_contract.rs rename to tests/regression/server/main/server_main_usb_recovery_contract.rs index 97067e8..2d706c6 100644 --- a/testing/tests/server_main_usb_recovery_contract.rs +++ b/tests/regression/server/main/server_main_usb_recovery_contract.rs @@ -1,10 +1,10 @@ -//! 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. +// 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 { diff --git a/tests/reliability/audio/audio_epoch_recovery_reliability_contract.rs b/tests/reliability/audio/audio_epoch_recovery_reliability_contract.rs new file mode 100644 index 0000000..8f16d57 --- /dev/null +++ b/tests/reliability/audio/audio_epoch_recovery_reliability_contract.rs @@ -0,0 +1,67 @@ +// Reliability contract for repeated audio epoch recovery. +// +// Scope: exercise repeated server-side audio recovery cycles without HID, UVC, +// GTK, GStreamer, or physical USB devices. +// Targets: `server/src/upstream_media_runtime.rs`. +// Why: the first-join audio fix should be safe to call during reconnect churn +// without causing heal loops, video resets, or calibration drift. + +use lesavka_server::upstream_media_runtime::UpstreamMediaRuntime; + +#[test] +fn repeated_audio_epoch_heals_do_not_reset_video_or_calibration() { + let runtime = UpstreamMediaRuntime::new(); + runtime.set_playout_offsets(162_659, 5_000); + let camera = runtime.activate_camera(); + let microphone = runtime.activate_microphone(); + let session_id = runtime.snapshot().session_id; + + for _ in 0..5 { + runtime.soft_recover_microphone(); + assert!(runtime.is_camera_active(camera.generation)); + assert!(!runtime.is_microphone_active(microphone.generation)); + assert_eq!(runtime.playout_offsets(), (162_659, 5_000)); + assert_eq!(runtime.snapshot().session_id, session_id); + } + + let fresh_microphone = runtime.activate_microphone(); + assert!(runtime.is_microphone_active(fresh_microphone.generation)); + assert!(fresh_microphone.generation > microphone.generation); + assert_eq!(fresh_microphone.session_id, session_id); +} + +#[test] +fn connect_disconnect_cycles_leave_no_stale_microphone_generation_active() { + let runtime = UpstreamMediaRuntime::new(); + + for cycle in 0..20 { + let camera = runtime.activate_camera(); + let microphone = runtime.activate_microphone(); + let session_id = runtime.snapshot().session_id; + + runtime.soft_recover_microphone(); + assert!( + runtime.is_camera_active(camera.generation), + "cycle {cycle}: video generation should survive audio heal" + ); + assert!( + !runtime.is_microphone_active(microphone.generation), + "cycle {cycle}: stale microphone generation should be retired" + ); + + let replacement = runtime.activate_microphone(); + assert_eq!(replacement.session_id, session_id); + assert!(runtime.is_microphone_active(replacement.generation)); + + runtime.close_microphone(replacement.generation); + runtime.close_camera(camera.generation); + assert!( + !runtime.is_camera_active(camera.generation), + "cycle {cycle}: camera should close cleanly after session" + ); + assert!( + !runtime.is_microphone_active(replacement.generation), + "cycle {cycle}: replacement microphone should close cleanly after session" + ); + } +} diff --git a/testing/tests/client_log_noise_contract.rs b/tests/reliability/client/diagnostics/client_log_noise_contract.rs similarity index 67% rename from testing/tests/client_log_noise_contract.rs rename to tests/reliability/client/diagnostics/client_log_noise_contract.rs index f2b08c1..ecf40e4 100644 --- a/testing/tests/client_log_noise_contract.rs +++ b/tests/reliability/client/diagnostics/client_log_noise_contract.rs @@ -1,15 +1,23 @@ -//! Contracts for keeping steady-state live-media recovery logs actionable. -//! -//! Scope: inspect client live-media logging and recovery source text. -//! Targets: `client/src/app/uplink_media/drop_logging.rs`, -//! `client/src/app/downlink_media.rs`. -//! Why: freshness-first media handling should not turn normal queue churn into -//! high-volume warnings that hide real failures or steal runtime budget. +// Contracts for keeping steady-state live-media recovery logs actionable. +// +// Scope: inspect client live-media logging and recovery source text. +// Targets: `client/src/app/uplink_media/drop_logging.rs`, +// `client/src/app/downlink_media.rs`. +// Why: freshness-first media handling should not turn normal queue churn into +// high-volume warnings that hide real failures or steal runtime budget. -const UPLINK_DROP_LOGGING_SRC: &str = - include_str!("../../client/src/app/uplink_media/drop_logging.rs"); -const DOWNLINK_MEDIA_SRC: &str = include_str!("../../client/src/app/downlink_media.rs"); -const AUDIO_RECOVERY_SRC: &str = include_str!("../../client/src/app/audio_recovery_config.rs"); +const UPLINK_DROP_LOGGING_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/app/uplink_media/drop_logging.rs" +)); +const DOWNLINK_MEDIA_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/app/downlink_media.rs" +)); +const AUDIO_RECOVERY_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/app/audio_recovery_config.rs" +)); #[test] fn upstream_queue_drops_are_rate_limited_instead_of_warn_spammed() { diff --git a/testing/tests/client_uplink_freshness_contract.rs b/tests/reliability/client/uplink/client_uplink_freshness_contract.rs similarity index 82% rename from testing/tests/client_uplink_freshness_contract.rs rename to tests/reliability/client/uplink/client_uplink_freshness_contract.rs index d784235..963bd41 100644 --- a/testing/tests/client_uplink_freshness_contract.rs +++ b/tests/reliability/client/uplink/client_uplink_freshness_contract.rs @@ -1,17 +1,30 @@ -//! Contract guardrails for uplink queue freshness budgets. -//! -//! Scope: source-level checks over client uplink queue constants. -//! Targets: `client/src/app/uplink_media/`, `client/src/sync_probe/capture.rs`. -//! Why: lip-sync quality depends on bounded queue age; accidental widening can -//! create near-second video lag under load. +// Contract guardrails for uplink queue freshness budgets. +// +// Scope: source-level checks over client uplink queue constants. +// Targets: `client/src/app/uplink_media/`, `client/src/sync_probe/capture.rs`. +// Why: lip-sync quality depends on bounded queue age; accidental widening can +// create near-second video lag under load. -const UPLINK_MEDIA_QUEUE_SRC: &str = - include_str!("../../client/src/app/uplink_media/bundled_media_queue.rs"); -const MEDIA_SOURCE_REQUIREMENTS_SRC: &str = - include_str!("../../client/src/app/uplink_media/media_source_requirements.rs"); -const VOICE_LOOP_SRC: &str = include_str!("../../client/src/app/uplink_media/voice_loop.rs"); -const CAMERA_LOOP_SRC: &str = include_str!("../../client/src/app/uplink_media/camera_loop.rs"); -const SYNC_PROBE_CAPTURE_SRC: &str = include_str!("../../client/src/sync_probe/capture.rs"); +const UPLINK_MEDIA_QUEUE_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/app/uplink_media/bundled_media_queue.rs" +)); +const MEDIA_SOURCE_REQUIREMENTS_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/app/uplink_media/media_source_requirements.rs" +)); +const VOICE_LOOP_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/app/uplink_media/voice_loop.rs" +)); +const CAMERA_LOOP_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/app/uplink_media/camera_loop.rs" +)); +const SYNC_PROBE_CAPTURE_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/sync_probe/capture.rs" +)); fn queue_block<'a>(src: &'a str, queue_const: &str) -> &'a str { let marker = format!("const {queue_const}:"); diff --git a/tests/reliability/diagnostics/log_spam_prevention_contract.rs b/tests/reliability/diagnostics/log_spam_prevention_contract.rs new file mode 100644 index 0000000..a1ee235 --- /dev/null +++ b/tests/reliability/diagnostics/log_spam_prevention_contract.rs @@ -0,0 +1,75 @@ +// Contracts for actionable, bounded diagnostic logging. +// +// Scope: inspect upstream/drop logging, audio recovery, and diagnostics text. +// Targets: client live-media and diagnostics source. +// Why: freshness-first media paths intentionally drop packets; those drops +// must be aggregated into useful evidence instead of warning on every packet. + +const UPLINK_DROP_LOGGING_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/app/uplink_media/drop_logging.rs" +)); +const AUDIO_RECOVERY_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/app/audio_recovery_config.rs" +)); +const UPLINK_TELEMETRY_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/uplink_telemetry.rs" +)); +const SNAPSHOT_REPORT_TEXT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/diagnostics/snapshot_report_text.rs" +)); + +#[test] +fn freshness_first_drops_are_aggregated_before_warning() { + for marker in [ + "UplinkDropLogLimiter", + "suppressed_full", + "suppressed_stale", + "UPLINK_DROP_WARN_INTERVAL", + "Duration::from_secs(5)", + "dropped_queue_full", + "dropped_stale", + "upstream media queue drop suppressed from WARN noise", + ] { + assert!( + UPLINK_DROP_LOGGING_SRC.contains(marker), + "uplink drop logging should preserve aggregation marker {marker}" + ); + } +} + +#[test] +fn repeated_audio_recovery_logs_are_rate_limited() { + for marker in [ + "AudioFailureLogLimiter", + "AUDIO_FAILURE_WARN_INTERVAL", + "self.suppressed_repeats = self.suppressed_repeats.saturating_add(1)", + "audio stream repeated unhealthy state suppressed from WARN noise", + ] { + assert!( + AUDIO_RECOVERY_SRC.contains(marker), + "audio recovery logging should preserve rate-limit marker {marker}" + ); + } +} + +#[test] +fn diagnostics_report_exposes_drop_totals_without_log_spam() { + for marker in [ + "dropped_packets", + "dropped_queue_full_packets", + "dropped_stale_packets", + "drops(total/full/stale)", + "planner_stale_audio_drops", + "planner_stale_video_drops", + "planner_skew_video_drops", + ] { + assert!( + UPLINK_TELEMETRY_SRC.contains(marker) || SNAPSHOT_REPORT_TEXT.contains(marker), + "diagnostics should preserve aggregate drop marker {marker}" + ); + } +} diff --git a/tests/reliability/downstream/video/downstream_blackout_recovery_contract.rs b/tests/reliability/downstream/video/downstream_blackout_recovery_contract.rs new file mode 100644 index 0000000..0a43f81 --- /dev/null +++ b/tests/reliability/downstream/video/downstream_blackout_recovery_contract.rs @@ -0,0 +1,73 @@ +// Downstream blackout recovery contracts. +// +// Scope: keep the eye-video stream observable when frames stop arriving or +// queues overflow. +// Targets: `server/src/video/eye_capture.rs` and `server/src/video/stream_core.rs`. +// Why: black-screen episodes should become measurable recovery events, not +// silent UI mysteries. + +const SERVER_EYE_CAPTURE: &str = include_str!("../../../../server/src/video/eye_capture.rs"); +const SERVER_STREAM_CORE: &str = include_str!("../../../../server/src/video/stream_core.rs"); + +#[test] +fn first_frame_and_midstream_blackouts_have_separate_watchdogs() { + let first_frame = SERVER_EYE_CAPTURE + .find("LESAVKA_EYE_FIRST_FRAME_TIMEOUT_MS") + .expect("first-frame timeout marker"); + let midstream = SERVER_EYE_CAPTURE + .find("eye_stall_warn_timeout()") + .expect("midstream stall timeout marker"); + + assert!( + first_frame < midstream, + "initial no-video failures should be reported before midstream stall monitoring starts" + ); + for marker in [ + "capture produced no frames within", + "downstream eye stream has produced no samples since the last frame", + "last_sample_wall_ms", + "first_sample_seen", + "stall_watchdog_alive", + ] { + assert!( + SERVER_EYE_CAPTURE.contains(marker), + "blackout recovery should preserve marker {marker}" + ); + } +} + +#[test] +fn blackout_warning_defaults_are_bounded_below_ten_seconds() { + for marker in [ + "unwrap_or(5_000)", + "Duration::from_millis(millis.max(500))", + "(millis > 0).then_some", + ] { + assert!( + SERVER_STREAM_CORE.contains(marker), + "stall warning timeout should preserve bounded marker {marker}" + ); + } +} + +#[test] +fn overflow_recovery_waits_for_idr_before_resuming_after_drops() { + let full_branch = SERVER_EYE_CAPTURE + .find("TrySendError::Full") + .expect("overflow branch"); + let wait_for_idr = SERVER_EYE_CAPTURE + .find("wait_for_idr.store(true") + .expect("IDR recovery marker"); + let clear_idr = SERVER_EYE_CAPTURE + .find("wait_for_idr.store(false") + .expect("IDR clear marker"); + + assert!( + full_branch < wait_for_idr, + "queue overflow should arm IDR recovery" + ); + assert!( + clear_idr < full_branch, + "successful IDR sends should clear recovery before later overflow can re-arm it" + ); +} diff --git a/tests/reliability/scripts/ci/input_transport_gate_safety_contract.rs b/tests/reliability/scripts/ci/input_transport_gate_safety_contract.rs new file mode 100644 index 0000000..960821b --- /dev/null +++ b/tests/reliability/scripts/ci/input_transport_gate_safety_contract.rs @@ -0,0 +1,92 @@ +// Reliability contracts for disruptive local input tests. +// +// Scope: inspect Cargo target metadata and the input transport CI gate. +// Targets: `Cargo.toml`, `scripts/ci/input_transport_gate.sh`. +// Why: tests that create uinput keyboards/mice can type into the active +// desktop, so they must be explicit opt-in work for isolated Jenkins workers. + +const CARGO_TOML: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")); +const INPUT_GATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/ci/input_transport_gate.sh" +)); +const CLIENT_MOUSE_EVENT_TESTS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/input/mouse_event_contract_tests.rs" +)); + +const DISRUPTIVE_INPUT_TARGETS: &[&str] = &[ + "client_keyboard_paste_rpc_contract", + "client_inputs_contract", + "client_inputs_extra_contract", + "client_inputs_routing_contract", + "client_keyboard_include_contract", + "client_keyboard_include_extra_contract", + "client_keyboard_process_contract", + "client_keyboard_shift_contract", + "client_mouse_include_contract", + "client_mouse_include_extra_contract", + "client_mouse_uinput_contract", + "client_keyboard_activation_contract", +]; + +fn test_target_block(name: &str) -> &str { + let needle = format!("name = \"{name}\""); + let start = CARGO_TOML + .find(&needle) + .unwrap_or_else(|| panic!("missing test target {name}")); + let rest = &CARGO_TOML[start..]; + let end = rest.find("\n[[test]]").unwrap_or(rest.len()); + &rest[..end] +} + +#[test] +fn virtual_hid_test_targets_require_explicit_disruptive_feature() { + assert!( + CARGO_TOML.contains("disruptive-input-tests = []"), + "Cargo should declare a named opt-in feature for local input emission" + ); + for target in DISRUPTIVE_INPUT_TARGETS { + let block = test_target_block(target); + assert!( + block.contains("required-features = [\"disruptive-input-tests\"]"), + "{target} should not run in default cargo test on a shared desktop" + ); + } +} + +#[test] +fn input_transport_gate_requires_isolated_worker_opt_in() { + for expected in [ + "LESAVKA_ALLOW_DISRUPTIVE_INPUT_TESTS", + "Skipping disruptive input transport tests", + "--features disruptive-input-tests", + ] { + assert!( + INPUT_GATE.contains(expected), + "input transport gate should make disruptive execution explicit: {expected}" + ); + } +} + +#[test] +fn input_transport_gate_runs_every_feature_gated_hid_target_when_opted_in() { + for target in DISRUPTIVE_INPUT_TARGETS { + assert!( + INPUT_GATE.contains(&format!("--test {target}")), + "input transport gate should exercise opted-in disruptive target {target}" + ); + } +} + +#[test] +fn crate_local_virtual_mouse_tests_are_env_gated() { + assert!( + CLIENT_MOUSE_EVENT_TESTS.contains("LESAVKA_ALLOW_DISRUPTIVE_INPUT_TESTS"), + "client crate-local virtual mouse tests should not emit events by default" + ); + assert!( + CLIENT_MOUSE_EVENT_TESTS.contains("disruptive_input_tests_enabled"), + "crate-local virtual mouse coverage should use a named safety gate" + ); +} diff --git a/tests/reliability/scripts/ci/jenkins_cadence_contract.rs b/tests/reliability/scripts/ci/jenkins_cadence_contract.rs new file mode 100644 index 0000000..9c64d17 --- /dev/null +++ b/tests/reliability/scripts/ci/jenkins_cadence_contract.rs @@ -0,0 +1,120 @@ +// Reliability contracts for Jenkins cadence and lab isolation. +// +// Scope: inspect Jenkins and CI entrypoint scripts. +// Targets: `Jenkinsfile`, `scripts/ci/daily_master_gate.sh`, +// `scripts/ci/baremetal_lab_gate.sh`. +// Why: Lesavka needs daily primary-branch signal without letting hardware or +// virtual HID tests disturb a shared desktop. The slower lab path must stay +// explicit so quality ratchets can grow safely. + +const JENKINSFILE: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/Jenkinsfile")); +const DAILY_GATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/ci/daily_master_gate.sh" +)); +const LAB_GATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/ci/baremetal_lab_gate.sh" +)); +const OPERATIONAL_ENV: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/docs/operational-env.md" +)); + +#[test] +fn jenkins_exposes_safe_daily_and_lab_profiles() { + for expected in [ + "LESAVKA_CI_PROFILE", + "choices: ['safe', 'daily', 'lab']", + "Daily Master Gate", + "scripts/ci/daily_master_gate.sh", + "RUN_DISRUPTIVE_INPUT_TESTS", + "Input Transport (Isolated Opt-In)", + "LESAVKA_ALLOW_DISRUPTIVE_INPUT_TESTS=1", + "RUN_LAB_HARDWARE_GATES", + "Bare-Metal Lab Gates (Opt-In)", + "scripts/ci/baremetal_lab_gate.sh", + ] { + assert!( + JENKINSFILE.contains(expected), + "Jenkinsfile should expose cadence/safety control: {expected}" + ); + } +} + +#[test] +fn jenkins_archives_profile_and_performance_artifacts() { + for expected in [ + "target/daily-master-gate/**", + "target/performance-gate/**", + "target/input-transport-gate/**", + "target/baremetal-lab-gate/**", + "target/video-downstream-gate/**", + ] { + assert!( + JENKINSFILE.contains(expected), + "Jenkins should retain ratchet evidence: {expected}" + ); + } +} + +#[test] +fn daily_gate_is_primary_branch_safe_and_runs_platform_gate() { + for expected in [ + "master|main|origin/master|origin/main|*/master|*/main", + "LESAVKA_DAILY_ALLOW_NON_PRIMARY", + "LESAVKA_CI_PROFILE=daily scripts/ci/platform_quality_gate.sh", + "target/daily-master-gate", + "lesavka_ci_profile_last_run_success", + "LESAVKA_DAILY_GATE_PUSHGATEWAY_JOB", + ] { + assert!( + DAILY_GATE.contains(expected), + "daily gate should guard primary-branch cadence: {expected}" + ); + } +} + +#[test] +fn lab_gate_requires_explicit_hardware_and_probe_opt_ins() { + for expected in [ + "LESAVKA_ALLOW_LAB_HARDWARE_TESTS", + "Skipping bare-metal lab gates", + "LESAVKA_RUN_VIDEO_DOWNSTREAM_GATE", + "LESAVKA_ALLOW_DISRUPTIVE_INPUT_TESTS", + "LESAVKA_RUN_SERVER_RCT_MATRIX", + "LESAVKA_SERVER_RCT_MATRIX_CMD", + "run_server_to_rc_mode_matrix.sh", + "LESAVKA_RUN_CLIENT_RCT_PROBE", + "LESAVKA_CLIENT_RCT_PROBE_CMD", + "run_client_to_rct_transport_probe.sh", + "target/baremetal-lab-gate", + "lesavka_lab_gate_step_result", + ] { + assert!( + LAB_GATE.contains(expected), + "lab gate should make hardware/disruptive work explicit: {expected}" + ); + } +} + +#[test] +fn new_ci_cadence_knobs_are_documented() { + for expected in [ + "LESAVKA_ALLOW_LAB_HARDWARE_TESTS", + "LESAVKA_CI_PROFILE", + "LESAVKA_DAILY_ALLOW_NON_PRIMARY", + "LESAVKA_DAILY_GATE_PUSHGATEWAY_JOB", + "LESAVKA_LAB_GATE_PUSHGATEWAY_JOB", + "LESAVKA_CLIENT_RCT_PROBE_CMD", + "LESAVKA_RUN_CLIENT_RCT_PROBE", + "LESAVKA_RUN_SERVER_RCT_MATRIX", + "LESAVKA_RUN_VIDEO_DOWNSTREAM_GATE", + "LESAVKA_SERVER_RCT_MATRIX_CMD", + ] { + assert!( + OPERATIONAL_ENV.contains(expected), + "operational env inventory should document {expected}" + ); + } +} diff --git a/tests/reliability/server/rct/server_rct_profile_switch_recovery_contract.rs b/tests/reliability/server/rct/server_rct_profile_switch_recovery_contract.rs new file mode 100644 index 0000000..c6e702c --- /dev/null +++ b/tests/reliability/server/rct/server_rct_profile_switch_recovery_contract.rs @@ -0,0 +1,49 @@ +// Reliability contract for repeated server-to-RCT profile changes. +// +// Scope: protect the script behavior needed to switch MJPEG/HEVC profiles and +// recover media endpoints between runs. +// Targets: scripts/manual/run_server_to_rc_mode_matrix.sh. +// Why: calibration only stays trustworthy if repeated mode/profile switches +// rebuild or wait for the physical UVC/UAC path cleanly before measuring. + +const MATRIX_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_server_to_rc_mode_matrix.sh" +)); + +#[test] +fn repeated_matrix_runs_keep_reconfigure_recovery_and_readiness_steps() { + for marker in [ + "LESAVKA_SERVER_RC_REPEAT_COUNT=${LESAVKA_SERVER_RC_REPEAT_COUNT:-1}", + "LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY=${LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY:-runtime}", + "LESAVKA_SERVER_RC_ALLOW_GADGET_RESET=${LESAVKA_SERVER_RC_ALLOW_GADGET_RESET:-1}", + "cycling UVC gadget descriptors", + "wait_tethys_media_ready", + "LESAVKA_SERVER_RC_SIGNAL_READY_ATTEMPTS=${LESAVKA_SERVER_RC_SIGNAL_READY_ATTEMPTS:-4}", + "write_signal_readiness_attempts_summary", + ] { + assert!( + MATRIX_SCRIPT.contains(marker), + "server-to-RCT repeated run should preserve recovery marker {marker}" + ); + } +} + +#[test] +fn repeated_profile_switches_publish_static_calibration_evidence() { + for marker in [ + "static_min_runs", + "static_max_spread_us", + "static_max_p95_skew_ms", + "static_max_median_skew_ms", + "mode_static_calibration_json", + "mode_static_calibration_env", + "static video delays", + "static audio delays", + ] { + assert!( + MATRIX_SCRIPT.contains(marker), + "server-to-RCT static calibration should preserve marker {marker}" + ); + } +} diff --git a/testing/tests/server_upstream_media_pairing_freshness_contract.rs b/tests/reliability/server/upstream_media_runtime/server_upstream_media_pairing_freshness_contract.rs similarity index 90% rename from testing/tests/server_upstream_media_pairing_freshness_contract.rs rename to tests/reliability/server/upstream_media_runtime/server_upstream_media_pairing_freshness_contract.rs index d927538..482bbe8 100644 --- a/testing/tests/server_upstream_media_pairing_freshness_contract.rs +++ b/tests/reliability/server/upstream_media_runtime/server_upstream_media_pairing_freshness_contract.rs @@ -1,9 +1,9 @@ -//! End-to-end server coverage for upstream media pairing and freshness. -//! -//! Scope: run a local gRPC server and verify webcam/mic packet pairing behavior. -//! Targets: `server/src/main.rs`, `server/src/upstream_media_runtime.rs`. -//! Why: MJPEG lip sync depends on keeping late/early packet decisions stable -//! while streams start, stop, or temporarily lose their pair. +// End-to-end server coverage for upstream media pairing and freshness. +// +// Scope: run a local gRPC server and verify webcam/mic packet pairing behavior. +// Targets: `server/src/main.rs`, `server/src/upstream_media_runtime.rs`. +// Why: MJPEG lip sync depends on keeping late/early packet decisions stable +// while streams start, stop, or temporarily lose their pair. #[cfg(coverage)] #[allow(warnings)] @@ -215,3 +215,22 @@ mod server_upstream_media_pairing { }); } } + +#[cfg(not(coverage))] +mod server_upstream_media_pairing_freshness_normal_mode { + const RUNTIME: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/upstream_media_runtime.rs" + )); + const RELAY_LIFECYCLE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_stream_lifecycle.rs" + )); + + #[test] + fn freshness_first_pairing_controls_remain_operator_visible() { + assert!(RELAY_LIFECYCLE.contains("LESAVKA_UPSTREAM_STALE_DROP_MS")); + assert!(RUNTIME.contains("source_lag")); + assert!(RUNTIME.contains("latest_camera_remote_pts_us")); + } +} diff --git a/testing/tests/video_downstream_feed_contract.rs b/tests/reliability/video/video_downstream_feed_contract.rs similarity index 88% rename from testing/tests/video_downstream_feed_contract.rs rename to tests/reliability/video/video_downstream_feed_contract.rs index 9ff1a45..2b724f6 100644 --- a/testing/tests/video_downstream_feed_contract.rs +++ b/tests/reliability/video/video_downstream_feed_contract.rs @@ -1,13 +1,13 @@ -//! Downstream eye-video feed contracts. -//! -//! Scope: exercise the server eye stream with a deterministic GStreamer test -//! source and lock down the native source-mode policy used by the launcher and -//! diagnostics. -//! Targets: `server/src/video.rs`, `server/src/video_support.rs`, -//! `common/src/eye_source.rs`. -//! Why: the live capture cards are hardware-dependent, but this catches the -//! packet, timing, keyframe, and mode-selection regressions that break the -//! client previews before hardware enters the loop. +// Downstream eye-video feed contracts. +// +// Scope: exercise the server eye stream with a deterministic GStreamer test +// source and lock down the native source-mode policy used by the launcher and +// diagnostics. +// Targets: `server/src/video.rs`, `server/src/video_support.rs`, +// `common/src/eye_source.rs`. +// Why: the live capture cards are hardware-dependent, but this catches the +// packet, timing, keyframe, and mode-selection regressions that break the +// client previews before hardware enters the loop. use futures_util::StreamExt; use lesavka_common::eye_source::{ @@ -18,8 +18,14 @@ use lesavka_server::{video, video_support::contains_idr}; use serial_test::serial; use std::time::Duration; -const SERVER_VIDEO_STREAM_CORE: &str = include_str!("../../server/src/video/stream_core.rs"); -const SERVER_VIDEO_EYE_CAPTURE: &str = include_str!("../../server/src/video/eye_capture.rs"); +const SERVER_VIDEO_STREAM_CORE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/video/stream_core.rs" +)); +const SERVER_VIDEO_EYE_CAPTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/video/eye_capture.rs" +)); #[test] fn native_downstream_eye_modes_stay_widescreen_and_square_pixel() { diff --git a/tests/security/client/paste/client_paste_security_contract.rs b/tests/security/client/paste/client_paste_security_contract.rs new file mode 100644 index 0000000..4df1edc --- /dev/null +++ b/tests/security/client/paste/client_paste_security_contract.rs @@ -0,0 +1,193 @@ +// Security coverage for encrypted paste payloads. +// +// Scope: validate that clipboard RPC payloads are encrypted, authenticated, +// and fail closed under key/nonce/ciphertext tampering. +// Targets: `client/src/paste.rs`, `server/src/paste.rs`, and shared paste key +// decoding. +// Why: paste is effectively remote keyboard injection, so corrupted or +// unauthenticated payloads should never fall through to HID writes. + +use lesavka_client::paste::build_paste_request; +use lesavka_server::paste::decrypt; +use serial_test::serial; +use temp_env::with_vars; +use tempfile::tempdir; + +const KEY_A: &str = "hex:00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; +const KEY_B: &str = "hex:ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"; + +#[test] +#[serial] +fn encrypted_paste_round_trips_only_when_keys_match() { + with_vars( + [ + ("LESAVKA_PASTE_KEY", Some(KEY_A)), + ("LESAVKA_PASTE_KEY_FILE", None::<&str>), + ], + || { + let req = build_paste_request("secret paste").expect("build request"); + assert!(req.encrypted); + assert_eq!(decrypt(&req).expect("decrypt request"), "secret paste"); + }, + ); +} + +#[test] +#[serial] +fn tampered_ciphertext_is_rejected() { + with_vars( + [ + ("LESAVKA_PASTE_KEY", Some(KEY_A)), + ("LESAVKA_PASTE_KEY_FILE", None::<&str>), + ], + || { + let mut req = build_paste_request("keep this private").expect("build request"); + req.data[0] ^= 0x55; + let err = decrypt(&req).expect_err("tampered ciphertext must fail auth"); + assert!(err.to_string().contains("paste decrypt failed")); + }, + ); +} + +#[test] +#[serial] +fn tampered_nonce_is_rejected() { + with_vars( + [ + ("LESAVKA_PASTE_KEY", Some(KEY_A)), + ("LESAVKA_PASTE_KEY_FILE", None::<&str>), + ], + || { + let mut req = build_paste_request("nonce protected").expect("build request"); + req.nonce[0] ^= 0x33; + let err = decrypt(&req).expect_err("tampered nonce must fail auth"); + assert!(err.to_string().contains("paste decrypt failed")); + }, + ); +} + +#[test] +#[serial] +fn every_nonce_byte_is_authenticated() { + with_vars( + [ + ("LESAVKA_PASTE_KEY", Some(KEY_A)), + ("LESAVKA_PASTE_KEY_FILE", None::<&str>), + ], + || { + let original = build_paste_request("nonce sweep").expect("build request"); + for index in 0..original.nonce.len() { + let mut req = original.clone(); + req.nonce[index] ^= 0x5a; + let err = decrypt(&req).expect_err("mutated nonce byte must fail auth"); + assert!( + err.to_string().contains("paste decrypt failed"), + "nonce byte {index} should fail closed, got {err:#}" + ); + } + }, + ); +} + +#[test] +#[serial] +fn truncated_ciphertexts_fail_without_leaking_plaintext() { + with_vars( + [ + ("LESAVKA_PASTE_KEY", Some(KEY_A)), + ("LESAVKA_PASTE_KEY_FILE", None::<&str>), + ], + || { + let original = build_paste_request("truncation protected").expect("build request"); + for len in [0, 1, 2, 8, 15, original.data.len().saturating_sub(1)] { + let mut req = original.clone(); + req.data.truncate(len); + let err = decrypt(&req).expect_err("truncated ciphertext must fail auth"); + let rendered = format!("{err:#}"); + assert!(rendered.contains("paste decrypt failed")); + assert!(!rendered.contains("truncation protected")); + } + }, + ); +} + +#[test] +#[serial] +fn malformed_nonce_is_rejected_without_panicking() { + with_vars( + [ + ("LESAVKA_PASTE_KEY", Some(KEY_A)), + ("LESAVKA_PASTE_KEY_FILE", None::<&str>), + ], + || { + let mut req = build_paste_request("bad nonce").expect("build request"); + req.nonce.truncate(3); + let err = decrypt(&req).expect_err("malformed nonce must fail validation"); + assert!(err.to_string().contains("12 bytes")); + }, + ); +} + +#[test] +#[serial] +fn missing_key_blocks_client_request_creation() { + let dir = tempdir().expect("empty home"); + with_vars( + [ + ("LESAVKA_PASTE_KEY", None::<&str>), + ("LESAVKA_PASTE_KEY_FILE", None::<&str>), + ("HOME", Some(dir.path().to_string_lossy().as_ref())), + ], + || { + let err = build_paste_request("no key").expect_err("missing key must fail"); + let rendered = format!("{err:#}"); + assert!( + rendered.contains("LESAVKA_PASTE_KEY") || rendered.contains("paste key file"), + "missing key error should point at the env var or default key file, got: {rendered}" + ); + }, + ); +} + +#[test] +#[serial] +fn bad_server_key_rejects_client_payload() { + let req = with_vars( + [ + ("LESAVKA_PASTE_KEY", Some(KEY_A)), + ("LESAVKA_PASTE_KEY_FILE", None::<&str>), + ], + || build_paste_request("wrong key").expect("build request"), + ); + + with_vars( + [ + ("LESAVKA_PASTE_KEY", Some(KEY_B)), + ("LESAVKA_PASTE_KEY_FILE", None::<&str>), + ], + || { + let err = decrypt(&req).expect_err("wrong key must fail auth"); + let rendered = format!("{err:#}"); + assert!(rendered.contains("paste decrypt failed")); + assert!(!rendered.contains(KEY_A)); + assert!(!rendered.contains("wrong key")); + }, + ); +} + +#[test] +#[serial] +fn plaintext_downgrade_is_rejected() { + with_vars( + [ + ("LESAVKA_PASTE_KEY", Some(KEY_A)), + ("LESAVKA_PASTE_KEY_FILE", None::<&str>), + ], + || { + let mut req = build_paste_request("plaintext downgrade").expect("build request"); + req.encrypted = false; + let err = decrypt(&req).expect_err("plaintext must not be accepted"); + assert!(err.to_string().contains("encrypted")); + }, + ); +} diff --git a/tests/security/install/cert_key_permissions_contract.rs b/tests/security/install/cert_key_permissions_contract.rs new file mode 100644 index 0000000..7f7e7c1 --- /dev/null +++ b/tests/security/install/cert_key_permissions_contract.rs @@ -0,0 +1,66 @@ +// Security coverage for installed secret permissions. +// +// Scope: preserve installer and UI behavior that keeps private keys readable +// only by the local Lesavka operator account. +// Targets: install scripts and the client TLS-bundle import helper. +// Why: mTLS is only meaningful if client/server private keys are not installed +// with broad filesystem permissions. + +const SERVER_INSTALL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/install/server.sh" +)); +const CLIENT_INSTALL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/install/client.sh" +)); +const CERT_UI: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/utility_button_bindings.rs" +)); + +#[test] +fn server_private_keys_and_client_bundle_are_installed_private() { + for marker in [ + "chmod 0600 \"$LESAVKA_TLS_DIR/\"*.key", + "chmod 0644 \"$LESAVKA_TLS_DIR/\"*.crt", + "chmod 0600 \"$LESAVKA_CLIENT_BUNDLE\"", + "cp \"$LESAVKA_TLS_DIR/client.key\"", + ] { + assert!( + SERVER_INSTALL.contains(marker), + "server installer should preserve private PKI permission marker {marker}" + ); + } +} + +#[test] +fn client_installer_preserves_private_client_key_mode() { + for marker in [ + "sudo install -m 0600", + "\"$tmp/client.key\" \"$CLIENT_PKI_DIR/client.key\"", + "sudo install -m 0644", + "\"$tmp/ca.crt\"", + "\"$tmp/client.crt\"", + ] { + assert!( + CLIENT_INSTALL.contains(marker), + "client installer should preserve enrollment permission marker {marker}" + ); + } +} + +#[test] +fn client_ui_bundle_import_tightens_key_permissions() { + for marker in [ + "tighten_client_key_permissions", + "PermissionsExt", + "permissions.set_mode(0o600)", + "target.join(\"client.key\")", + ] { + assert!( + CERT_UI.contains(marker), + "cert import UI should preserve permission marker {marker}" + ); + } +} diff --git a/tests/security/scripts/install/tls_security_contract.rs b/tests/security/scripts/install/tls_security_contract.rs new file mode 100644 index 0000000..221a3c0 --- /dev/null +++ b/tests/security/scripts/install/tls_security_contract.rs @@ -0,0 +1,64 @@ +// Security contract for relay TLS and client identity handling. +// +// Scope: verify installers and runtime keep mTLS assets explicit and avoid +// silently treating plaintext relay access as production-safe. +// Targets: `scripts/install/server.sh`, `scripts/install/client.sh`, and +// `server/src/security.rs`. +// Why: the relay carries HID, clipboard, microphone, and camera data; transport +// security should be intentional rather than an accidental deployment detail. + +const SERVER_INSTALL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/install/server.sh" +)); +const CLIENT_INSTALL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/install/client.sh" +)); +const SECURITY: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/security.rs" +)); + +#[test] +fn relay_tls_assets_are_created_packaged_and_required_by_default() { + for marker in [ + "LESAVKA_REQUIRE_TLS:-1", + "LESAVKA_TLS_CERT", + "LESAVKA_TLS_KEY", + "LESAVKA_TLS_CLIENT_CA", + "lesavka-client-pki.tar.gz", + "chmod 0600", + ] { + assert!( + SERVER_INSTALL.contains(marker), + "server installer should preserve TLS marker {marker}" + ); + } + + for marker in [ + "LESAVKA_CLIENT_PKI_BUNDLE", + "ca.crt", + "client.crt", + "client.key", + "HTTPS/mTLS relay connections will not work until this bundle is installed", + ] { + assert!( + CLIENT_INSTALL.contains(marker), + "client installer should preserve TLS client marker {marker}" + ); + } + + for marker in [ + "LESAVKA_REQUIRE_TLS", + "LESAVKA_TLS_CLIENT_CA", + "serving plaintext gRPC for local/dev use", + "ServerTlsConfig", + "client_ca_root", + ] { + assert!( + SECURITY.contains(marker), + "runtime security module should preserve marker {marker}" + ); + } +} diff --git a/tests/security/server/tls/server_tls_security_contract.rs b/tests/security/server/tls/server_tls_security_contract.rs new file mode 100644 index 0000000..c592abd --- /dev/null +++ b/tests/security/server/tls/server_tls_security_contract.rs @@ -0,0 +1,184 @@ +// Security coverage for relay TLS/mTLS fail-closed policy. +// +// Scope: exercise local TLS policy helpers and preserve installer/runtime +// markers that make production relay access client-authenticated. +// Targets: `server/src/security.rs`, `client/src/relay_transport.rs`, and +// install-time PKI handling. +// Why: HID, clipboard, mic, and camera RPCs are too powerful to rely on +// application-level good behavior; production trust must be enforced at the +// transport boundary. + +use lesavka_client::relay_transport::{endpoint, enforce_transport_policy}; +use lesavka_server::security::server_tls_config; +use serial_test::serial; +use temp_env::with_vars; +use tempfile::tempdir; + +const SERVER_INSTALL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/install/server.sh" +)); +const CLIENT_INSTALL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/install/client.sh" +)); +const SERVER_SECURITY: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/security.rs" +)); +const CLIENT_TRANSPORT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/relay_transport.rs" +)); +const SERVER_ENTRYPOINT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/entrypoint.rs" +)); + +#[test] +#[serial] +fn production_tls_requires_server_identity_files() { + with_vars( + [ + ("LESAVKA_REQUIRE_TLS", Some("1")), + ("LESAVKA_TLS_CERT", Some("/tmp/lesavka-missing-server.crt")), + ("LESAVKA_TLS_KEY", Some("/tmp/lesavka-missing-server.key")), + ("LESAVKA_TLS_CLIENT_CA", Some("/tmp/lesavka-missing-ca.crt")), + ], + || { + let err = server_tls_config().expect_err("required TLS must fail closed"); + assert!(err.to_string().contains("LESAVKA_REQUIRE_TLS=1")); + }, + ); +} + +#[test] +#[serial] +fn server_tls_config_accepts_identity_and_keeps_client_ca_binding_explicit() { + let dir = tempdir().expect("tls dir"); + let cert = dir.path().join("server.crt"); + let key = dir.path().join("server.key"); + let ca = dir.path().join("ca.crt"); + std::fs::write(&cert, b"not a real cert").expect("write cert"); + std::fs::write(&key, b"not a real key").expect("write key"); + std::fs::write(&ca, b"not a real ca").expect("write ca"); + + with_vars( + [ + ("LESAVKA_REQUIRE_TLS", Some("1")), + ("LESAVKA_TLS_CERT", Some(cert.to_string_lossy().as_ref())), + ("LESAVKA_TLS_KEY", Some(key.to_string_lossy().as_ref())), + ("LESAVKA_TLS_CLIENT_CA", Some(ca.to_string_lossy().as_ref())), + ("LESAVKA_TLS_CLIENT_AUTH_OPTIONAL", Some("0")), + ], + || { + assert!( + server_tls_config() + .expect("identity and CA should build TLS config") + .is_some() + ); + }, + ); + + assert!(SERVER_SECURITY.contains("client_ca_root")); + assert!(SERVER_SECURITY.contains("LESAVKA_TLS_CLIENT_AUTH_OPTIONAL")); +} + +#[test] +#[serial] +fn client_https_endpoint_fails_fast_without_enrollment() { + let dir = tempdir().expect("empty home"); + with_vars( + [ + ("HOME", Some(dir.path().to_string_lossy().as_ref())), + ("LESAVKA_TLS_CA", None::<&str>), + ("LESAVKA_TLS_CLIENT_CERT", None::<&str>), + ("LESAVKA_TLS_CLIENT_KEY", None::<&str>), + ("LESAVKA_ALLOW_INSECURE", None::<&str>), + ], + || { + let err = endpoint("https://38.28.125.112:50051") + .expect_err("missing client cert must fail before connecting"); + let rendered = format!("{err:#}"); + assert!(rendered.contains("TLS enrollment is missing")); + assert!(rendered.contains("client.key")); + }, + ); +} + +#[test] +#[serial] +fn client_https_endpoint_rejects_malformed_enrollment_material() { + let dir = tempdir().expect("pki home"); + let pki = dir.path().join(".config/lesavka/pki"); + std::fs::create_dir_all(&pki).expect("create pki"); + std::fs::write(pki.join("ca.crt"), b"wrong ca").expect("write ca"); + std::fs::write(pki.join("client.crt"), b"malformed cert").expect("write cert"); + std::fs::write(pki.join("client.key"), b"malformed key").expect("write key"); + + with_vars( + [ + ("HOME", Some(dir.path().to_string_lossy().as_ref())), + ("LESAVKA_TLS_CA", None::<&str>), + ("LESAVKA_TLS_CLIENT_CERT", None::<&str>), + ("LESAVKA_TLS_CLIENT_KEY", None::<&str>), + ], + || { + let err = endpoint("https://lesavka-server:50051") + .expect_err("malformed cert/key material must fail"); + assert!(format!("{err:#}").contains("configuring relay TLS")); + }, + ); +} + +#[test] +#[serial] +fn public_plaintext_transport_requires_deliberate_lab_override() { + with_vars([("LESAVKA_ALLOW_INSECURE", None::<&str>)], || { + let err = enforce_transport_policy("http://38.28.125.112:50051") + .expect_err("public plaintext relay should be rejected"); + assert!( + err.to_string() + .contains("refusing insecure relay transport") + ); + }); + + with_vars([("LESAVKA_ALLOW_INSECURE", Some("1"))], || { + enforce_transport_policy("http://38.28.125.112:50051") + .expect("explicit lab override should allow plaintext"); + }); +} + +#[test] +fn install_and_runtime_paths_keep_mtls_client_identity_first_class() { + for marker in [ + "LESAVKA_REQUIRE_TLS:-1", + "LESAVKA_TLS_CLIENT_CA", + "openssl genrsa -out \"$LESAVKA_TLS_DIR/client.key\"", + "lesavka-client-pki.tar.gz", + "chmod 0600 \"$LESAVKA_TLS_DIR/\"*.key", + ] { + assert!( + SERVER_INSTALL.contains(marker), + "server installer should preserve mTLS marker {marker}" + ); + } + + for marker in [ + "LESAVKA_CLIENT_PKI_BUNDLE", + "ca.crt", + "client.crt", + "client.key", + "install -m 0600", + ] { + assert!( + CLIENT_INSTALL.contains(marker), + "client installer should preserve enrollment marker {marker}" + ); + } + + assert!(SERVER_ENTRYPOINT.contains("security::server_tls_config()?")); + assert!(SERVER_ENTRYPOINT.contains(".tls_config(tls)?")); + assert!(CLIENT_TRANSPORT.contains("tls.identity(Identity::from_pem(cert, key))")); + assert!(CLIENT_TRANSPORT.contains("tls.ca_certificate(Certificate::from_pem(ca))")); +} diff --git a/tests/security/server/upstream_media/upstream_media_payload_security_contract.rs b/tests/security/server/upstream_media/upstream_media_payload_security_contract.rs new file mode 100644 index 0000000..9d4ffe2 --- /dev/null +++ b/tests/security/server/upstream_media/upstream_media_payload_security_contract.rs @@ -0,0 +1,87 @@ +// Security contracts for malformed upstream media payload handling. +// +// Scope: inspect server-side bundled upstream media ingress before UVC/UAC +// handoff. +// Targets: server/src/main/relay_service.rs and +// server/src/main/relay_service/upstream_media_rpc.rs. +// Why: upstream media payloads arrive from remote clients; malformed, stale, +// replayed, or impossible bundles should be dropped as data, not played into +// the browser-visible webcam/microphone path. + +const RELAY_SERVICE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_service.rs" +)); +const UPSTREAM_RPC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_service/upstream_media_rpc.rs" +)); + +#[test] +fn empty_and_impossible_mixed_bundles_are_rejected_before_handoff() { + for marker in [ + "if !has_audio && !has_video", + "return None;", + "MEDIA_V2_MAX_MIXED_CAPTURE_SPAN_US", + "dropping mixed bundle with impossible A/V capture span", + "continue;", + ] { + assert!( + RELAY_SERVICE.contains(marker) || UPSTREAM_RPC.contains(marker), + "upstream media ingress should preserve malformed-bundle guard {marker}" + ); + } + + let impossible_span_idx = UPSTREAM_RPC + .find("dropping mixed bundle with impossible A/V capture span") + .expect("impossible span drop marker"); + let audio_handoff_idx = UPSTREAM_RPC + .find(".send(scheduled_audio)") + .expect("audio handoff send marker"); + assert!( + impossible_span_idx < audio_handoff_idx, + "impossible A/V capture spans must be dropped before handoff workers receive media" + ); +} + +#[test] +fn stale_or_replayed_bundles_are_dropped_before_codec_recovery_state_advances() { + for marker in [ + "bundle.seq <= seq", + "dropping duplicate/stale bundled packet", + "max_queue_age_ms", + "media_v2_handoff_schedule", + "dropping whole bundle because it is already outside the freshness budget", + ] { + assert!( + RELAY_SERVICE.contains(marker) || UPSTREAM_RPC.contains(marker), + "upstream media ingress should preserve replay/stale guard {marker}" + ); + } + + let stale_seq_idx = UPSTREAM_RPC + .find("dropping duplicate/stale bundled packet") + .expect("stale sequence marker"); + let schedule_idx = UPSTREAM_RPC + .find("media_v2_handoff_schedule") + .expect("schedule marker"); + assert!( + stale_seq_idx < schedule_idx, + "replayed bundles should be dropped before scheduling output media" + ); +} + +#[test] +fn stale_hevc_video_enters_recovery_instead_of_streaming_delta_damage() { + for marker in [ + "waiting_for_hevc_keyframe = true", + "v2 held HEVC delta frame until next recovery keyframe", + "media_v2_should_hold_hevc_video_for_recovery", + "media_v2_has_hevc_recovery_keyframe", + ] { + assert!( + RELAY_SERVICE.contains(marker) || UPSTREAM_RPC.contains(marker), + "HEVC ingress should preserve recovery marker {marker}" + ); + } +} diff --git a/testing/tests/client_runtime_smoke_contract.rs b/tests/smoke/client/runtime/client_runtime_smoke_contract.rs similarity index 89% rename from testing/tests/client_runtime_smoke_contract.rs rename to tests/smoke/client/runtime/client_runtime_smoke_contract.rs index bd81f6c..bfc11e0 100644 --- a/testing/tests/client_runtime_smoke_contract.rs +++ b/tests/smoke/client/runtime/client_runtime_smoke_contract.rs @@ -1,11 +1,11 @@ -//! Integration smoke coverage for client runtime constructors. -//! -//! Scope: execute public client startup/media constructors through stable APIs -//! without requiring hardware-specific assertions. -//! Targets: `client/src/app.rs`, `client/src/input/camera.rs`, -//! `client/src/input/microphone.rs`, and `client/src/output/audio.rs`. -//! Why: these paths are operationally important but often depend on runtime -//! host capabilities; smoke contracts keep them exercised in CI. +// Integration smoke coverage for client runtime constructors. +// +// Scope: execute public client startup/media constructors through stable APIs +// without requiring hardware-specific assertions. +// Targets: `client/src/app.rs`, `client/src/input/camera.rs`, +// `client/src/input/microphone.rs`, and `client/src/output/audio.rs`. +// Why: these paths are operationally important but often depend on runtime +// host capabilities; smoke contracts keep them exercised in CI. use lesavka_client::LesavkaClientApp; use lesavka_client::input::camera::{CameraCapture, CameraCodec, CameraConfig}; diff --git a/testing/tests/server_runtime_smoke_contract.rs b/tests/smoke/server/runtime_support/server_runtime_smoke_contract.rs similarity index 96% rename from testing/tests/server_runtime_smoke_contract.rs rename to tests/smoke/server/runtime_support/server_runtime_smoke_contract.rs index 53e1ea1..2c5977a 100644 --- a/testing/tests/server_runtime_smoke_contract.rs +++ b/tests/smoke/server/runtime_support/server_runtime_smoke_contract.rs @@ -1,11 +1,11 @@ -//! Integration smoke coverage for server runtime helpers. -//! -//! Scope: exercise public runtime helpers that should be stable even on hosts -//! without full gadget/audio plumbing. -//! Targets: `server/src/audio.rs`, `server/src/gadget.rs`, -//! `server/src/runtime_support.rs`. -//! Why: these contracts provide broad safety coverage around startup/recovery -//! code paths that are otherwise hard to hit from unit-only tests. +// Integration smoke coverage for server runtime helpers. +// +// Scope: exercise public runtime helpers that should be stable even on hosts +// without full gadget/audio plumbing. +// Targets: `server/src/audio.rs`, `server/src/gadget.rs`, +// `server/src/runtime_support.rs`. +// Why: these contracts provide broad safety coverage around startup/recovery +// code paths that are otherwise hard to hit from unit-only tests. use lesavka_server::audio::{self, ClipTap}; use lesavka_server::gadget::UsbGadget; diff --git a/testing/tests/server_video_sink_smoke_contract.rs b/tests/smoke/server/video_sinks/server_video_sink_smoke_contract.rs similarity index 90% rename from testing/tests/server_video_sink_smoke_contract.rs rename to tests/smoke/server/video_sinks/server_video_sink_smoke_contract.rs index 9d87916..cbdc395 100644 --- a/testing/tests/server_video_sink_smoke_contract.rs +++ b/tests/smoke/server/video_sinks/server_video_sink_smoke_contract.rs @@ -1,10 +1,10 @@ -//! Integration smoke coverage for server camera sink constructors. -//! -//! Scope: exercise public sink and relay constructors with resilient -//! assertions that tolerate host-specific media capabilities. -//! Targets: `server/src/video_sinks.rs`. -//! Why: sink setup contains substantial branch logic that should be executed -//! in CI even when real HDMI/UVC hardware is unavailable. +// Integration smoke coverage for server camera sink constructors. +// +// Scope: exercise public sink and relay constructors with resilient +// assertions that tolerate host-specific media capabilities. +// Targets: `server/src/video_sinks.rs`. +// Why: sink setup contains substantial branch logic that should be executed +// in CI even when real HDMI/UVC hardware is unavailable. use lesavka_common::lesavka::VideoPacket; use lesavka_server::camera::{CameraCodec, CameraConfig, CameraOutput}; diff --git a/tests/system/scripts/install/system_installation_contract.rs b/tests/system/scripts/install/system_installation_contract.rs new file mode 100644 index 0000000..7d08d2c --- /dev/null +++ b/tests/system/scripts/install/system_installation_contract.rs @@ -0,0 +1,48 @@ +// System installation contract for safe server/client deployment scripts. +// +// Scope: verify install scripts keep idempotent runtime defaults and explicit +// operator gates around disruptive system changes. +// Targets: `scripts/install/server.sh` and `scripts/install/client.sh`. +// Why: Lesavka runs across multiple machines; install scripts must configure +// the system repeatably without surprising gadget resets or missing media deps. + +const SERVER_INSTALL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/install/server.sh" +)); +const CLIENT_INSTALL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/install/client.sh" +)); + +#[test] +fn system_installers_keep_disruptive_changes_opt_in_and_media_defaults_explicit() { + for marker in [ + "LESAVKA_INSTALL_CAM_OUTPUT", + "LESAVKA_INSTALL_UVC_CODEC", + "LESAVKA_FORCE_GADGET_REBUILD", + "LESAVKA_ALLOW_GADGET_RESET", + "Preserving the attached gadget to avoid wedging the Pi USB controller", + "LESAVKA_KERNEL_UPDATE:-0", + "gst-plugins-bad", + "gst-libav", + ] { + assert!( + SERVER_INSTALL.contains(marker), + "server installer should preserve marker {marker}" + ); + } + + for marker in [ + "LESAVKA_CLIENT_PKI_BUNDLE", + "desktop-file-utils", + "gst-plugin-pipewire", + "LESAVKA_REF", + "Installing launchable client binaries", + ] { + assert!( + CLIENT_INSTALL.contains(marker), + "client installer should preserve marker {marker}" + ); + } +} diff --git a/tests/system/scripts/install/systemd_unit_env_contract.rs b/tests/system/scripts/install/systemd_unit_env_contract.rs new file mode 100644 index 0000000..a940f16 --- /dev/null +++ b/tests/system/scripts/install/systemd_unit_env_contract.rs @@ -0,0 +1,90 @@ +// System lifecycle contract for generated systemd units and env files. +// +// Scope: assert install-time unit/env generation remains idempotent and +// restart-safe without touching the live host. +// Targets: `scripts/install/server.sh`. +// Why: the server is a bare-metal appliance; systemd/env drift is one of the +// easiest ways to reintroduce stale codecs or unsafe gadget behavior. + +const SERVER_INSTALL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/install/server.sh" +)); + +#[test] +fn generated_systemd_units_load_runtime_env_files_in_the_right_places() { + for marker in [ + "EnvironmentFile=-/etc/lesavka/server.env", + "EnvironmentFile=-/etc/lesavka/uvc.env", + "ExecStartPre=/usr/local/bin/lesavka-core.sh --attach", + "ExecStart=/usr/local/bin/lesavka-server", + "ExecStart=/usr/local/bin/lesavka-uvc.sh", + "Wants=lesavka-uvc.service", + "Requires=lesavka-core.service", + ] { + assert!( + SERVER_INSTALL.contains(marker), + "generated units should preserve marker {marker}" + ); + } +} + +#[test] +fn generated_units_are_restartable_without_leaking_helper_processes() { + for marker in [ + "Restart=always", + "RestartSec=5", + "RestartSec=2", + "KillMode=control-group", + "TimeoutStopSec=10", + "StandardError=append:/var/log/lesavka/server.stderr", + "StandardError=append:/var/log/lesavka/uvc.stderr", + ] { + assert!( + SERVER_INSTALL.contains(marker), + "generated units should preserve restart marker {marker}" + ); + } + assert!( + !SERVER_INSTALL.contains("KillMode=process"), + "helpers should not be left behind by process-only termination" + ); + assert!( + !SERVER_INSTALL.contains("RefuseManualStop=yes"), + "operators need to be able to stop services during recovery" + ); +} + +#[test] +fn env_files_are_rendered_to_tempfiles_then_installed_idempotently() { + for marker in [ + "render_uvc_env_file >\"$UVC_ENV_TMP\"", + "sudo cmp -s \"$UVC_ENV_TMP\" /etc/lesavka/uvc.env", + "sudo install -m 0644 \"$UVC_ENV_TMP\" /etc/lesavka/uvc.env", + "UVC_ENV_CHANGED=0", + "lesavka-uvc already active; runtime settings unchanged.", + ] { + assert!( + SERVER_INSTALL.contains(marker), + "UVC env update should preserve idempotence marker {marker}" + ); + } +} + +#[test] +fn server_env_persists_runtime_profile_and_tls_settings() { + for marker in [ + "LESAVKA_CAM_CODEC=%s", + "LESAVKA_UPSTREAM_HEVC_VIDEO_PLAYOUT_MODE_OFFSETS_US=%s", + "LESAVKA_UPSTREAM_MJPEG_VIDEO_PLAYOUT_MODE_OFFSETS_US=%s", + "LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP=%s", + "LESAVKA_SERVER_BIND_ADDR=%s", + "LESAVKA_REQUIRE_TLS=%s", + "LESAVKA_TLS_CLIENT_CA=%s", + ] { + assert!( + SERVER_INSTALL.contains(marker), + "server env should persist marker {marker}" + ); + } +} diff --git a/tests/test-taxonomy-manifest.json b/tests/test-taxonomy-manifest.json new file mode 100644 index 0000000..f017389 --- /dev/null +++ b/tests/test-taxonomy-manifest.json @@ -0,0 +1,390 @@ +[ + { + "category": "contract", + "new": "tests/contract/client/app/client_app_include_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/app/client_app_process_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/sync_probe/client_browser_sync_script_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/input/camera/client_camera_include_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/input/camera/client_camera_timing_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/sync_probe/client_hevc_bundle_audit_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/input/inputs/client_inputs_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/input/inputs/client_inputs_extra_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/input/inputs/client_inputs_routing_contract.rs" + }, + { + "category": "regression", + "new": "tests/regression/client/input/inputs/client_inputs_toggle_contract.rs" + }, + { + "category": "installer", + "new": "tests/installer/scripts/install/client_install_script_contract.rs" + }, + { + "category": "regression", + "new": "tests/regression/client/input/keyboard/client_keyboard_activation_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/input/keyboard/client_keyboard_clipboard_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/input/keyboard/client_keyboard_include_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/input/keyboard/client_keyboard_include_extra_contract.rs" + }, + { + "category": "api", + "new": "tests/api/client/input/keyboard/client_keyboard_paste_rpc_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/input/keyboard/client_keyboard_process_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/input/keyboard/client_keyboard_shift_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/input/keyboard/client_keymap_contract.rs" + }, + { + "category": "ui", + "new": "tests/ui/client/launcher/client_launcher_layout_contract.rs" + }, + { + "category": "ui", + "new": "tests/ui/client/launcher/client_launcher_runtime_contract.rs" + }, + { + "category": "ui", + "new": "tests/ui/client/launcher/client_layout_contract.rs" + }, + { + "category": "reliability", + "new": "tests/reliability/client/diagnostics/client_log_noise_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/main/client_main_binary_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/main/client_main_process_contract.rs" + }, + { + "category": "manual", + "new": "tests/manual/client/sync_probe/client_manual_sync_script_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/input/microphone/client_microphone_gain_control_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/input/microphone/client_microphone_include_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/input/microphone/client_microphone_requested_source_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/input/microphone/client_microphone_source_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/input/microphone/client_microphone_startup_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/input/microphone/client_microphone_tap_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/input/mouse/client_mouse_include_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/input/mouse/client_mouse_include_extra_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/input/mouse/client_mouse_uinput_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/output/audio/client_output_audio_include_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/output/display/client_output_display_include_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/output/video/client_output_video_include_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/client/paste/client_paste_contract.rs" + }, + { + "category": "manual", + "new": "tests/manual/client/sync_probe/client_rct_transport_probe_contract.rs" + }, + { + "category": "api", + "new": "tests/api/client/bin/lesavka_relayctl/client_relayctl_binary_contract.rs" + }, + { + "category": "api", + "new": "tests/api/client/bin/lesavka_relayctl/client_relayctl_process_contract.rs" + }, + { + "category": "smoke", + "new": "tests/smoke/client/runtime/client_runtime_smoke_contract.rs" + }, + { + "category": "manual", + "new": "tests/manual/client/sync_probe/client_server_rc_matrix_script_contract.rs" + }, + { + "category": "reliability", + "new": "tests/reliability/client/uplink/client_uplink_freshness_contract.rs" + }, + { + "category": "performance", + "new": "tests/performance/client/uplink/client_uplink_performance_contract.rs" + }, + { + "category": "compatibility", + "new": "tests/compatibility/client/video_support/client_video_support_include_contract.rs" + }, + { + "category": "api", + "new": "tests/api/common/cli/common_cli_binary_contract.rs" + }, + { + "category": "api", + "new": "tests/api/common/cli/common_cli_contract.rs" + }, + { + "category": "api", + "new": "tests/api/common/handshake/handshake_camera_contract.rs" + }, + { + "category": "performance", + "new": "tests/performance/scripts/ci/performance_gate_script_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/server/audio/server_audio_include_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/server/camera/server_camera_contract.rs" + }, + { + "category": "compatibility", + "new": "tests/compatibility/server/camera/server_camera_runtime_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/scripts/daemon/server_core_script_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/server/gadget/server_gadget_include_contract.rs" + }, + { + "category": "regression", + "new": "tests/regression/server/gadget/server_gadget_recovery_contract.rs" + }, + { + "category": "installer", + "new": "tests/installer/scripts/install/server_install_script_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/server/main/server_main_binary_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/server/main/server_main_binary_extra_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/server/main/server_main_eye_hub_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/server/main/server_main_media_extra_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/server/main/server_main_process_contract.rs" + }, + { + "category": "api", + "new": "tests/api/server/main/server_main_rpc_contract.rs" + }, + { + "category": "api", + "new": "tests/api/server/main/server_main_rpc_reset_contract.rs" + }, + { + "category": "api", + "new": "tests/api/server/main/server_main_state_rpc_contract.rs" + }, + { + "category": "regression", + "new": "tests/regression/server/main/server_main_usb_recovery_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/server/runtime_support/server_runtime_contract.rs" + }, + { + "category": "smoke", + "new": "tests/smoke/server/runtime_support/server_runtime_smoke_contract.rs" + }, + { + "category": "api", + "new": "tests/api/server/upstream_media_runtime/server_upstream_media_audio_contract.rs" + }, + { + "category": "api", + "new": "tests/api/server/upstream_media_runtime/server_upstream_media_bundle_contract.rs" + }, + { + "category": "api", + "new": "tests/api/server/upstream_media_runtime/server_upstream_media_contract.rs" + }, + { + "category": "api", + "new": "tests/api/server/upstream_media_runtime/server_upstream_media_pairing_contract.rs" + }, + { + "category": "reliability", + "new": "tests/reliability/server/upstream_media_runtime/server_upstream_media_pairing_freshness_contract.rs" + }, + { + "category": "api", + "new": "tests/api/server/upstream_media_runtime/server_upstream_media_v2_handoff_contract.rs" + }, + { + "category": "api", + "new": "tests/api/server/upstream_media_runtime/server_upstream_media_video_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/server/uvc/server_uvc_binary_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/server/uvc/server_uvc_binary_extra_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/server/uvc/server_uvc_process_contract.rs" + }, + { + "category": "compatibility", + "new": "tests/compatibility/server/uvc/server_uvc_runtime_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/server/uvc/server_uvc_script_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/server/video/server_video_include_contract.rs" + }, + { + "category": "smoke", + "new": "tests/smoke/server/video_sinks/server_video_sink_smoke_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/server/video_sinks/server_video_sinks_include_contract.rs" + }, + { + "category": "contract", + "new": "tests/contract/common/hid/shared_hid_contract.rs" + }, + { + "category": "manual", + "new": "tests/manual/scripts/manual/uvc_frame_meta_log_contract.rs" + }, + { + "category": "reliability", + "new": "tests/reliability/video/video_downstream_feed_contract.rs" + }, + { + "category": "compatibility", + "new": "tests/compatibility/video/video_support_contract.rs" + }, + { + "category": "unit", + "new": "tests/unit/common/hid/common_hid_unit.rs" + }, + { + "category": "component", + "new": "tests/component/client/uplink/client_uplink_component_contract.rs" + }, + { + "category": "integration", + "new": "tests/integration/common/proto/relay_proto_integration_contract.rs" + }, + { + "category": "e2e", + "new": "tests/e2e/scripts/manual/upstream_media_e2e_contract.rs" + }, + { + "category": "system", + "new": "tests/system/scripts/install/system_installation_contract.rs" + }, + { + "category": "chaos", + "new": "tests/chaos/client/uplink/uplink_backpressure_chaos_contract.rs" + }, + { + "category": "security", + "new": "tests/security/scripts/install/tls_security_contract.rs" + }, + { + "category": "helpers", + "new": "tests/helpers/support/live_capture_clock_shim.rs" + }, + { + "category": "helpers", + "new": "tests/helpers/support/server_upstream_media_harness.rs" + } +] diff --git a/tests/ui/client/launcher/client_audio_recovery_ui_contract.rs b/tests/ui/client/launcher/client_audio_recovery_ui_contract.rs new file mode 100644 index 0000000..7448293 --- /dev/null +++ b/tests/ui/client/launcher/client_audio_recovery_ui_contract.rs @@ -0,0 +1,66 @@ +// UI contract for the Recover Upstream audio controls. +// +// Scope: source-level assertions around the launcher recovery rail and button +// actions. This avoids opening a real GTK session while still protecting copy, +// labels, tooltips, and RPC routing. +// Targets: `client/src/launcher/ui_components/build_operations_rail.rs` and +// `client/src/launcher/ui/utility_button_bindings.rs`. +// Why: operators need the button that fixed the stale Google Meet epoch to say +// exactly what it does, and it must not be confused with USB or video recovery. + +const OPERATIONS_RAIL_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui_components/build_operations_rail.rs" +)); +const UTILITY_BINDINGS_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/utility_button_bindings.rs" +)); + +#[test] +fn recover_audio_button_is_labeled_as_audio_under_recover_upstream() { + for marker in [ + "let recovery_heading = gtk::Label::new(Some(\"Recover\\nUpstream\"));", + "let uac_recover_button = rail_button(", + "\"Audio\",", + "retire the stale upstream audio epoch", + "bundled mic+camera reconnect without resetting USB or changing calibration", + ] { + assert!( + OPERATIONS_RAIL_SRC.contains(marker), + "Recover Audio UI should preserve marker {marker}" + ); + } + + assert!( + !OPERATIONS_RAIL_SRC.contains("\"Heal A/V\""), + "the old label implied video/calibration mutation and should stay retired" + ); +} + +#[test] +fn recover_audio_click_routes_to_uac_only_and_explains_side_effects() { + let audio_start = UTILITY_BINDINGS_SRC + .find("widgets.uac_recover_button.connect_clicked") + .expect("audio recovery binding should exist"); + let video_start = UTILITY_BINDINGS_SRC + .find("widgets.uvc_recover_button.connect_clicked") + .expect("video recovery binding should follow audio recovery"); + let audio_block = &UTILITY_BINDINGS_SRC[audio_start..video_start]; + + for marker in [ + "Recover Audio 1/3: retiring the stale upstream audio epoch cleanly", + "recover_uac_soft(&server_addr)", + "Recover Audio 2/3: old epoch released", + "bundled media will reconnect without resetting USB or calibration", + ] { + assert!( + audio_block.contains(marker), + "Recover Audio action should preserve marker {marker}" + ); + } + + assert!(!audio_block.contains("recover_usb_soft")); + assert!(!audio_block.contains("recover_uvc_soft")); + assert!(!audio_block.contains("reset_usb")); +} diff --git a/tests/ui/client/launcher/client_codec_transport_ui_contract.rs b/tests/ui/client/launcher/client_codec_transport_ui_contract.rs new file mode 100644 index 0000000..1e9ba0c --- /dev/null +++ b/tests/ui/client/launcher/client_codec_transport_ui_contract.rs @@ -0,0 +1,113 @@ +// UI contract for upstream webcam transport truth. +// +// Scope: source-level assertions around the HEVC/MJPEG selector and status +// chips without opening a GTK session. +// Targets: `client/src/launcher/state/*`, +// `client/src/launcher/ui_components/build_device_controls.rs`, and +// `client/src/launcher/ui_runtime/status_details.rs`. +// Why: the launcher must not show a decorative codec selector; the selected +// transport has calibrated server timing consequences and must match runtime. + +const SELECTION_MODELS_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/state/selection_models.rs" +)); +const LAUNCHER_MOD_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/mod.rs" +)); +const BUILD_DEVICE_CONTROLS_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui_components/build_device_controls.rs" +)); +const STATUS_REFRESH_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui_runtime/status_refresh.rs" +)); +const STATUS_DETAILS_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui_runtime/status_details.rs" +)); +const STAGE_DEVICE_BINDINGS_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/stage_device_bindings.rs" +)); + +#[test] +fn webcam_transport_selector_exposes_real_hevc_and_mjpeg_choices() { + for marker in [ + "pub enum WebcamTransport", + "#[default]\n Hevc", + "Self::Hevc => \"hevc\"", + "Self::Mjpeg => \"mjpeg\"", + "Self::Hevc => \"HEVC\"", + "Self::Mjpeg => \"MJPEG\"", + "LESAVKA_CAM_CODEC", + "state.webcam_transport.env_value().to_string()", + ] { + assert!( + SELECTION_MODELS_SRC.contains(marker) || LAUNCHER_MOD_SRC.contains(marker), + "webcam transport contract should preserve marker {marker}" + ); + } + + for marker in [ + "for transport in [WebcamTransport::Hevc, WebcamTransport::Mjpeg]", + "webcam_transport_combo.append(Some(transport.as_id()), transport.label());", + "webcam_transport_combo.set_active_id(Some(state.webcam_transport.as_id()));", + "webcam_transport_combo.set_sensitive(true);", + "HEVC is the low-latency default; MJPEG is the calibrated fallback", + ] { + assert!( + BUILD_DEVICE_CONTROLS_SRC.contains(marker), + "webcam transport dropdown should preserve marker {marker}" + ); + } +} + +#[test] +fn webcam_transport_changes_are_staged_when_relay_is_live() { + for marker in [ + "state.borrow_mut().select_webcam_transport(selected);", + "child_proc.borrow().is_some()", + "Webcam transport changed to {} for the next reconnect; keeping the live decoder path stable.", + "Webcam transport set to {} for the next relay launch.", + ] { + assert!( + STAGE_DEVICE_BINDINGS_SRC.contains(marker), + "live transport selection should preserve marker {marker}" + ); + } + + for marker in [ + ".webcam_transport_combo\n .set_sensitive(!relay_live && state.channels.camera);", + "Reconnect before changing the upstream webcam transport; the server decoder is calibrated per ingress codec.", + "Choose HEVC for low-latency upstream video or MJPEG as the calibrated fallback for the next relay launch.", + ] { + assert!( + STATUS_REFRESH_SRC.contains(marker), + "transport sensitivity/tooltip should preserve marker {marker}" + ); + } +} + +#[test] +fn uvc_chip_reports_selected_transport_not_stale_server_codec() { + for marker in [ + "let codec = state.webcam_transport.label().to_string();", + "if !relay_live {\n return (StatusLightState::Live, codec);", + "if matches!(health, StatusLightState::Live) {\n (health, codec)", + "state.select_webcam_transport(WebcamTransport::Mjpeg);", + "(StatusLightState::Live, \"MJPEG\".to_string())", + ] { + assert!( + STATUS_DETAILS_SRC.contains(marker) + || include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/tests/ui_runtime.rs" + )) + .contains(marker), + "UVC chip should preserve selected transport marker {marker}" + ); + } +} diff --git a/testing/tests/client_launcher_layout_contract.rs b/tests/ui/client/launcher/client_launcher_layout_contract.rs similarity index 86% rename from testing/tests/client_launcher_layout_contract.rs rename to tests/ui/client/launcher/client_launcher_layout_contract.rs index 0d69156..39962a5 100644 --- a/testing/tests/client_launcher_layout_contract.rs +++ b/tests/ui/client/launcher/client_launcher_layout_contract.rs @@ -1,24 +1,60 @@ -//! Contract tests for the launcher layout proportions. -//! -//! Scope: statically guard the GTK layout constants and sizing glue used by -//! the launcher shell. -//! Targets: split `client/src/launcher/ui_components/*` layout modules. -//! Why: the launcher is an operational control surface; accidental spacing -//! regressions can hide diagnostics or make eye/device previews unusable. +// Contract tests for the launcher layout proportions. +// +// Scope: statically guard the GTK layout constants and sizing glue used by +// the launcher shell. +// Targets: split `client/src/launcher/ui_components/*` layout modules. +// Why: the launcher is an operational control surface; accidental spacing +// regressions can hide diagnostics or make eye/device previews unusable. const UI_LAYOUT_SRC: &str = concat!( - include_str!("../../client/src/launcher/ui.rs"), - include_str!("../../client/src/launcher/ui/startup_window_guard.rs"), - include_str!("../../client/src/launcher/ui_components/types.rs"), - include_str!("../../client/src/launcher/ui_components/build_shell.rs"), - include_str!("../../client/src/launcher/ui/preview_profiles.rs"), - include_str!("../../client/src/launcher/ui_components/display_pane.rs"), - include_str!("../../client/src/launcher/ui_components/build_device_controls.rs"), - include_str!("../../client/src/launcher/ui_components/build_operations_rail.rs"), - include_str!("../../client/src/launcher/ui_components/combo_helpers.rs"), - include_str!("../../client/src/launcher/ui_components/control_buttons.rs"), - include_str!("../../client/src/launcher/ui_components/panel_chips.rs"), - include_str!("../../client/src/launcher/ui_components/style.rs"), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/startup_window_guard.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui_components/types.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui_components/build_shell.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/preview_profiles.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui_components/display_pane.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui_components/build_device_controls.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui_components/build_operations_rail.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui_components/combo_helpers.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui_components/control_buttons.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui_components/panel_chips.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui_components/style.rs" + )), ); fn const_i32(name: &str) -> i32 { @@ -118,7 +154,7 @@ fn eye_and_device_combos_use_compact_labels_so_the_rail_stays_visible() { assert!(UI_LAYOUT_SRC.contains("format!(\"{size}@{fps}\")")); assert!(UI_LAYOUT_SRC.contains("format!(\"Source {}\", compact_size_label(option.height))")); assert!(UI_LAYOUT_SRC.contains("fn shorten_input_label(")); - assert!(UI_LAYOUT_SRC.contains("shorten_label_with_limit(value, 22)")); + assert!(UI_LAYOUT_SRC.contains("shorten_label_with_limit(value, 34)")); assert!( !UI_LAYOUT_SRC.contains("@ {} fps (Device H.264)"), "long capture labels force a huge GTK combo natural width" @@ -142,7 +178,7 @@ fn device_staging_and_testing_stay_independent_so_preview_does_not_fill_dead_hei assert!(UI_LAYOUT_SRC.contains("devices_panel.set_valign(gtk::Align::Fill);")); assert!(UI_LAYOUT_SRC.contains("preview_panel.set_valign(gtk::Align::Fill);")); assert!(UI_LAYOUT_SRC.contains("preview_panel.set_hexpand(true);")); - assert!(UI_LAYOUT_SRC.contains("build_panel(\"Upstream Media\")")); + assert!(UI_LAYOUT_SRC.contains("build_panel_with_action(\"Upstream Media\"")); assert!( !UI_LAYOUT_SRC.contains("gtk::SizeGroup::new(gtk::SizeGroupMode::Vertical)"), "the webcam testing column must not inherit the full height of the staging controls" @@ -293,8 +329,12 @@ fn relay_controls_keep_connect_inline_with_server_entry() { assert!(UI_LAYOUT_SRC.contains("relay_grid.attach(&certs_button, 1, 0, 1, 1);")); assert!(UI_LAYOUT_SRC.contains("relay_grid.attach(&start_button, 2, 0, 1, 1);")); assert!(UI_LAYOUT_SRC.contains("stabilize_button(&certs_button, 66);")); - assert!(UI_LAYOUT_SRC.contains("stabilize_button(&start_button, 76);")); - assert!(UI_LAYOUT_SRC.contains("let recovery_heading = gtk::Label::new(Some(\"Recover\"));")); + assert!(UI_LAYOUT_SRC.contains("start_button.set_hexpand(false);")); + assert!(!UI_LAYOUT_SRC.contains("stabilize_button(&start_button, 76);")); + assert!( + UI_LAYOUT_SRC + .contains("let recovery_heading = gtk::Label::new(Some(\"Recover\\nUpstream\"));") + ); assert!( UI_LAYOUT_SRC .contains("let recovery_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);") @@ -334,16 +374,16 @@ fn relay_controls_keep_connect_inline_with_server_entry() { assert!(UI_LAYOUT_SRC.contains("tools_heading.set_width_chars(RELAY_SUBGROUP_LABEL_WIDTH);")); assert!(UI_LAYOUT_SRC.contains("let clipboard_button = rail_button(\"Clipboard\"")); assert!(UI_LAYOUT_SRC.contains("let usb_recover_button = rail_button(")); - assert!(UI_LAYOUT_SRC.contains("\"USB\",")); + assert!(UI_LAYOUT_SRC.contains("\"HID\",")); assert!(UI_LAYOUT_SRC.contains("let uac_recover_button = rail_button(")); - assert!(UI_LAYOUT_SRC.contains("\"UAC\",")); + assert!(UI_LAYOUT_SRC.contains("\"Audio\",")); assert!(UI_LAYOUT_SRC.contains("let uvc_recover_button = rail_button(")); - assert!(UI_LAYOUT_SRC.contains("\"UVC\",")); + assert!(UI_LAYOUT_SRC.contains("\"Video\",")); assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&usb_recover_button);")); assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&uac_recover_button);")); assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&uvc_recover_button);")); assert!( - source_index("let recovery_heading = gtk::Label::new(Some(\"Recover\"));") + source_index("let recovery_heading = gtk::Label::new(Some(\"Recover\\nUpstream\"));") < source_index( "let calibration_heading = gtk::Label::new(Some(\"Audio/Video\\nUpstream\\nCalibration\"));" ) @@ -375,6 +415,12 @@ fn media_controls_own_stream_toggles_and_inline_gain_controls() { assert!(!UI_LAYOUT_SRC.contains("let channel_heading = gtk::Label::new(Some(\"Streams\"));")); assert!(UI_LAYOUT_SRC.contains("gtk::ToggleButton::with_label(\"Camera\")")); assert!(UI_LAYOUT_SRC.contains("gtk::ToggleButton::with_label(\"Mic\")")); + assert!(UI_LAYOUT_SRC.contains("gtk::ToggleButton::with_label(\"๐Ÿงน\")")); + assert!( + UI_LAYOUT_SRC.contains("Noise cancellation is on for the upstream microphone.") + && UI_LAYOUT_SRC + .contains("Noise cancellation is off; upstream microphone is raw aside from gain.") + ); assert!(UI_LAYOUT_SRC.contains("gtk::ToggleButton::with_label(\"Speaker\")")); assert!(UI_LAYOUT_SRC.contains("camera_channel_toggle.add_css_class(\"media-toggle\");")); assert!(UI_LAYOUT_SRC.contains("audio_channel_toggle.add_css_class(\"media-toggle\");")); @@ -406,6 +452,21 @@ fn media_controls_own_stream_toggles_and_inline_gain_controls() { ); assert!(UI_LAYOUT_SRC.contains("microphone_selectors.append(µphone_combo);")); assert!(UI_LAYOUT_SRC.contains("microphone_selectors.append(&mic_gain_scale);")); + assert!( + UI_LAYOUT_SRC.contains("let upstream_audio_transport_combo = gtk::ComboBoxText::new();") + ); + assert!(UI_LAYOUT_SRC.contains( + "upstream_audio_transport_combo.append(Some(transport.as_id()), transport.label());" + )); + assert!( + UI_LAYOUT_SRC + .contains("Opus is compressed and low-bandwidth; PCM is the known-good fallback.") + ); + assert!(UI_LAYOUT_SRC.contains("upstream_transport_row.append(&webcam_transport_combo);")); + assert!( + UI_LAYOUT_SRC.contains("upstream_transport_row.append(&upstream_audio_transport_combo);") + ); + assert!(UI_LAYOUT_SRC.contains("let playback_group = build_subgroup(\"Mic Playback\");")); assert_eq!( UI_LAYOUT_SRC .matches("attach_device_control_row(\n &media_grid") diff --git a/testing/tests/client_launcher_runtime_contract.rs b/tests/ui/client/launcher/client_launcher_runtime_contract.rs similarity index 66% rename from testing/tests/client_launcher_runtime_contract.rs rename to tests/ui/client/launcher/client_launcher_runtime_contract.rs index 6e61b50..52c9072 100644 --- a/testing/tests/client_launcher_runtime_contract.rs +++ b/tests/ui/client/launcher/client_launcher_runtime_contract.rs @@ -1,54 +1,154 @@ -//! Contract tests for launcher-owned relay process lifetime. -//! -//! Scope: static guardrails around launcher runtime process management. -//! Targets: split launcher UI/runtime modules under `client/src/launcher/`. -//! 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. +// Contract tests for launcher-owned relay process lifetime. +// +// Scope: static guardrails around launcher runtime process management. +// Targets: split launcher UI/runtime modules under `client/src/launcher/`. +// 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 = concat!( - include_str!("../../client/src/launcher/ui_runtime.rs"), - include_str!("../../client/src/launcher/ui_runtime/control_paths.rs"), - include_str!("../../client/src/launcher/ui_runtime/process_logs.rs"), - include_str!("../../client/src/launcher/ui_runtime/status_refresh.rs"), - include_str!("../../client/src/launcher/ui_runtime/status_details.rs"), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui_runtime.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui_runtime/control_paths.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui_runtime/process_logs.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui_runtime/status_refresh.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui_runtime/status_details.rs" + )), ); const UI_SRC: &str = concat!( - include_str!("../../client/src/launcher/ui.rs"), - include_str!("../../client/src/launcher/ui/message_and_network_state.rs"), - include_str!("../../client/src/launcher/ui/control_requests.rs"), - include_str!("../../client/src/launcher/ui/activation_setup.rs"), - include_str!("../../client/src/launcher/ui/device_refresh_binding.rs"), - include_str!("../../client/src/launcher/ui/local_test_bindings.rs"), - include_str!("../../client/src/launcher/ui/media_device_bindings.rs"), - include_str!("../../client/src/launcher/ui/relay_input_bindings.rs"), - include_str!("../../client/src/launcher/ui/runtime_poll.rs"), - include_str!("../../client/src/launcher/ui/stage_device_bindings.rs"), - include_str!("../../client/src/launcher/ui/eye_capture_bindings.rs"), - include_str!("../../client/src/launcher/ui/utility_button_bindings.rs"), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/message_and_network_state.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/control_requests.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/activation_setup.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/device_refresh_binding.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/local_test_bindings.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/media_device_bindings.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/relay_input_bindings.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/runtime_poll.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/runtime_poll/runtime_monitor_tick.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/stage_device_bindings.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/eye_capture_bindings.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui/utility_button_bindings.rs" + )), ); const DEVICE_TEST_SRC: &str = concat!( - include_str!("../../client/src/launcher/device_test.rs"), - include_str!("../../client/src/launcher/device_test/controller.rs"), - include_str!("../../client/src/launcher/device_test/local_preview.rs"), - include_str!("../../client/src/launcher/device_test/pipeline_helpers.rs"), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/device_test.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/device_test/controller.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/device_test/local_preview.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/device_test/pipeline_helpers.rs" + )), ); const CAMERA_SRC: &str = concat!( - include_str!("../../client/src/input/camera.rs"), - include_str!("../../client/src/input/camera/capture_pipeline.rs"), - include_str!("../../client/src/input/camera/encoder_selection.rs"), - include_str!("../../client/src/input/camera/preview_tap.rs"), - include_str!("../../client/src/input/camera/source_description.rs"), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/input/camera.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/input/camera/capture_pipeline.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/input/camera/encoder_selection.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/input/camera/preview_tap.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/input/camera/source_description.rs" + )), ); -const MICROPHONE_SRC: &str = include_str!("../../client/src/input/microphone.rs"); -const LAUNCHER_MOD_SRC: &str = include_str!("../../client/src/launcher/mod.rs"); -const MAIN_SRC: &str = include_str!("../../client/src/main.rs"); +const MICROPHONE_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/input/microphone.rs" +)); +const LAUNCHER_MOD_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/mod.rs" +)); +const MAIN_SRC: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/client/src/main.rs")); const UI_COMPONENTS_SRC: &str = concat!( - include_str!("../../client/src/launcher/ui_components.rs"), - include_str!("../../client/src/launcher/ui_components/build_shell.rs"), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui_components.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/ui_components/build_shell.rs" + )), ); const PREVIEW_SRC: &str = concat!( - include_str!("../../client/src/launcher/preview.rs"), - include_str!("../../client/src/launcher/preview/status_pipeline.rs"), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/preview.rs" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/launcher/preview/status_pipeline.rs" + )), ); #[test] @@ -176,6 +276,27 @@ fn launcher_webcam_quality_selection_reaches_preview_and_relay_env() { assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_CAM_H264_KBIT\"")); } +#[test] +fn launcher_audio_transport_selection_reaches_relay_env_and_chips() { + assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_UPLINK_AUDIO_CODEC\"")); + assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_MIC_NOISE_SUPPRESSION\"")); + assert!(UI_RUNTIME_SRC.contains("state.upstream_audio_transport.as_common_codec()")); + assert!(UI_RUNTIME_SRC.contains("state.mic_noise_suppression")); + assert!(UI_RUNTIME_SRC.contains("state.upstream_audio_transport.label()")); + assert!(UI_RUNTIME_SRC.contains("Upstream microphone transport:")); + assert!(UI_RUNTIME_SRC.contains("Noise suppression is enabled before transport.")); + assert!( + UI_RUNTIME_SRC.contains("Changing upstream audio transport restarts the microphone path") + ); + assert!( + UI_RUNTIME_SRC.contains( + "Choose Opus for compressed upstream audio or PCM as the known-good fallback." + ) + ); + assert!(UI_SRC.contains("audio_combo.connect_changed")); + assert!(UI_SRC.contains("toggle.connect_toggled")); +} + #[test] fn launcher_utility_buttons_still_bind_to_live_actions() { assert!(UI_SRC.contains("widgets.clipboard_button.connect_clicked")); @@ -198,7 +319,9 @@ fn launcher_utility_buttons_still_bind_to_live_actions() { assert!(UI_SRC.contains("recover_usb_soft(&server_addr)")); assert!(UI_SRC.contains("recover_uac_soft(&server_addr)")); assert!(UI_SRC.contains("recover_uvc_soft(&server_addr)")); - assert!(UI_SRC.contains("Recover USB 2/3: HID reopened.")); + assert!(UI_SRC.contains("Recover HID 2/3: handles reopened.")); + assert!(UI_SRC.contains("Recover Audio 2/3: old epoch released.")); + assert!(UI_SRC.contains("Recover Video 2/3: webcam sink retired.")); } #[test] diff --git a/testing/tests/client_layout_contract.rs b/tests/ui/client/launcher/client_layout_contract.rs similarity index 94% rename from testing/tests/client_layout_contract.rs rename to tests/ui/client/launcher/client_layout_contract.rs index eb968bd..19bc5a5 100644 --- a/testing/tests/client_layout_contract.rs +++ b/tests/ui/client/launcher/client_layout_contract.rs @@ -1,9 +1,9 @@ -//! Integration coverage for the client window layout contract. -//! -//! Scope: exercise `layout::apply` end-to-end against a fake `swaymsg` binary. -//! Targets: `client/src/layout.rs`. -//! Why: this keeps layout policy coverage centralized in `testing/tests` while -//! avoiding source-LOC ratchet growth in the client crate. +// Integration coverage for the client window layout contract. +// +// Scope: exercise `layout::apply` end-to-end against a fake `swaymsg` binary. +// Targets: `client/src/layout.rs`. +// Why: this keeps layout policy coverage centralized in `tests` while +// avoiding source-LOC ratchet growth in the client crate. use lesavka_client::layout::{Layout, apply}; use serial_test::serial; diff --git a/tests/unit/client/app/client_audio_recovery_config_unit.rs b/tests/unit/client/app/client_audio_recovery_config_unit.rs new file mode 100644 index 0000000..237e2d0 --- /dev/null +++ b/tests/unit/client/app/client_audio_recovery_config_unit.rs @@ -0,0 +1,115 @@ +#![allow(dead_code)] + +// Unit coverage for client audio recovery knobs. +// +// Scope: include the client recovery parser helpers without starting GTK, +// GStreamer, or any relay process. +// Targets: `client/src/app/audio_recovery_config.rs` and +// `client/src/app/uplink_media/webcam_media_loop.rs`. +// Why: the real-world Google Meet fix depends on one safe startup UAC epoch +// retirement and bounded recovery retries, not on manual operator folklore. + +use std::time::{Duration, Instant}; + +use lesavka_common::lesavka::{KeyboardReport, MouseReport}; +use serial_test::serial; +use temp_env::{with_var, with_var_unset, with_vars}; +use tokio_stream::wrappers::errors::BroadcastStreamRecvError; +use tracing::warn; + +include!("../../../../client/src/app/audio_recovery_config.rs"); + +const WEBCAM_MEDIA_LOOP_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/client/src/app/uplink_media/webcam_media_loop.rs" +)); + +#[test] +#[serial] +fn remote_audio_usb_recovery_is_opt_in_and_cooldown_bounded() { + with_var_unset("LESAVKA_AUDIO_AUTO_RECOVER_USB", || { + assert!( + !audio_usb_auto_recover_enabled(), + "USB-level audio recovery must stay opt-in because hard gadget recovery can disturb a live call" + ); + }); + + with_vars( + [ + ("LESAVKA_AUDIO_AUTO_RECOVER_USB", Some("1")), + ("LESAVKA_AUDIO_AUTO_RECOVER_AFTER", Some("0")), + ("LESAVKA_AUDIO_AUTO_RECOVER_COOLDOWN_MS", Some("2500")), + ], + || { + assert!(audio_usb_auto_recover_enabled()); + assert_eq!( + audio_usb_recover_after(), + 3, + "invalid thresholds should fall back to the safe default" + ); + assert_eq!(audio_usb_recover_cooldown(), Duration::from_millis(2500)); + }, + ); + + with_var( + "LESAVKA_AUDIO_AUTO_RECOVER_COOLDOWN_MS", + Some("not-a-number"), + || { + assert_eq!( + audio_usb_recover_cooldown(), + Duration::from_secs(60), + "malformed cooldowns should not create a tight recovery loop" + ); + }, + ); +} + +#[test] +fn remote_audio_error_classifier_targets_source_epoch_failures_only() { + for message in [ + "remote speaker capture produced no audio samples after 3000 ms on hw:UAC2Gadget,0", + "remote speaker capture stalled for 3000 ms on hw:UAC2Gadget,0", + "remote speaker capture cadence is too low on hw:UAC2Gadget,0", + ] { + assert!( + is_recoverable_remote_audio_error(message), + "expected recoverable remote audio source error: {message}" + ); + } + + for message in [ + "TLS handshake failed", + "recover_uac returned permission denied", + "video decoder stalled", + "microphone capture device missing locally", + ] { + assert!( + !is_recoverable_remote_audio_error(message), + "unrelated failures must not trigger USB/audio recovery: {message}" + ); + } +} + +#[test] +fn startup_epoch_auto_heal_is_one_shot_uac_recovery_after_three_seconds() { + for marker in [ + "const DEFAULT_UPSTREAM_AUTO_HEAL_AFTER_MS: u64 = 3_000;", + "let mut startup_epoch_heal_delay = upstream_epoch_auto_heal_delay();", + "startup_epoch_heal_delay.take()", + "spawn_upstream_epoch_auto_heal(ep.clone(), heal_delay);", + "client.recover_uac(Request::new(Empty {})).await", + "automatic upstream A/V epoch heal requested", + ] { + assert!( + WEBCAM_MEDIA_LOOP_SRC.contains(marker), + "startup epoch auto-heal should preserve marker {marker}" + ); + } + + for forbidden in ["reset_usb(Request::new(Empty {})).await", "recover_uvc("] { + assert!( + !WEBCAM_MEDIA_LOOP_SRC.contains(forbidden), + "startup epoch auto-heal must not affect USB or video recovery path: {forbidden}" + ); + } +} diff --git a/tests/unit/client/uplink/client_upstream_bundle_queue_unit.rs b/tests/unit/client/uplink/client_upstream_bundle_queue_unit.rs new file mode 100644 index 0000000..100e928 --- /dev/null +++ b/tests/unit/client/uplink/client_upstream_bundle_queue_unit.rs @@ -0,0 +1,137 @@ +// Unit coverage for client upstream bundled media queue behavior. +// +// Scope: exercise the freshness queue with complete `UpstreamMediaBundle` +// values rather than split audio/video packets. +// Targets: `client/src/uplink_fresh_queue.rs`. +// Why: the upstream sync win depends on dropping stale media as complete A/V +// bundles so the server never receives an orphaned tone or flash. + +#[path = "../../../../client/src/uplink_fresh_queue.rs"] +#[allow(warnings)] +mod uplink_fresh_queue; + +use lesavka_common::lesavka::{AudioEncoding, AudioPacket, UpstreamMediaBundle, VideoPacket}; +use std::time::Duration; +use uplink_fresh_queue::{FreshPacketQueue, FreshQueueConfig, FreshQueuePolicy}; + +fn bundle(seq: u64, capture_pts_us: u64) -> UpstreamMediaBundle { + UpstreamMediaBundle { + session_id: 7, + seq, + capture_start_us: capture_pts_us.saturating_sub(20_000), + capture_end_us: capture_pts_us.saturating_add(20_000), + video: Some(VideoPacket { + seq, + pts: capture_pts_us, + data: vec![0, 0, 0, 1, 0x26, 0x01, seq as u8], + client_capture_pts_us: capture_pts_us, + client_send_pts_us: capture_pts_us.saturating_add(5_000), + client_queue_age_ms: 5, + ..Default::default() + }), + audio: vec![ + AudioPacket { + seq: seq.saturating_mul(2), + pts: capture_pts_us.saturating_sub(20_000), + data: vec![0x11; 1_920], + client_capture_pts_us: capture_pts_us.saturating_sub(20_000), + client_send_pts_us: capture_pts_us.saturating_add(5_000), + client_queue_age_ms: 25, + ..Default::default() + }, + AudioPacket { + seq: seq.saturating_mul(2).saturating_add(1), + pts: capture_pts_us.saturating_add(20_000), + data: vec![0x22; 1_920], + client_capture_pts_us: capture_pts_us.saturating_add(20_000), + client_send_pts_us: capture_pts_us.saturating_add(25_000), + client_queue_age_ms: 5, + ..Default::default() + }, + ], + audio_sample_rate: 48_000, + audio_channels: 2, + audio_encoding: AudioEncoding::PcmS16le as i32, + video_width: 1280, + video_height: 720, + video_fps: 30, + } +} + +fn assert_complete_bundle(bundle: &UpstreamMediaBundle, expected_seq: u64) { + assert_eq!(bundle.seq, expected_seq); + assert!( + bundle.video.is_some(), + "freshness queue must not split video out of its bundle" + ); + assert_eq!( + bundle.audio.len(), + 2, + "freshness queue must not split audio out of its bundle" + ); + let video = bundle.video.as_ref().expect("video packet"); + assert!( + bundle.capture_start_us <= video.client_capture_pts_us + && bundle.capture_end_us >= video.client_capture_pts_us + ); + assert!(bundle.audio.iter().all(|audio| { + bundle.capture_start_us <= audio.client_capture_pts_us + && bundle.capture_end_us >= audio.client_capture_pts_us + })); +} + +#[tokio::test] +async fn latest_only_policy_drops_superseded_bundles_as_complete_av_units() { + let queue = FreshPacketQueue::new(FreshQueueConfig { + capacity: 8, + max_age: Duration::from_secs(1), + policy: FreshQueuePolicy::LatestOnly, + }); + + for seq in 1..=3 { + let _ = queue.push( + bundle(seq, 1_000_000 + seq * 33_333), + Duration::from_millis(10), + ); + } + + let popped = queue.pop_fresh().await; + assert_eq!(popped.dropped_stale, 2); + assert_eq!(popped.queue_depth, 0); + assert_complete_bundle(&popped.packet.expect("newest bundle"), 3); +} + +#[tokio::test] +async fn stale_bundle_drop_never_delivers_audio_or_video_halves() { + let queue = FreshPacketQueue::new(FreshQueueConfig { + capacity: 4, + max_age: Duration::from_millis(200), + policy: FreshQueuePolicy::DrainOldest, + }); + + let _ = queue.push(bundle(1, 1_000_000), Duration::from_millis(250)); + let _ = queue.push(bundle(2, 1_033_333), Duration::from_millis(25)); + + let popped = queue.pop_fresh().await; + assert_eq!(popped.dropped_stale, 1); + assert_complete_bundle(&popped.packet.expect("fresh bundle"), 2); +} + +#[tokio::test] +async fn capacity_pressure_drops_oldest_bundles_instead_of_backlogging_media() { + let queue = FreshPacketQueue::new(FreshQueueConfig { + capacity: 2, + max_age: Duration::from_secs(1), + policy: FreshQueuePolicy::DrainOldest, + }); + + let first = queue.push(bundle(1, 1_000_000), Duration::from_millis(5)); + let second = queue.push(bundle(2, 1_033_333), Duration::from_millis(5)); + let third = queue.push(bundle(3, 1_066_666), Duration::from_millis(5)); + + assert_eq!(first.dropped_queue_full, 0); + assert_eq!(second.queue_depth, 2); + assert_eq!(third.dropped_queue_full, 1); + assert_complete_bundle(&queue.pop_fresh().await.packet.expect("second bundle"), 2); + assert_complete_bundle(&queue.pop_fresh().await.packet.expect("third bundle"), 3); +} diff --git a/tests/unit/client/uplink/client_upstream_keyframe_state_unit.rs b/tests/unit/client/uplink/client_upstream_keyframe_state_unit.rs new file mode 100644 index 0000000..c5c3492 --- /dev/null +++ b/tests/unit/client/uplink/client_upstream_keyframe_state_unit.rs @@ -0,0 +1,91 @@ +// Unit coverage for HEVC recovery state in the client upstream path. +// +// Scope: include the client HEVC keyframe helpers with a tiny camera-config +// shim so the logic can be tested without GTK/GStreamer capture hardware. +// Targets: `client/src/app/uplink_media/video_keyframes.rs`. +// Why: freshness-first dropping is only safe for predictive codecs when the +// next emitted video frame is a clean recovery point. + +mod input { + pub mod camera { + #[derive(Clone, Copy)] + pub enum CameraCodec { + Mjpeg, + Hevc, + } + + #[derive(Clone, Copy)] + pub struct CameraConfig { + pub codec: CameraCodec, + } + } +} + +use lesavka_common::lesavka::{UpstreamMediaBundle, VideoPacket}; +use temp_env::with_vars; + +include!("../../../../client/src/app/uplink_media/video_keyframes.rs"); + +fn video_bundle(data: Vec) -> UpstreamMediaBundle { + UpstreamMediaBundle { + video: Some(VideoPacket { + data, + ..Default::default() + }), + ..Default::default() + } +} + +#[test] +fn annex_b_irap_detection_handles_three_and_four_byte_start_codes() { + assert!(contains_hevc_irap(&[0, 0, 0, 1, 0x26, 0x01, 0xaa])); + assert!(contains_hevc_irap(&[0, 0, 1, 0x28, 0x01, 0xbb])); + assert!(contains_hevc_irap(&[ + 0, 0, 1, 0x02, 0x01, 0xcc, 0, 0, 0, 1, 0x2e, 0x01, 0xdd + ])); + assert!(!contains_hevc_irap(&[0, 0, 0, 1, 0x02, 0x01, 0xee])); + assert!(!contains_hevc_irap(&[0x26, 0x01, 0xaa])); +} + +#[test] +fn recovery_wait_holds_delta_bundles_and_releases_on_irap() { + let delta = video_bundle(vec![0, 0, 0, 1, 0x02, 0x01, 0xaa]); + let irap = video_bundle(vec![0, 0, 0, 1, 0x26, 0x01, 0xbb]); + + assert!(!should_hold_hevc_bundle_for_keyframe_recovery( + false, &delta + )); + assert!(should_hold_hevc_bundle_for_keyframe_recovery(true, &delta)); + assert!(!should_hold_hevc_bundle_for_keyframe_recovery(true, &irap)); + assert!(bundle_has_hevc_recovery_keyframe(&irap)); + assert!(!bundle_has_hevc_recovery_keyframe(&delta)); +} + +#[test] +fn capture_gap_only_enters_wait_state_when_hevc_recovery_applies() { + let mut waiting = false; + note_hevc_capture_gap(false, &mut waiting); + assert!(!waiting); + + note_hevc_capture_gap(true, &mut waiting); + assert!(waiting); +} + +#[test] +fn codec_selection_enables_recovery_for_hevc_only() { + use input::camera::{CameraCodec, CameraConfig}; + + assert!(upstream_camera_uses_hevc(Some(CameraConfig { + codec: CameraCodec::Hevc, + }))); + assert!(!upstream_camera_uses_hevc(Some(CameraConfig { + codec: CameraCodec::Mjpeg, + }))); + + with_vars([("LESAVKA_CAM_CODEC", Some("h.265"))], || { + assert!(upstream_camera_uses_hevc(None)); + }); + with_vars([("LESAVKA_CAM_CODEC", Some("mjpeg"))], || { + assert!(!upstream_camera_uses_hevc(None)); + }); +} diff --git a/tests/unit/common/audio/common_audio_transport_unit.rs b/tests/unit/common/audio/common_audio_transport_unit.rs new file mode 100644 index 0000000..523356f --- /dev/null +++ b/tests/unit/common/audio/common_audio_transport_unit.rs @@ -0,0 +1,119 @@ +// Unit tests for shared audio transport metadata. +// +// Scope: exercise `common/src/audio_transport.rs` without GStreamer or network +// dependencies. +// Targets: `common/src/audio_transport.rs`. +// Why: Opus should be evaluated as a transport codec without weakening the +// known-good raw PCM microphone path. + +use lesavka_common::audio_transport::{ + AudioTransportProfile, UpstreamAudioCodec, bundle_audio_profile, mark_bundle_audio_profile, + mark_packet_opus, mark_packet_pcm_s16le, normalize_audio_encoding, packet_audio_profile, + packet_is_raw_pcm_s16le, parse_upstream_audio_codec, +}; +use lesavka_common::lesavka::{AudioEncoding, AudioPacket, UpstreamMediaBundle}; + +#[test] +fn opus_profile_is_low_bandwidth_without_changing_capture_clock() { + let pcm = AudioTransportProfile::pcm_s16le(); + let opus = AudioTransportProfile::opus_voice(); + + assert_eq!(pcm.sample_rate, 48_000); + assert_eq!(opus.sample_rate, 48_000); + assert_eq!(pcm.channels, 2); + assert_eq!(opus.channels, 2); + assert_eq!(pcm.frame_duration_us, 20_000); + assert_eq!(opus.frame_duration_us, 20_000); + assert_eq!(pcm.expected_payload_bytes(), 3_840); + assert_eq!(opus.expected_payload_bytes(), 160); +} + +#[test] +fn unstamped_legacy_audio_remains_pcm_for_backward_compatibility() { + let packet = AudioPacket::default(); + + assert_eq!( + normalize_audio_encoding(packet.encoding), + AudioEncoding::PcmS16le + ); + assert_eq!( + packet_audio_profile(&packet), + AudioTransportProfile::pcm_s16le() + ); + assert!(packet_is_raw_pcm_s16le(&packet)); +} + +#[test] +fn packet_and_bundle_metadata_can_select_opus_without_payload_guessing() { + let packet = AudioPacket { + encoding: AudioEncoding::Opus as i32, + sample_rate: 48_000, + channels: 2, + frame_duration_us: 20_000, + data: vec![0xaa; 160], + ..AudioPacket::default() + }; + let bundle = UpstreamMediaBundle { + audio_encoding: AudioEncoding::Opus as i32, + audio_sample_rate: 48_000, + audio_channels: 2, + audio: vec![packet.clone()], + ..UpstreamMediaBundle::default() + }; + + assert_eq!( + packet_audio_profile(&packet), + AudioTransportProfile::opus_voice() + ); + assert_eq!( + bundle_audio_profile(&bundle), + AudioTransportProfile::opus_voice() + ); + assert!(!packet_is_raw_pcm_s16le(&packet)); +} + +#[test] +fn marking_helpers_keep_current_pcm_path_explicit() { + let mut packet = AudioPacket { + frame_duration_us: 10_000, + ..AudioPacket::default() + }; + mark_packet_pcm_s16le(&mut packet); + assert_eq!(packet.encoding, AudioEncoding::PcmS16le as i32); + assert_eq!(packet.sample_rate, 48_000); + assert_eq!(packet.channels, 2); + assert_eq!( + packet.frame_duration_us, 10_000, + "caller-supplied packet duration should be preserved" + ); + + let mut bundle = UpstreamMediaBundle::default(); + mark_bundle_audio_profile(&mut bundle, AudioTransportProfile::pcm_s16le()); + assert_eq!(bundle.audio_encoding, AudioEncoding::PcmS16le as i32); + assert_eq!(bundle.audio_sample_rate, 48_000); + assert_eq!(bundle.audio_channels, 2); +} + +#[test] +fn upstream_audio_codec_parser_keeps_opus_and_pcm_names_stable() { + assert_eq!( + parse_upstream_audio_codec("opus"), + Some(UpstreamAudioCodec::Opus) + ); + assert_eq!( + parse_upstream_audio_codec("raw"), + Some(UpstreamAudioCodec::PcmS16le) + ); + assert_eq!(parse_upstream_audio_codec("aac"), None); + + let mut packet = AudioPacket { + data: vec![0xaa; 160], + ..AudioPacket::default() + }; + mark_packet_opus(&mut packet); + assert_eq!(packet.encoding, AudioEncoding::Opus as i32); + assert_eq!( + packet_audio_profile(&packet), + AudioTransportProfile::opus_voice() + ); +} diff --git a/tests/unit/common/hid/common_hid_edge_cases_unit.rs b/tests/unit/common/hid/common_hid_edge_cases_unit.rs new file mode 100644 index 0000000..877605e --- /dev/null +++ b/tests/unit/common/hid/common_hid_edge_cases_unit.rs @@ -0,0 +1,66 @@ +// Extra pure HID encoder edge coverage. +// +// Scope: verify balanced modifier/key release sequences without touching +// evdev, uinput, or gadget device nodes. +// Targets: `common/src/hid.rs`. +// Why: a leaked shift/modifier or missing release is exactly how a safe +// synthetic input path turns into foreground typing like `aAaasa`. + +use lesavka_common::hid::{KeyboardHidReport, append_char_reports, char_to_usage}; + +fn reports_for(text: &str) -> Vec { + let mut reports = Vec::new(); + for ch in text.chars() { + assert!(append_char_reports(&mut reports, ch), "unsupported {ch:?}"); + } + reports +} + +#[test] +fn shifted_character_releases_modifier_before_following_unshifted_character() { + let reports = reports_for("Aa"); + + assert_eq!(reports.len(), 6); + assert_eq!(reports[0], [0x02, 0, 0, 0, 0, 0, 0, 0]); + assert_eq!(reports[1], [0x02, 0, 0x04, 0, 0, 0, 0, 0]); + assert_eq!(reports[2], [0x02, 0, 0, 0, 0, 0, 0, 0]); + assert_eq!(reports[3], [0; 8]); + assert_eq!(reports[4], [0, 0, 0x04, 0, 0, 0, 0, 0]); + assert_eq!(reports[5], [0; 8]); +} + +#[test] +fn whitespace_characters_emit_press_release_pairs_without_sticky_keys() { + let reports = reports_for("\n\r\t "); + let expected_usages = [0x28, 0x28, 0x2B, 0x2C]; + + assert_eq!(reports.len(), expected_usages.len() * 2); + for (chunk, usage) in reports.chunks_exact(2).zip(expected_usages) { + assert_eq!(chunk[0], [0, 0, usage, 0, 0, 0, 0, 0]); + assert_eq!(chunk[1], [0; 8]); + } +} + +#[test] +fn shifted_symbols_are_balanced_modifier_key_release_sequences() { + for ch in [ + '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', '{', '}', '|', ':', '"', '~', + '<', '>', '?', + ] { + let mut reports = Vec::new(); + let (usage, modifiers) = char_to_usage(ch).expect("supported shifted symbol"); + + assert_eq!(modifiers, 0x02, "{ch:?} should require shift"); + assert!(append_char_reports(&mut reports, ch)); + + assert_eq!( + reports, + vec![ + [0x02, 0, 0, 0, 0, 0, 0, 0], + [0x02, 0, usage, 0, 0, 0, 0, 0], + [0x02, 0, 0, 0, 0, 0, 0, 0], + [0; 8], + ] + ); + } +} diff --git a/tests/unit/common/hid/common_hid_unit.rs b/tests/unit/common/hid/common_hid_unit.rs new file mode 100644 index 0000000..4b763df --- /dev/null +++ b/tests/unit/common/hid/common_hid_unit.rs @@ -0,0 +1,31 @@ +// Unit coverage for the shared HID report encoder. +// +// Scope: exercise the smallest pure HID helpers without touching devices. +// Targets: `common/src/hid.rs`. +// Why: paste injection and local key simulation both depend on exact USB HID +// report shapes, so the primitive encoder should fail loudly on drift. + +use lesavka_common::hid::{append_char_reports, char_to_usage}; + +#[test] +fn shifted_printable_character_expands_to_modifier_key_and_release_sequence() { + let mut reports = Vec::new(); + + assert!(append_char_reports(&mut reports, '?')); + + assert_eq!(char_to_usage('?'), Some((0x38, 0x02))); + assert_eq!(reports.len(), 4); + assert_eq!(reports[0], [0x02, 0, 0, 0, 0, 0, 0, 0]); + assert_eq!(reports[1], [0x02, 0, 0x38, 0, 0, 0, 0, 0]); + assert_eq!(reports[2], [0x02, 0, 0, 0, 0, 0, 0, 0]); + assert_eq!(reports[3], [0; 8]); +} + +#[test] +fn unsupported_unicode_character_does_not_emit_partial_reports() { + let mut reports = Vec::new(); + + assert!(!append_char_reports(&mut reports, '๐Ÿ™‚')); + + assert!(reports.is_empty()); +} diff --git a/tests/unit/server/video_sinks/hevc_mjpeg_guard_unit.rs b/tests/unit/server/video_sinks/hevc_mjpeg_guard_unit.rs new file mode 100644 index 0000000..faced26 --- /dev/null +++ b/tests/unit/server/video_sinks/hevc_mjpeg_guard_unit.rs @@ -0,0 +1,174 @@ +// Unit coverage for the HEVC-to-MJPEG decoded-frame guard. +// +// Scope: include the server guard in the root test taxonomy and model the UVC +// helper decision that freezes suspicious frames. +// Targets: server/src/video_sinks/hevc_mjpeg_guard.rs. +// Why: when HEVC decode produces a syntactically valid but visually damaged +// JPEG, users should see a short last-good-frame freeze instead of grey slabs, +// black frames, or tearing. + +mod video_support { + pub fn env_u32(name: &str, default: u32) -> u32 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse().ok()) + .unwrap_or(default) + } +} + +#[allow(clippy::items_after_test_module)] +mod guard { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/video_sinks/hevc_mjpeg_guard.rs" + )); + + pub fn jpeg_quality() -> u32 { + hevc_jpeg_quality() + } + + pub fn min_reference() -> u32 { + min_reference_bytes() + } + + pub fn drop_pct() -> u32 { + size_drop_pct() + } + + pub fn should_freeze(previous_bytes: u64, next_bytes: usize) -> bool { + should_freeze_decoded_mjpeg(previous_bytes, next_bytes) + } + + pub fn should_freeze_frame(previous_bytes: u64, decoded_mjpeg: &[u8]) -> bool { + should_freeze_decoded_mjpeg_frame(previous_bytes, decoded_mjpeg) + } +} + +#[derive(Default)] +struct GuardedFrameEmitter { + last_good_bytes: u64, + emitted_bytes: Vec, + frozen_frames: usize, +} + +impl GuardedFrameEmitter { + fn push(&mut self, bytes: usize) -> bool { + if guard::should_freeze(self.last_good_bytes, bytes) { + self.frozen_frames += 1; + return false; + } + + self.last_good_bytes = bytes as u64; + self.emitted_bytes.push(bytes); + true + } +} + +#[test] +fn guard_freezes_suspicious_decoded_collapses_but_allows_normal_variation() { + temp_env::with_vars( + [ + ("LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP", Some("1")), + ("LESAVKA_UVC_HEVC_SIZE_DROP_PCT", Some("45")), + ("LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES", Some("65536")), + ], + || { + assert!(!guard::should_freeze(200_000, 110_000)); + assert!(guard::should_freeze(200_000, 80_000)); + assert!(!guard::should_freeze(20_000, 1_000)); + }, + ); +} + +#[test] +fn last_good_frame_model_does_not_emit_damaged_hevc_mjpeg_frames() { + temp_env::with_vars( + [ + ("LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP", Some("1")), + ("LESAVKA_UVC_HEVC_SIZE_DROP_PCT", Some("45")), + ("LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES", Some("65536")), + ], + || { + let mut emitter = GuardedFrameEmitter::default(); + + assert!(emitter.push(220_000)); + assert!(!emitter.push(20_000)); + assert!(!emitter.push(60_000)); + assert!(emitter.push(210_000)); + + assert_eq!(emitter.frozen_frames, 2); + assert_eq!(emitter.emitted_bytes, vec![220_000, 210_000]); + assert_eq!(emitter.last_good_bytes, 210_000); + }, + ); +} + +#[test] +fn guard_tuning_env_is_clamped_to_safe_runtime_bounds() { + temp_env::with_vars( + [ + ("LESAVKA_UVC_HEVC_JPEG_QUALITY", Some("0")), + ("LESAVKA_UVC_HEVC_SIZE_DROP_PCT", Some("0")), + ("LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES", Some("0")), + ], + || { + assert_eq!(guard::jpeg_quality(), 1); + assert_eq!(guard::drop_pct(), 1); + assert_eq!(guard::min_reference(), 1); + }, + ); + + temp_env::with_vars( + [ + ("LESAVKA_UVC_HEVC_JPEG_QUALITY", Some("101")), + ("LESAVKA_UVC_HEVC_SIZE_DROP_PCT", Some("100")), + ("LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES", Some("2048")), + ], + || { + assert_eq!(guard::jpeg_quality(), 100); + assert_eq!(guard::drop_pct(), 95); + assert_eq!(guard::min_reference(), 2048); + }, + ); +} + +fn jpeg_with_payload(payload: &[u8]) -> Vec { + let mut bytes = vec![0xff, 0xd8, 0xff, 0xda]; + bytes.extend_from_slice(payload); + bytes.extend_from_slice(&[0xff, 0xd9]); + bytes +} + +#[test] +fn guard_freezes_incomplete_and_low_entropy_decoded_frames() { + temp_env::with_vars( + [ + ("LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP", Some("1")), + ("LESAVKA_UVC_HEVC_SIZE_DROP_PCT", Some("45")), + ("LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES", Some("65536")), + ("LESAVKA_UVC_HEVC_MIN_PAYLOAD_DISTINCT_BYTES", Some("12")), + ("LESAVKA_UVC_HEVC_DOMINANT_BYTE_PCT", Some("92")), + ], + || { + let healthy_payload: Vec = (0..130_000).map(|idx| (idx % 251) as u8).collect(); + let grey_slab_payload = vec![0x80; 130_000]; + let black_slab_payload = vec![0x00; 130_000]; + let mut truncated = jpeg_with_payload(&healthy_payload); + truncated.truncate(truncated.len() - 1); + + assert!(!guard::should_freeze_frame( + 220_000, + &jpeg_with_payload(&healthy_payload), + )); + assert!(guard::should_freeze_frame( + 220_000, + &jpeg_with_payload(&grey_slab_payload), + )); + assert!(guard::should_freeze_frame( + 220_000, + &jpeg_with_payload(&black_slab_payload), + )); + assert!(guard::should_freeze_frame(220_000, &truncated)); + }, + ); +}