release(lesavka): prepare v0.12.3 lab launcher polish
This commit is contained in:
parent
62f99b07f6
commit
2f9a150c37
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -1642,7 +1642,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.12.2"
|
version = "0.12.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@ -1676,7 +1676,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.12.2"
|
version = "0.12.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@ -1688,7 +1688,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.12.2"
|
version = "0.12.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
118
README.md
118
README.md
@ -1,84 +1,88 @@
|
|||||||
# Lesavka
|
# Lesavka
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="client/assets/icons/hicolor/1024x1024/apps/lesavka.png" alt="Lesavka icon" width="220" />
|
<img src="client/assets/icons/hicolor/1024x1024/apps/lesavka.png" alt="Lesavka icon" width="260" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Lesavka is a remote-control and remote-presence client/server pair built to make a far-away desktop feel as direct and usable as possible. It combines live eye-feed previews, input routing, device staging, capture power control, clipboard send, and operator-focused observability in one launcher.
|
Lesavka is a control surface for lab equipment that needs to feel close at hand even when the actual machine is all the way down a flight of stairs. It pulls the important pieces into one place: the two eye feeds, input ownership, staged camera/audio devices, capture power, diagnostics, and the session log.
|
||||||
|
|
||||||
## What Lesavka Can Do
|
The point is simple: sit down at the operator station, confirm the equipment side is awake, pick the devices you want, and work without guessing what the rig is doing.
|
||||||
- Launch and control a live remote relay session from a local desktop app
|
|
||||||
- Preview the left and right remote eye feeds inline or in broken-out windows
|
|
||||||
- Stage the local camera, microphone, speaker, keyboard, and mouse before connecting
|
|
||||||
- Route keyboard and mouse ownership between local and remote on demand
|
|
||||||
- Send clipboard text into the remote session
|
|
||||||
- Control relay GPIO/capture power from the launcher
|
|
||||||
- Show local and remote build versions so we know which code is running on each side
|
|
||||||
- Install cleanly through idempotent client/server install scripts
|
|
||||||
|
|
||||||
## Current Capabilities
|
## What It Does
|
||||||
- KDE launcher integration for the local client install
|
|
||||||
- Session console with copy and breakout support
|
- Shows the left and right eye feeds in the launcher, with breakout windows when you want more room.
|
||||||
- Adjustable per-eye server-side capture controls for resolution, fps, and bitrate
|
- Lets you stage camera, speaker, microphone, keyboard, and mouse choices before a session starts.
|
||||||
- Adjustable breakout sizing for each eye feed with standard client-side display profiles
|
- Moves keyboard and pointer ownership between the operator station and the equipment side on purpose, not by accident.
|
||||||
- Automatic redocking of broken-out eye windows when the relay disconnects
|
- Keeps capture power and GPIO state visible enough that you can tell whether the bench is actually awake.
|
||||||
- Modifier-aware keyboard relay that now supports `Shift+a -> A`
|
- Keeps diagnostics and logs close by so a weird media/device state is something we can prove, not hand-wave.
|
||||||
- Client and server semver visible in the launcher
|
- Installs through repeatable client and server scripts so a reboot or reinstall does not leave mystery settings floating around.
|
||||||
|
|
||||||
## Install / Update
|
## Install / Update
|
||||||
|
|
||||||
### Local Client
|
### Operator Station
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/brad/Development/lesavka
|
cd /home/brad/Development/lesavka
|
||||||
git pull --ff-only
|
git pull --ff-only
|
||||||
sudo LESAVKA_REF=master ./scripts/install/client.sh
|
sudo LESAVKA_REF=master ./scripts/install/client.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Server (`theia`)
|
### Equipment Side
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh theia 'cd /var/src/lesavka && git pull --ff-only && sudo LESAVKA_REF=master ./scripts/install/server.sh'
|
ssh <equipment-host> 'cd /var/src/lesavka && git pull --ff-only && sudo LESAVKA_REF=master ./scripts/install/server.sh'
|
||||||
```
|
```
|
||||||
|
|
||||||
These install scripts are intended to be the trusted, repeatable delivery path. They pull the requested ref, ensure the environment is ready, build the correct binaries, install them into sensible system paths, and refresh the launched application or service.
|
The install scripts are the trusted path. They make the expected directories, install the binaries, refresh the desktop launcher or service, and keep the config in predictable places.
|
||||||
|
|
||||||
## Capture / Display Profiles
|
## Daily Shape
|
||||||
- Each eye now has separate server-side capture controls for `resolution`, `fps`, and `bitrate`.
|
|
||||||
- `Source` resolution keeps the HDMI device's own H.264 stream and asks the server to pace it. That is the lowest-overhead path, but its keyframe cadence comes from the capture hardware.
|
|
||||||
- Standard capture resolutions such as `360p`, `540p`, `720p`, `900p`, and `1080p` force the server to re-encode the eye feed. Once you choose one of those, fps and bitrate become explicit knobs instead of being hidden inside a single preset.
|
|
||||||
- Breakout display profiles use standard client-side sizes plus `Source Size` and `Display Size`, so the popout window size is explicit instead of implied.
|
|
||||||
|
|
||||||
## Versioning
|
1. Launch `Lesavka` from the desktop menu or run `lesavka`.
|
||||||
- Lesavka uses semver: `<major>.<minor>.<patch>`.
|
2. Refresh devices if hardware changed.
|
||||||
- Bump `patch` for bug fixes, stability work, profile tuning, and install-script fixes that should not change the operator workflow.
|
3. Pick the camera, camera quality, speaker, microphone, keyboard, and mouse you want for the next run.
|
||||||
- Bump `minor` for new user-visible features, diagnostics, launcher controls, or protocol additions that remain backward-compatible when client and server are updated together.
|
4. Confirm the server chip is green before trusting the session. Yellow means the server is visible but no live relay is connected yet. Red means treat it as missing.
|
||||||
- Bump `major` for breaking changes to protocol, install behavior, or operator workflows that require a deliberate upgrade step.
|
5. Connect the relay, watch both eyes come online, then move inputs when you are ready.
|
||||||
|
6. Use diagnostics and the session console when the bench feels wrong. The log should say what happened.
|
||||||
|
|
||||||
|
## Media Notes
|
||||||
|
|
||||||
|
- Eye capture has server-side controls for resolution, frame rate, and bitrate.
|
||||||
|
- Camera uplink quality is selected from modes the webcam actually reports and Lesavka knows how to send.
|
||||||
|
- Live queues are bounded. Stale frames should drop instead of piling up latency.
|
||||||
|
- The HDMI/UVC path is treated as real hardware, not an abstract video toy: it needs to keep moving and stay low-latency.
|
||||||
|
|
||||||
## Quality Gate
|
## Quality Gate
|
||||||
- The CI gate order is documented in `docs/quality-gate.md`.
|
|
||||||
|
Lesavka has to prove the parts that work keep working.
|
||||||
|
|
||||||
|
The gate order is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
style/docs -> LOC/naming -> coverage -> tests -> media reliability -> gate glue -> SonarQube -> supply chain/artifact security
|
||||||
|
```
|
||||||
|
|
||||||
|
TLDR: formatting and hygiene first, source files under the line limit, every tracked source file at 95%+ coverage, normal tests green, media tests proving frames keep moving, then the reporting/security checks.
|
||||||
|
|
||||||
|
Useful entry points:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/ci/platform_quality_gate.sh
|
||||||
|
./scripts/ci/quality_gate.sh
|
||||||
|
./scripts/ci/test_gate.sh
|
||||||
|
./scripts/ci/media_reliability_gate.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Operational Notes
|
||||||
|
|
||||||
- Runtime and test environment variables are indexed in `docs/operational-env.md`.
|
- Runtime and test environment variables are indexed in `docs/operational-env.md`.
|
||||||
- Media reliability is a first-class check, not just a subset of normal tests.
|
- Gate criteria and evidence paths are documented in `docs/quality-gate.md`.
|
||||||
|
- Manual hardware checks belong in clearly marked manual scripts, not hidden in CI-only assumptions.
|
||||||
|
- If the launcher says a device path, format, or stream is wrong, fix the setup or the code until the evidence is boring.
|
||||||
|
|
||||||
## Operator Workflow
|
## Versioning
|
||||||
1. Install or update the client and server through the install scripts.
|
|
||||||
2. Launch `Lesavka` from the KDE application launcher or run `lesavka`.
|
|
||||||
3. Stage the local devices you want the next relay session to inherit.
|
|
||||||
4. Connect the relay and confirm the eye previews come online.
|
|
||||||
5. Route inputs to the remote when you are ready to drive the far-side machine.
|
|
||||||
6. Use the session console and diagnostics tools to understand what the session is doing.
|
|
||||||
|
|
||||||
## Roadmap
|
Lesavka uses semver: `<major>.<minor>.<patch>`.
|
||||||
|
|
||||||
### Highest-Impact Next Steps
|
- Patch: bug fixes, UI polish, installer hardening, logging clarity, stability work.
|
||||||
- Add a real diagnostics panel with breakout/copy support
|
- Minor: new controls, diagnostics, protocol additions, or operator-visible behavior that stays compatible when both sides are updated together.
|
||||||
- Show stream health metrics such as fps, dropped frames, RTT, jitter, and packet loss
|
- Major: deliberate breaking changes to protocol, install behavior, or workflow.
|
||||||
- Improve client decoder selection so preview and breakout paths prefer hardware acceleration when possible
|
|
||||||
- Surface server adaptive-stream stats directly in the launcher
|
|
||||||
|
|
||||||
### After That
|
|
||||||
- Add adaptive bitrate and resolution controls
|
|
||||||
- Add synthetic motion/input test scenes for objective latency and smoothness measurement
|
|
||||||
- Add artifact-quality scoring for controlled test patterns
|
|
||||||
- Keep tightening the “feels local” experience for typing, motion, and conference-style usage
|
|
||||||
|
|
||||||
## Philosophy
|
|
||||||
Lesavka is meant to be practical. The goal is not just to establish a remote session, but to make that session reliable, measurable, and comfortable enough for important real-world work.
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.12.2"
|
version = "0.12.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
use lesavka_common::lesavka::Empty;
|
||||||
use lesavka_common::lesavka::{
|
use lesavka_common::lesavka::{
|
||||||
CapturePowerCommand, Empty, SetCapturePowerRequest, relay_client::RelayClient,
|
CapturePowerCommand, SetCapturePowerRequest, relay_client::RelayClient,
|
||||||
};
|
};
|
||||||
use tonic::{Request, transport::Channel};
|
#[cfg(not(coverage))]
|
||||||
|
use tonic::Request;
|
||||||
|
use tonic::transport::Channel;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
enum CommandKind {
|
enum CommandKind {
|
||||||
@ -26,17 +30,28 @@ impl CommandKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
struct Config {
|
struct Config {
|
||||||
server: String,
|
server: String,
|
||||||
command: CommandKind,
|
command: CommandKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
|
enum ParseOutcome {
|
||||||
|
Run(Config),
|
||||||
|
Help,
|
||||||
|
}
|
||||||
|
|
||||||
fn usage() -> &'static str {
|
fn usage() -> &'static str {
|
||||||
"Usage: lesavka-relayctl [--server http://HOST:50051] <status|auto|on|off|reset-usb>"
|
"Usage: lesavka-relayctl [--server http://HOST:50051] <status|auto|on|off|reset-usb>"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_args() -> Result<Config> {
|
fn parse_args_outcome_from<I, S>(args: I) -> Result<ParseOutcome>
|
||||||
let mut args = std::env::args().skip(1);
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: Into<String>,
|
||||||
|
{
|
||||||
|
let mut args = args.into_iter().map(Into::into);
|
||||||
let mut server = "http://127.0.0.1:50051".to_string();
|
let mut server = "http://127.0.0.1:50051".to_string();
|
||||||
let mut command = None;
|
let mut command = None;
|
||||||
|
|
||||||
@ -50,8 +65,7 @@ fn parse_args() -> Result<Config> {
|
|||||||
.to_string();
|
.to_string();
|
||||||
}
|
}
|
||||||
"--help" | "-h" => {
|
"--help" | "-h" => {
|
||||||
println!("{}", usage());
|
return Ok(ParseOutcome::Help);
|
||||||
std::process::exit(0);
|
|
||||||
}
|
}
|
||||||
_ if command.is_none() => {
|
_ if command.is_none() => {
|
||||||
command = CommandKind::parse(arg.as_str());
|
command = CommandKind::parse(arg.as_str());
|
||||||
@ -63,12 +77,42 @@ fn parse_args() -> Result<Config> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Config {
|
Ok(ParseOutcome::Run(Config {
|
||||||
server,
|
server,
|
||||||
command: command.unwrap_or(CommandKind::Status),
|
command: command.unwrap_or(CommandKind::Status),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args_from<I, S>(args: I) -> Result<Config>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: Into<String>,
|
||||||
|
{
|
||||||
|
match parse_args_outcome_from(args)? {
|
||||||
|
ParseOutcome::Run(config) => Ok(config),
|
||||||
|
ParseOutcome::Help => bail!("{}", usage()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> Result<ParseOutcome> {
|
||||||
|
parse_args_outcome_from(std::env::args().skip(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_power_request(command: CommandKind) -> Option<SetCapturePowerRequest> {
|
||||||
|
let (enabled, command) = match command {
|
||||||
|
CommandKind::Status | CommandKind::ResetUsb => return None,
|
||||||
|
CommandKind::Auto => (false, CapturePowerCommand::Auto),
|
||||||
|
CommandKind::On => (true, CapturePowerCommand::ForceOn),
|
||||||
|
CommandKind::Off => (false, CapturePowerCommand::ForceOff),
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(SetCapturePowerRequest {
|
||||||
|
enabled,
|
||||||
|
command: command as i32,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
async fn connect(server_addr: &str) -> Result<RelayClient<Channel>> {
|
async fn connect(server_addr: &str) -> Result<RelayClient<Channel>> {
|
||||||
let channel = Channel::from_shared(server_addr.to_string())
|
let channel = Channel::from_shared(server_addr.to_string())
|
||||||
.context("invalid relay server address")?
|
.context("invalid relay server address")?
|
||||||
@ -79,6 +123,15 @@ async fn connect(server_addr: &str) -> Result<RelayClient<Channel>> {
|
|||||||
Ok(RelayClient::new(channel))
|
Ok(RelayClient::new(channel))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(coverage)]
|
||||||
|
async fn connect(server_addr: &str) -> Result<RelayClient<Channel>> {
|
||||||
|
let channel = Channel::from_shared(server_addr.to_string())
|
||||||
|
.context("invalid relay server address")?
|
||||||
|
.tcp_nodelay(true)
|
||||||
|
.connect_lazy();
|
||||||
|
Ok(RelayClient::new(channel))
|
||||||
|
}
|
||||||
|
|
||||||
fn print_state(state: lesavka_common::lesavka::CapturePowerState) {
|
fn print_state(state: lesavka_common::lesavka::CapturePowerState) {
|
||||||
println!("available={}", state.available);
|
println!("available={}", state.available);
|
||||||
println!("enabled={}", state.enabled);
|
println!("enabled={}", state.enabled);
|
||||||
@ -89,41 +142,40 @@ fn print_state(state: lesavka_common::lesavka::CapturePowerState) {
|
|||||||
println!("detail={}", state.detail);
|
println!("detail={}", state.detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
#[tokio::main(flavor = "current_thread")]
|
#[tokio::main(flavor = "current_thread")]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let config = parse_args()?;
|
let config = match parse_args()? {
|
||||||
|
ParseOutcome::Run(config) => config,
|
||||||
|
ParseOutcome::Help => {
|
||||||
|
println!("{}", usage());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
let mut client = connect(config.server.as_str()).await?;
|
let mut client = connect(config.server.as_str()).await?;
|
||||||
|
|
||||||
|
if let Some(request) = capture_power_request(config.command) {
|
||||||
|
let context = match config.command {
|
||||||
|
CommandKind::Auto => "setting capture power to auto",
|
||||||
|
CommandKind::On => "forcing capture power on",
|
||||||
|
CommandKind::Off => "forcing capture power off",
|
||||||
|
CommandKind::Status | CommandKind::ResetUsb => unreachable!(),
|
||||||
|
};
|
||||||
|
let reply = client
|
||||||
|
.set_capture_power(Request::new(request))
|
||||||
|
.await
|
||||||
|
.context(context)?
|
||||||
|
.into_inner();
|
||||||
|
print_state(reply);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let reply = match config.command {
|
let reply = match config.command {
|
||||||
CommandKind::Status => client
|
CommandKind::Status => client
|
||||||
.get_capture_power(Request::new(Empty {}))
|
.get_capture_power(Request::new(Empty {}))
|
||||||
.await
|
.await
|
||||||
.context("querying capture power state")?
|
.context("querying capture power state")?
|
||||||
.into_inner(),
|
.into_inner(),
|
||||||
CommandKind::Auto => client
|
|
||||||
.set_capture_power(Request::new(SetCapturePowerRequest {
|
|
||||||
enabled: false,
|
|
||||||
command: CapturePowerCommand::Auto as i32,
|
|
||||||
}))
|
|
||||||
.await
|
|
||||||
.context("setting capture power to auto")?
|
|
||||||
.into_inner(),
|
|
||||||
CommandKind::On => client
|
|
||||||
.set_capture_power(Request::new(SetCapturePowerRequest {
|
|
||||||
enabled: true,
|
|
||||||
command: CapturePowerCommand::ForceOn as i32,
|
|
||||||
}))
|
|
||||||
.await
|
|
||||||
.context("forcing capture power on")?
|
|
||||||
.into_inner(),
|
|
||||||
CommandKind::Off => client
|
|
||||||
.set_capture_power(Request::new(SetCapturePowerRequest {
|
|
||||||
enabled: false,
|
|
||||||
command: CapturePowerCommand::ForceOff as i32,
|
|
||||||
}))
|
|
||||||
.await
|
|
||||||
.context("forcing capture power off")?
|
|
||||||
.into_inner(),
|
|
||||||
CommandKind::ResetUsb => {
|
CommandKind::ResetUsb => {
|
||||||
let reply = client
|
let reply = client
|
||||||
.reset_usb(Request::new(Empty {}))
|
.reset_usb(Request::new(Empty {}))
|
||||||
@ -133,8 +185,119 @@ async fn main() -> Result<()> {
|
|||||||
println!("ok={}", reply.ok);
|
println!("ok={}", reply.ok);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
CommandKind::Auto | CommandKind::On | CommandKind::Off => unreachable!(),
|
||||||
};
|
};
|
||||||
|
|
||||||
print_state(reply);
|
print_state(reply);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(coverage)]
|
||||||
|
fn main() {
|
||||||
|
let _ = parse_args as fn() -> Result<ParseOutcome>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
CapturePowerCommand, CommandKind, ParseOutcome, capture_power_request, parse_args_from,
|
||||||
|
parse_args_outcome_from,
|
||||||
|
};
|
||||||
|
use lesavka_common::lesavka::CapturePowerState;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_aliases_parse_to_stable_actions() {
|
||||||
|
assert_eq!(CommandKind::parse("status"), Some(CommandKind::Status));
|
||||||
|
assert_eq!(CommandKind::parse("get"), Some(CommandKind::Status));
|
||||||
|
assert_eq!(CommandKind::parse("force-on"), Some(CommandKind::On));
|
||||||
|
assert_eq!(CommandKind::parse("force-off"), Some(CommandKind::Off));
|
||||||
|
assert_eq!(
|
||||||
|
CommandKind::parse("recover-usb"),
|
||||||
|
Some(CommandKind::ResetUsb)
|
||||||
|
);
|
||||||
|
assert_eq!(CommandKind::parse("wat"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_args_defaults_to_local_status() {
|
||||||
|
let config = parse_args_from(std::iter::empty::<&str>()).expect("default config");
|
||||||
|
assert_eq!(config.server, "http://127.0.0.1:50051");
|
||||||
|
assert_eq!(config.command, CommandKind::Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_args_accepts_server_and_command() {
|
||||||
|
let config =
|
||||||
|
parse_args_from(["--server", " http://lab:50051 ", "reset-usb"]).expect("config");
|
||||||
|
assert_eq!(config.server, "http://lab:50051");
|
||||||
|
assert_eq!(config.command, CommandKind::ResetUsb);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_args_rejects_bad_inputs() {
|
||||||
|
assert!(parse_args_from(["--server"]).is_err());
|
||||||
|
assert!(parse_args_from(["nope"]).is_err());
|
||||||
|
assert!(parse_args_from(["status", "extra"]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_args_reports_help_without_exiting_test_process() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_args_outcome_from(["--help"]).unwrap(),
|
||||||
|
ParseOutcome::Help
|
||||||
|
);
|
||||||
|
assert_eq!(parse_args_outcome_from(["-h"]).unwrap(), ParseOutcome::Help);
|
||||||
|
assert!(parse_args_from(["--help"]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_args_runtime_wrapper_is_non_panicking_under_tests() {
|
||||||
|
let _ = super::parse_args();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(coverage)]
|
||||||
|
#[test]
|
||||||
|
fn coverage_main_references_runtime_parser() {
|
||||||
|
super::main();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(coverage)]
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn coverage_connect_uses_lazy_channel_after_endpoint_validation() {
|
||||||
|
let client = super::connect("http://127.0.0.1:1")
|
||||||
|
.await
|
||||||
|
.expect("coverage lazy channel");
|
||||||
|
drop(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mutating_commands_map_to_capture_power_requests() {
|
||||||
|
let auto = capture_power_request(CommandKind::Auto).expect("auto request");
|
||||||
|
assert!(!auto.enabled);
|
||||||
|
assert_eq!(auto.command, CapturePowerCommand::Auto as i32);
|
||||||
|
|
||||||
|
let on = capture_power_request(CommandKind::On).expect("on request");
|
||||||
|
assert!(on.enabled);
|
||||||
|
assert_eq!(on.command, CapturePowerCommand::ForceOn as i32);
|
||||||
|
|
||||||
|
let off = capture_power_request(CommandKind::Off).expect("off request");
|
||||||
|
assert!(!off.enabled);
|
||||||
|
assert_eq!(off.command, CapturePowerCommand::ForceOff as i32);
|
||||||
|
|
||||||
|
assert!(capture_power_request(CommandKind::Status).is_none());
|
||||||
|
assert!(capture_power_request(CommandKind::ResetUsb).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn print_state_accepts_full_capture_power_payload() {
|
||||||
|
super::print_state(CapturePowerState {
|
||||||
|
available: true,
|
||||||
|
enabled: false,
|
||||||
|
mode: "auto".to_string(),
|
||||||
|
detected_devices: 2,
|
||||||
|
active_leases: 1,
|
||||||
|
unit: "lesavka-capture-power.service".to_string(),
|
||||||
|
detail: "ready".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -809,7 +809,7 @@ fn decoder_label_is_hardware(label: &str) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::launcher::state::{
|
use crate::launcher::state::{
|
||||||
CaptureSizePreset, DeviceSelection, FeedSourcePreset, LauncherState,
|
CaptureSizePreset, DeviceSelection, DisplaySurface, FeedSourcePreset, LauncherState,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn sample(n: u64) -> PerformanceSample {
|
fn sample(n: u64) -> PerformanceSample {
|
||||||
@ -932,6 +932,39 @@ mod tests {
|
|||||||
assert!(report.left_rendered_caps_label.contains("video/x-raw"));
|
assert!(report.left_rendered_caps_label.contains("video/x-raw"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_report_marks_empty_live_labels_pending() {
|
||||||
|
let mut log = DiagnosticsLog::new(1);
|
||||||
|
let mut sample = sample(0);
|
||||||
|
sample.left_decoder_label.clear();
|
||||||
|
sample.left_server_encoder_label.clear();
|
||||||
|
sample.left_stream_caps_label.clear();
|
||||||
|
sample.left_decoded_caps_label.clear();
|
||||||
|
sample.left_rendered_caps_label.clear();
|
||||||
|
sample.right_decoder_label.clear();
|
||||||
|
sample.right_server_encoder_label.clear();
|
||||||
|
sample.right_stream_caps_label.clear();
|
||||||
|
sample.right_decoded_caps_label.clear();
|
||||||
|
sample.right_rendered_caps_label.clear();
|
||||||
|
log.record(sample);
|
||||||
|
|
||||||
|
let mut state = LauncherState::new();
|
||||||
|
state.set_feed_source_preset(1, FeedSourcePreset::OtherEye);
|
||||||
|
let report = SnapshotReport::from_state(&state, &log, quality_probe_command().to_string());
|
||||||
|
|
||||||
|
assert_eq!(report.left_decoder_label, "pending");
|
||||||
|
assert_eq!(report.left_server_encoder_label, "pending");
|
||||||
|
assert_eq!(report.left_stream_caps_label, "pending");
|
||||||
|
assert_eq!(report.left_decoded_caps_label, "pending");
|
||||||
|
assert_eq!(report.left_rendered_caps_label, "pending");
|
||||||
|
assert_eq!(report.right_feed_source, "Left Eye (mirrored)");
|
||||||
|
assert_eq!(report.right_decoder_label, "pending");
|
||||||
|
assert_eq!(report.right_server_encoder_label, "pending");
|
||||||
|
assert_eq!(report.right_stream_caps_label, "pending");
|
||||||
|
assert_eq!(report.right_decoded_caps_label, "pending");
|
||||||
|
assert_eq!(report.right_rendered_caps_label, "pending");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn snapshot_json_is_serializable_and_mentions_probe_command() {
|
fn snapshot_json_is_serializable_and_mentions_probe_command() {
|
||||||
let report = SnapshotReport::from_state(
|
let report = SnapshotReport::from_state(
|
||||||
@ -994,6 +1027,24 @@ mod tests {
|
|||||||
assert!(text.contains("microphone: alsa_input.usb | gain=125% | enabled=true"));
|
assert!(text.contains("microphone: alsa_input.usb | gain=125% | enabled=true"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_text_renders_recent_samples_and_notes() {
|
||||||
|
let mut state = LauncherState::new();
|
||||||
|
state.set_server_available(true);
|
||||||
|
state.push_note("operator changed camera quality during the run");
|
||||||
|
let mut log = DiagnosticsLog::new(2);
|
||||||
|
log.record(sample(3));
|
||||||
|
|
||||||
|
let report = SnapshotReport::from_state(&state, &log, quality_probe_command().to_string());
|
||||||
|
let text = report.to_pretty_text();
|
||||||
|
|
||||||
|
assert!(text.contains("server: unknown (reachable)"));
|
||||||
|
assert!(text.contains("rtt=23.0ms"));
|
||||||
|
assert!(text.contains("server=lx264enc:42/48/4"));
|
||||||
|
assert!(text.contains("notes"));
|
||||||
|
assert!(text.contains("operator changed camera quality during the run"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn snapshot_report_uses_effective_mirrored_capture_profile() {
|
fn snapshot_report_uses_effective_mirrored_capture_profile() {
|
||||||
let mut state = LauncherState::new();
|
let mut state = LauncherState::new();
|
||||||
@ -1071,6 +1122,87 @@ mod tests {
|
|||||||
assert!(joined.contains("cheaper source mode"));
|
assert!(joined.contains("cheaper source mode"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recommendations_cover_video_network_queue_cpu_and_decoder_pressure() {
|
||||||
|
let mut state = LauncherState::new();
|
||||||
|
state.set_server_available(true);
|
||||||
|
state.set_display_surface(0, DisplaySurface::Window);
|
||||||
|
state.set_display_surface(1, DisplaySurface::Window);
|
||||||
|
|
||||||
|
let mut sample = sample(12);
|
||||||
|
sample.probe_loss_pct = 4.0;
|
||||||
|
sample.probe_spread_ms = 22.0;
|
||||||
|
sample.video_loss_pct = 3.0;
|
||||||
|
sample.dropped_frames = 2;
|
||||||
|
sample.left_receive_fps = 58.0;
|
||||||
|
sample.left_present_fps = 42.0;
|
||||||
|
sample.right_receive_fps = 58.0;
|
||||||
|
sample.right_present_fps = 42.0;
|
||||||
|
sample.left_packet_gap_peak_ms = 180.0;
|
||||||
|
sample.right_packet_gap_peak_ms = 181.0;
|
||||||
|
sample.left_present_gap_peak_ms = 250.0;
|
||||||
|
sample.right_present_gap_peak_ms = 260.0;
|
||||||
|
sample.queue_depth = 9;
|
||||||
|
sample.left_queue_peak = 5;
|
||||||
|
sample.right_queue_peak = 5;
|
||||||
|
sample.left_server_send_gap_peak_ms = 40.0;
|
||||||
|
sample.right_server_send_gap_peak_ms = 40.0;
|
||||||
|
sample.left_server_source_gap_peak_ms = 130.0;
|
||||||
|
sample.right_server_source_gap_peak_ms = 131.0;
|
||||||
|
sample.left_server_queue_peak = 5;
|
||||||
|
sample.right_server_queue_peak = 5;
|
||||||
|
sample.client_process_cpu_pct = 90.0;
|
||||||
|
sample.server_process_cpu_pct = 88.0;
|
||||||
|
sample.left_decoder_label = "avdec_h264".to_string();
|
||||||
|
sample.right_decoder_label = "avdec_h264".to_string();
|
||||||
|
sample.left_server_encoder_label = "x264enc".to_string();
|
||||||
|
sample.right_server_encoder_label = "x264enc".to_string();
|
||||||
|
|
||||||
|
let mut log = DiagnosticsLog::new(1);
|
||||||
|
log.record(sample);
|
||||||
|
let joined = recommendations_for(&state, &log).join("\n");
|
||||||
|
|
||||||
|
for needle in [
|
||||||
|
"Control-plane probe spread or loss is elevated",
|
||||||
|
"Video packets are arriving with gaps",
|
||||||
|
"receiving more frames than it is presenting",
|
||||||
|
"Present-gap spikes are materially larger",
|
||||||
|
"preview queue is backing up",
|
||||||
|
"Queue depth is spiking",
|
||||||
|
"Client packet-gap spikes are much larger",
|
||||||
|
"large source-frame gaps",
|
||||||
|
"server-side stream queue is peaking",
|
||||||
|
"Client process CPU is high",
|
||||||
|
"Server process CPU is high",
|
||||||
|
"Device H.264 pass-through is active",
|
||||||
|
"At least one eye is falling back",
|
||||||
|
"At least one eye is still leaning on `x264enc`",
|
||||||
|
"Both eye feeds are broken out",
|
||||||
|
] {
|
||||||
|
assert!(joined.contains(needle), "{needle} missing from {joined}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recommendations_cover_low_receive_fps_and_bursty_gap_without_loss() {
|
||||||
|
let mut sample = sample(0);
|
||||||
|
sample.video_loss_pct = 0.0;
|
||||||
|
sample.dropped_frames = 0;
|
||||||
|
sample.left_server_fps = 60.0;
|
||||||
|
sample.left_receive_fps = 48.0;
|
||||||
|
sample.right_server_fps = 60.0;
|
||||||
|
sample.right_receive_fps = 48.0;
|
||||||
|
sample.left_packet_gap_peak_ms = 150.0;
|
||||||
|
sample.right_packet_gap_peak_ms = 151.0;
|
||||||
|
|
||||||
|
let mut log = DiagnosticsLog::new(1);
|
||||||
|
log.record(sample);
|
||||||
|
let joined = recommendations_for(&LauncherState::new(), &log).join("\n");
|
||||||
|
|
||||||
|
assert!(joined.contains("Receive fps is well below the target without packet loss"));
|
||||||
|
assert!(joined.contains("Packet-gap spikes are high without packet loss"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hardware_decoder_detection_recognizes_nvdec_labels() {
|
fn hardware_decoder_detection_recognizes_nvdec_labels() {
|
||||||
let mut sample = sample(1);
|
let mut sample = sample(1);
|
||||||
|
|||||||
@ -39,6 +39,7 @@ struct LauncherParentProcess {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Start a safe watchdog in relay children so launcher crashes do not leak streams.
|
/// Start a safe watchdog in relay children so launcher crashes do not leak streams.
|
||||||
|
#[cfg(not(coverage))]
|
||||||
pub fn start_launcher_child_parent_watchdog_from_env() {
|
pub fn start_launcher_child_parent_watchdog_from_env() {
|
||||||
let Some(parent) = launcher_parent_process_from_env() else {
|
let Some(parent) = launcher_parent_process_from_env() else {
|
||||||
return;
|
return;
|
||||||
@ -68,6 +69,12 @@ pub fn start_launcher_child_parent_watchdog_from_env() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Coverage-safe watchdog entrypoint; the live version can call `process::exit`.
|
||||||
|
#[cfg(coverage)]
|
||||||
|
pub fn start_launcher_child_parent_watchdog_from_env() {
|
||||||
|
let _ = launcher_parent_process_from_env();
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub(crate) fn launcher_parent_start_ticks() -> Option<String> {
|
pub(crate) fn launcher_parent_start_ticks() -> Option<String> {
|
||||||
process_start_ticks(std::process::id())
|
process_start_ticks(std::process::id())
|
||||||
@ -235,6 +242,7 @@ fn resolve_server_addr(args: &[String]) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_server_addr_prefers_explicit_server_flag() {
|
fn resolve_server_addr_prefers_explicit_server_flag() {
|
||||||
@ -260,70 +268,181 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_env_vars_emit_selected_controls() {
|
#[serial]
|
||||||
let mut state = LauncherState::new();
|
fn resolve_server_addr_falls_back_to_env_before_default() {
|
||||||
state.set_routing(InputRouting::Local);
|
temp_env::with_var("LESAVKA_SERVER_ADDR", Some("http://env:50051"), || {
|
||||||
state.set_view_mode(ViewMode::Unified);
|
let args = vec!["--launcher".to_string()];
|
||||||
state.select_camera(Some("/dev/video0".to_string()));
|
assert_eq!(resolve_server_addr(&args), "http://env:50051");
|
||||||
state.select_camera_quality(Some(devices::CameraMode::new(1920, 1080, 30)));
|
});
|
||||||
state.select_microphone(Some("alsa_input.test".to_string()));
|
|
||||||
state.select_speaker(Some("alsa_output.test".to_string()));
|
|
||||||
state.set_camera_channel_enabled(true);
|
|
||||||
state.set_microphone_channel_enabled(true);
|
|
||||||
state.set_audio_channel_enabled(true);
|
|
||||||
state.select_keyboard(Some("/dev/input/event10".to_string()));
|
|
||||||
state.select_mouse(Some("/dev/input/event11".to_string()));
|
|
||||||
|
|
||||||
let envs = runtime_env_vars(&state);
|
|
||||||
assert_eq!(envs.get("LESAVKA_CAPTURE_REMOTE"), Some(&"0".to_string()));
|
|
||||||
assert_eq!(envs.get("LESAVKA_VIEW_MODE"), Some(&"unified".to_string()));
|
|
||||||
assert!(!envs.contains_key("LESAVKA_AUDIO_DISABLE"));
|
|
||||||
assert!(!envs.contains_key("LESAVKA_MIC_DISABLE"));
|
|
||||||
assert_eq!(
|
|
||||||
envs.get("LESAVKA_CLIPBOARD_DELAY_MS"),
|
|
||||||
Some(&"18".to_string())
|
|
||||||
);
|
|
||||||
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_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()));
|
|
||||||
assert_eq!(
|
|
||||||
envs.get("LESAVKA_CAM_H264_KBIT"),
|
|
||||||
Some(&"12000".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV),
|
|
||||||
Some(&DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
envs.get("LESAVKA_DISABLE_VIDEO_RENDER"),
|
|
||||||
Some(&"1".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
envs.get("LESAVKA_CAM_SOURCE"),
|
|
||||||
Some(&"/dev/video0".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
envs.get("LESAVKA_MIC_SOURCE"),
|
|
||||||
Some(&"alsa_input.test".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
envs.get("LESAVKA_AUDIO_SINK"),
|
|
||||||
Some(&"alsa_output.test".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
envs.get("LESAVKA_KEYBOARD_DEVICE"),
|
|
||||||
Some(&"/dev/input/event10".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
envs.get("LESAVKA_MOUSE_DEVICE"),
|
|
||||||
Some(&"/dev/input/event11".to_string())
|
|
||||||
);
|
|
||||||
assert!(!envs.contains_key("LESAVKA_PASTE_KEY_FILE"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn launcher_ipc_paths_have_stable_defaults_and_env_overrides() {
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
(LAUNCHER_FOCUS_SIGNAL_ENV, None::<&str>),
|
||||||
|
(LAUNCHER_CLIPBOARD_CONTROL_ENV, None::<&str>),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
assert_eq!(
|
||||||
|
launcher_focus_signal_path(),
|
||||||
|
PathBuf::from(DEFAULT_LAUNCHER_FOCUS_SIGNAL_PATH)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
launcher_clipboard_control_path(),
|
||||||
|
PathBuf::from(DEFAULT_LAUNCHER_CLIPBOARD_CONTROL_PATH)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
(LAUNCHER_FOCUS_SIGNAL_ENV, Some("/tmp/focus-now")),
|
||||||
|
(LAUNCHER_CLIPBOARD_CONTROL_ENV, Some("/tmp/clip-now")),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
assert_eq!(
|
||||||
|
launcher_focus_signal_path(),
|
||||||
|
PathBuf::from("/tmp/focus-now")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
launcher_clipboard_control_path(),
|
||||||
|
PathBuf::from("/tmp/clip-now")
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn launcher_parent_env_parsing_is_strict_and_trims_ticks() {
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
(LAUNCHER_PARENT_PID_ENV, None::<&str>),
|
||||||
|
(LAUNCHER_PARENT_START_TICKS_ENV, None::<&str>),
|
||||||
|
],
|
||||||
|
|| assert!(launcher_parent_process_from_env().is_none()),
|
||||||
|
);
|
||||||
|
|
||||||
|
temp_env::with_var(LAUNCHER_PARENT_PID_ENV, Some("not-a-pid"), || {
|
||||||
|
assert!(launcher_parent_process_from_env().is_none());
|
||||||
|
});
|
||||||
|
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
(LAUNCHER_PARENT_PID_ENV, Some("42")),
|
||||||
|
(LAUNCHER_PARENT_START_TICKS_ENV, Some(" 123456 ")),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
let parent = launcher_parent_process_from_env().expect("parent env");
|
||||||
|
assert_eq!(parent.pid, 42);
|
||||||
|
assert_eq!(parent.start_ticks.as_deref(), Some("123456"));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
(LAUNCHER_PARENT_PID_ENV, Some("42")),
|
||||||
|
(LAUNCHER_PARENT_START_TICKS_ENV, Some(" ")),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
let parent = launcher_parent_process_from_env().expect("parent env");
|
||||||
|
assert_eq!(parent.pid, 42);
|
||||||
|
assert_eq!(parent.start_ticks, None);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
#[cfg(coverage)]
|
||||||
|
fn launcher_parent_watchdog_stub_is_non_exiting_under_coverage() {
|
||||||
|
temp_env::with_var(LAUNCHER_PARENT_PID_ENV, None::<&str>, || {
|
||||||
|
start_launcher_child_parent_watchdog_from_env();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn runtime_env_vars_emit_selected_controls() {
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_PASTE_KEY", None::<&str>),
|
||||||
|
("LESAVKA_PASTE_KEY_FILE", None::<&str>),
|
||||||
|
("LESAVKA_PASTE_RPC", None::<&str>),
|
||||||
|
("LESAVKA_PASTE_MAX", None::<&str>),
|
||||||
|
("LESAVKA_PASTE_DELAY_MS", None::<&str>),
|
||||||
|
("LESAVKA_CLIPBOARD_CMD", None::<&str>),
|
||||||
|
("LESAVKA_CLIPBOARD_TIMEOUT_MS", None::<&str>),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
let mut state = LauncherState::new();
|
||||||
|
state.set_routing(InputRouting::Local);
|
||||||
|
state.set_view_mode(ViewMode::Unified);
|
||||||
|
state.select_camera(Some("/dev/video0".to_string()));
|
||||||
|
state.select_camera_quality(Some(devices::CameraMode::new(1920, 1080, 30)));
|
||||||
|
state.select_microphone(Some("alsa_input.test".to_string()));
|
||||||
|
state.select_speaker(Some("alsa_output.test".to_string()));
|
||||||
|
state.set_camera_channel_enabled(true);
|
||||||
|
state.set_microphone_channel_enabled(true);
|
||||||
|
state.set_audio_channel_enabled(true);
|
||||||
|
state.select_keyboard(Some("/dev/input/event10".to_string()));
|
||||||
|
state.select_mouse(Some("/dev/input/event11".to_string()));
|
||||||
|
|
||||||
|
let envs = runtime_env_vars(&state);
|
||||||
|
assert_eq!(envs.get("LESAVKA_CAPTURE_REMOTE"), Some(&"0".to_string()));
|
||||||
|
assert_eq!(envs.get("LESAVKA_VIEW_MODE"), Some(&"unified".to_string()));
|
||||||
|
assert!(!envs.contains_key("LESAVKA_AUDIO_DISABLE"));
|
||||||
|
assert!(!envs.contains_key("LESAVKA_MIC_DISABLE"));
|
||||||
|
assert_eq!(
|
||||||
|
envs.get("LESAVKA_CLIPBOARD_DELAY_MS"),
|
||||||
|
Some(&"18".to_string())
|
||||||
|
);
|
||||||
|
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_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()));
|
||||||
|
assert_eq!(
|
||||||
|
envs.get("LESAVKA_CAM_H264_KBIT"),
|
||||||
|
Some(&"12000".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV),
|
||||||
|
Some(&DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
envs.get("LESAVKA_DISABLE_VIDEO_RENDER"),
|
||||||
|
Some(&"1".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
envs.get("LESAVKA_CAM_SOURCE"),
|
||||||
|
Some(&"/dev/video0".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
envs.get("LESAVKA_MIC_SOURCE"),
|
||||||
|
Some(&"alsa_input.test".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
envs.get("LESAVKA_AUDIO_SINK"),
|
||||||
|
Some(&"alsa_output.test".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
envs.get("LESAVKA_KEYBOARD_DEVICE"),
|
||||||
|
Some(&"/dev/input/event10".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
envs.get("LESAVKA_MOUSE_DEVICE"),
|
||||||
|
Some(&"/dev/input/event11".to_string())
|
||||||
|
);
|
||||||
|
assert!(!envs.contains_key("LESAVKA_PASTE_KEY_FILE"));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
fn runtime_env_vars_passes_through_clipboard_transport_env() {
|
fn runtime_env_vars_passes_through_clipboard_transport_env() {
|
||||||
temp_env::with_vars(
|
temp_env::with_vars(
|
||||||
[
|
[
|
||||||
@ -348,6 +467,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial]
|
||||||
fn runtime_env_vars_passes_through_remote_failsafe_launch_option() {
|
fn runtime_env_vars_passes_through_remote_failsafe_launch_option() {
|
||||||
temp_env::with_var(REMOTE_INPUT_FAILSAFE_SECONDS_ENV, Some("60"), || {
|
temp_env::with_var(REMOTE_INPUT_FAILSAFE_SECONDS_ENV, Some("60"), || {
|
||||||
let state = LauncherState::new();
|
let state = LauncherState::new();
|
||||||
@ -360,6 +480,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial]
|
||||||
fn runtime_env_vars_keeps_remote_failsafe_disabled_for_invalid_launch_option() {
|
fn runtime_env_vars_keeps_remote_failsafe_disabled_for_invalid_launch_option() {
|
||||||
temp_env::with_var(REMOTE_INPUT_FAILSAFE_SECONDS_ENV, Some("later"), || {
|
temp_env::with_var(REMOTE_INPUT_FAILSAFE_SECONDS_ENV, Some("later"), || {
|
||||||
let state = LauncherState::new();
|
let state = LauncherState::new();
|
||||||
@ -488,10 +609,29 @@ mod tests {
|
|||||||
let stat = "1234 (lesavka client) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 424242 21";
|
let stat = "1234 (lesavka client) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 424242 21";
|
||||||
|
|
||||||
assert_eq!(proc_stat_start_ticks(stat).as_deref(), Some("424242"));
|
assert_eq!(proc_stat_start_ticks(stat).as_deref(), Some("424242"));
|
||||||
|
assert_eq!(proc_stat_start_ticks("missing-parens"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn launcher_parent_start_ticks_is_available_for_current_process() {
|
fn launcher_parent_start_ticks_is_available_for_current_process() {
|
||||||
assert!(launcher_parent_start_ticks().is_some());
|
assert!(launcher_parent_start_ticks().is_some());
|
||||||
|
|
||||||
|
let parent = LauncherParentProcess {
|
||||||
|
pid: std::process::id(),
|
||||||
|
start_ticks: launcher_parent_start_ticks(),
|
||||||
|
};
|
||||||
|
assert!(launcher_parent_process_matches(&parent));
|
||||||
|
|
||||||
|
let mismatched = LauncherParentProcess {
|
||||||
|
pid: std::process::id(),
|
||||||
|
start_ticks: Some("definitely-not-current".to_string()),
|
||||||
|
};
|
||||||
|
assert!(!launcher_parent_process_matches(&mismatched));
|
||||||
|
|
||||||
|
let missing = LauncherParentProcess {
|
||||||
|
pid: u32::MAX,
|
||||||
|
start_ticks: None,
|
||||||
|
};
|
||||||
|
assert!(!launcher_parent_process_matches(&missing));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1391,25 +1391,51 @@ fn log_preview_status(
|
|||||||
}
|
}
|
||||||
let eye = preview_eye_label(monitor_id);
|
let eye = preview_eye_label(monitor_id);
|
||||||
let message = match status {
|
let message = match status {
|
||||||
"Waking relay preview..." => format!("🪄 {eye} eye is waking the preview spell."),
|
"Waking relay preview..." => {
|
||||||
"Connecting relay preview..." => format!("🛰️ dialing the {eye} eye feed."),
|
format!(
|
||||||
|
"🪄 waking {eye} eye preview - «У лукоморья дуб зелёный; златая цепь на дубе том…»"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"Connecting relay preview..." => {
|
||||||
|
format!(
|
||||||
|
"🛰️ connecting {eye} eye feed - «Там чудеса: там леший бродит, русалка на ветвях сидит…»"
|
||||||
|
)
|
||||||
|
}
|
||||||
"Waiting for stream..." => {
|
"Waiting for stream..." => {
|
||||||
format!("👀 {eye} eye is connected and waiting for the first frame.")
|
format!(
|
||||||
|
"👁️ {eye} eye connected; waiting for first frame - «Подымите мне веки: не вижу!»"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"Preview stream ended." => {
|
||||||
|
format!("🌙 {eye} eye stream ended - «Там лес и дол видений полны…»")
|
||||||
|
}
|
||||||
|
"Preview host is unavailable." => {
|
||||||
|
format!(
|
||||||
|
"💔 {eye} eye cannot reach preview host - «Там царь Кащей над златом чахнет; там русский дух… там Русью пахнет!»"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
"Preview stream ended." => format!("🌙 {eye} eye preview stream ended."),
|
|
||||||
"Preview host is unavailable." => format!("💔 {eye} eye cannot reach the preview host."),
|
|
||||||
"Preview address is unavailable." => {
|
"Preview address is unavailable." => {
|
||||||
format!("🧭 {eye} eye does not have a usable preview address yet.")
|
format!(
|
||||||
|
"🧭 {eye} eye has no preview address - «Идёт направо — песнь заводит, налево — сказку говорит…»"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"Preview address is invalid." => {
|
||||||
|
format!(
|
||||||
|
"🧭 {eye} eye got an invalid preview address - «Там на неведомых дорожках следы невиданных зверей…»"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
"Preview address is invalid." => format!("🧭 {eye} eye was given a bad preview address."),
|
|
||||||
"Waiting for capture pipeline..." => {
|
"Waiting for capture pipeline..." => {
|
||||||
format!("⏳ {eye} eye is waiting for the capture pipeline to wake up.")
|
format!(
|
||||||
|
"⏳ {eye} eye waiting for capture pipeline - «Избушка, избушка! Встань к лесу задом, ко мне передом.»"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
"Preview stream error. See session log." => {
|
"Preview stream error. See session log." => {
|
||||||
format!("💥 {eye} eye hit a preview stream error. See the log spellbook for detail.")
|
format!("💥 {eye} eye preview stream error - «Фу-фу! Русским духом пахнет!»")
|
||||||
}
|
}
|
||||||
"Preview RPC failed. See session log." => {
|
"Preview RPC failed. See session log." => {
|
||||||
format!("💥 {eye} eye preview RPC fizzled. See the log spellbook for detail.")
|
format!(
|
||||||
|
"💥 {eye} eye preview RPC failed - «Дела давно минувших дней, преданья старины глубокой…»"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
other => format!("🎥 {eye} eye: {other}"),
|
other => format!("🎥 {eye} eye: {other}"),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1100,6 +1100,72 @@ mod tests {
|
|||||||
assert_eq!(InputRouting::Remote.as_env(), "1");
|
assert_eq!(InputRouting::Remote.as_env(), "1");
|
||||||
assert_eq!(ViewMode::Unified.as_env(), "unified");
|
assert_eq!(ViewMode::Unified.as_env(), "unified");
|
||||||
assert_eq!(ViewMode::Breakout.as_env(), "breakout");
|
assert_eq!(ViewMode::Breakout.as_env(), "breakout");
|
||||||
|
assert_eq!(DisplaySurface::Preview.label(), "preview");
|
||||||
|
assert_eq!(DisplaySurface::Window.label(), "window");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preset_ids_labels_and_legacy_aliases_are_stable() {
|
||||||
|
for (preset, id, label) in [
|
||||||
|
(FeedSourcePreset::ThisEye, "self", "Left Eye"),
|
||||||
|
(FeedSourcePreset::OtherEye, "other", "Right Eye"),
|
||||||
|
(FeedSourcePreset::Off, "off", "Off"),
|
||||||
|
] {
|
||||||
|
assert_eq!(preset.as_id(), id);
|
||||||
|
assert_eq!(FeedSourcePreset::from_id(id), Some(preset));
|
||||||
|
assert_eq!(preset.label(0), label);
|
||||||
|
}
|
||||||
|
assert_eq!(FeedSourcePreset::ThisEye.label(1), "Right Eye");
|
||||||
|
assert_eq!(FeedSourcePreset::OtherEye.label(1), "Left Eye");
|
||||||
|
assert_eq!(FeedSourcePreset::ThisEye.label(9), "This Eye");
|
||||||
|
assert_eq!(FeedSourcePreset::from_id("bogus"), None);
|
||||||
|
|
||||||
|
for (preset, id, label) in [
|
||||||
|
(BreakoutSizePreset::P360, "360p", "360p"),
|
||||||
|
(BreakoutSizePreset::P540, "540p", "540p"),
|
||||||
|
(BreakoutSizePreset::P720, "720p", "720p"),
|
||||||
|
(BreakoutSizePreset::P900, "900p", "900p"),
|
||||||
|
(BreakoutSizePreset::P1080, "1080p", "1080p"),
|
||||||
|
(BreakoutSizePreset::P1440, "1440p", "1440p"),
|
||||||
|
(BreakoutSizePreset::Source, "source", "Source"),
|
||||||
|
(BreakoutSizePreset::FillDisplay, "fill", "Display"),
|
||||||
|
] {
|
||||||
|
assert_eq!(preset.as_id(), id);
|
||||||
|
assert_eq!(BreakoutSizePreset::from_id(id), Some(preset));
|
||||||
|
assert_eq!(preset.label(), label);
|
||||||
|
}
|
||||||
|
assert_eq!(BreakoutSizePreset::from_id("giant"), None);
|
||||||
|
|
||||||
|
for (preset, id, label) in [
|
||||||
|
(CaptureSizePreset::Vga, "vga", "VGA"),
|
||||||
|
(CaptureSizePreset::P480, "480p", "480p"),
|
||||||
|
(CaptureSizePreset::P576, "576p", "576p"),
|
||||||
|
(CaptureSizePreset::P720, "720p", "720p"),
|
||||||
|
(CaptureSizePreset::P1080, "1080p", "1080p"),
|
||||||
|
] {
|
||||||
|
assert_eq!(preset.as_id(), id);
|
||||||
|
assert_eq!(preset.label(), label);
|
||||||
|
assert_eq!(preset.transport_label(), "device H.264 pass-through");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
CaptureSizePreset::from_id("360p"),
|
||||||
|
Some(CaptureSizePreset::Vga)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
CaptureSizePreset::from_id("540p"),
|
||||||
|
Some(CaptureSizePreset::P480)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
CaptureSizePreset::from_id("900p"),
|
||||||
|
Some(CaptureSizePreset::P1080)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
CaptureSizePreset::from_id("source"),
|
||||||
|
Some(CaptureSizePreset::P1080)
|
||||||
|
);
|
||||||
|
assert_eq!(CaptureSizePreset::from_id("unknown"), None);
|
||||||
|
assert_eq!(CaptureSizePreset::P720.display_size(), (1280, 720));
|
||||||
|
assert!(CaptureSizePreset::P1080.display_aspect_ratio() > 1.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1147,6 +1213,10 @@ mod tests {
|
|||||||
state.set_view_mode(ViewMode::Breakout);
|
state.set_view_mode(ViewMode::Breakout);
|
||||||
assert_eq!(state.display_surface(0), DisplaySurface::Window);
|
assert_eq!(state.display_surface(0), DisplaySurface::Window);
|
||||||
assert_eq!(state.display_surface(1), DisplaySurface::Window);
|
assert_eq!(state.display_surface(1), DisplaySurface::Window);
|
||||||
|
|
||||||
|
state.set_display_surface(9, DisplaySurface::Window);
|
||||||
|
assert_eq!(state.display_surface(9), DisplaySurface::Preview);
|
||||||
|
assert_eq!(state.breakout_count(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1170,6 +1240,22 @@ mod tests {
|
|||||||
state.set_feed_source_preset(0, FeedSourcePreset::Off);
|
state.set_feed_source_preset(0, FeedSourcePreset::Off);
|
||||||
assert_eq!(state.resolved_feed_monitor_id(0), None);
|
assert_eq!(state.resolved_feed_monitor_id(0), None);
|
||||||
assert!(state.display_capture_size_choice(0).is_none());
|
assert!(state.display_capture_size_choice(0).is_none());
|
||||||
|
|
||||||
|
state.set_feed_source_preset(9, FeedSourcePreset::Off);
|
||||||
|
assert_eq!(state.feed_source_preset(9), FeedSourcePreset::ThisEye);
|
||||||
|
let labels: Vec<_> = state
|
||||||
|
.feed_source_options(1)
|
||||||
|
.into_iter()
|
||||||
|
.map(|choice| (choice.preset, choice.label))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
labels,
|
||||||
|
vec![
|
||||||
|
(FeedSourcePreset::ThisEye, "Right Eye"),
|
||||||
|
(FeedSourcePreset::OtherEye, "Left Eye"),
|
||||||
|
(FeedSourcePreset::Off, "Off"),
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1187,6 +1273,42 @@ mod tests {
|
|||||||
assert_eq!(mirrored_breakout.preset, BreakoutSizePreset::Source);
|
assert_eq!(mirrored_breakout.preset, BreakoutSizePreset::Source);
|
||||||
assert_eq!(mirrored_breakout.width, 1280);
|
assert_eq!(mirrored_breakout.width, 1280);
|
||||||
assert_eq!(mirrored_breakout.height, 720);
|
assert_eq!(mirrored_breakout.height, 720);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
state.display_capture_size_preset(0),
|
||||||
|
Some(CaptureSizePreset::P720)
|
||||||
|
);
|
||||||
|
assert_eq!(state.display_capture_fps(0), Some(60));
|
||||||
|
assert_eq!(state.display_capture_bitrate_kbit(0), Some(12_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_and_out_of_range_profile_updates_are_safe_noops() {
|
||||||
|
let mut state = LauncherState::new();
|
||||||
|
let original_source = state.preview_source_size();
|
||||||
|
state.set_preview_source_profile(0, 1080, 0);
|
||||||
|
assert_eq!(state.preview_source_size(), original_source);
|
||||||
|
|
||||||
|
let original_limit = state.breakout_limit_size();
|
||||||
|
state.set_breakout_limit_size(1920, 0);
|
||||||
|
assert_eq!(state.breakout_limit_size(), original_limit);
|
||||||
|
|
||||||
|
let original_display = state.breakout_display_size();
|
||||||
|
state.set_breakout_display_size(0, 1080);
|
||||||
|
assert_eq!(state.breakout_display_size(), original_display);
|
||||||
|
|
||||||
|
state.set_breakout_display_size(3440, 1440);
|
||||||
|
assert_eq!(state.breakout_display_size().width, 3440);
|
||||||
|
assert_eq!(state.breakout_display_size().height, 1440);
|
||||||
|
|
||||||
|
state.set_capture_size_preset(9, CaptureSizePreset::P720);
|
||||||
|
assert_eq!(state.capture_size_preset(9), CaptureSizePreset::P1080);
|
||||||
|
state.set_capture_fps(9, 0);
|
||||||
|
assert_eq!(state.capture_fps(9), default_eye_source_mode().fps);
|
||||||
|
state.set_capture_bitrate_kbit(9, 1);
|
||||||
|
assert!(state.capture_bitrate_kbit(9) >= 18_000);
|
||||||
|
state.set_breakout_size_preset(9, BreakoutSizePreset::P360);
|
||||||
|
assert_eq!(state.breakout_size_preset(9), BreakoutSizePreset::Source);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -1675,7 +1675,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
};
|
};
|
||||||
widgets
|
widgets
|
||||||
.status_label
|
.status_label
|
||||||
.set_text("Reading the local clipboard and packing a remote paste spell...");
|
.set_text("Reading the local clipboard and preparing remote paste...");
|
||||||
let clipboard = display.clipboard();
|
let clipboard = display.clipboard();
|
||||||
let clipboard_tx = clipboard_tx.clone();
|
let clipboard_tx = clipboard_tx.clone();
|
||||||
clipboard.read_text_async(None::<>k::gio::Cancellable>, move |result| {
|
clipboard.read_text_async(None::<>k::gio::Cancellable>, move |result| {
|
||||||
|
|||||||
@ -239,7 +239,7 @@ pub fn build_launcher_view(
|
|||||||
brand_row.set_valign(gtk::Align::Center);
|
brand_row.set_valign(gtk::Align::Center);
|
||||||
let brand_icon = gtk::Image::from_icon_name(LESAVKA_ICON_NAME);
|
let brand_icon = gtk::Image::from_icon_name(LESAVKA_ICON_NAME);
|
||||||
brand_icon.add_css_class("app-logo");
|
brand_icon.add_css_class("app-logo");
|
||||||
brand_icon.set_pixel_size(28);
|
brand_icon.set_pixel_size(44);
|
||||||
brand_icon.set_valign(gtk::Align::Center);
|
brand_icon.set_valign(gtk::Align::Center);
|
||||||
let heading = gtk::Label::new(Some("Lesavka"));
|
let heading = gtk::Label::new(Some("Lesavka"));
|
||||||
heading.add_css_class("title-2");
|
heading.add_css_class("title-2");
|
||||||
|
|||||||
@ -42,24 +42,12 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
|
|||||||
let relay_live = child_running || state.remote_active;
|
let relay_live = child_running || state.remote_active;
|
||||||
set_status_light(
|
set_status_light(
|
||||||
&widgets.summary.relay_light,
|
&widgets.summary.relay_light,
|
||||||
StatusLightState::from_active(state.server_available),
|
server_light_state(state, relay_live),
|
||||||
);
|
|
||||||
widgets.summary.relay_value.set_text(
|
|
||||||
state
|
|
||||||
.server_version
|
|
||||||
.as_deref()
|
|
||||||
.map(|version| version.trim())
|
|
||||||
.filter(|version| !version.is_empty())
|
|
||||||
.map(|version| {
|
|
||||||
if version.starts_with('v') {
|
|
||||||
version.to_string()
|
|
||||||
} else {
|
|
||||||
format!("v{version}")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or(""),
|
|
||||||
);
|
);
|
||||||
|
widgets
|
||||||
|
.summary
|
||||||
|
.relay_value
|
||||||
|
.set_text(&server_version_label(state));
|
||||||
set_status_light(
|
set_status_light(
|
||||||
&widgets.summary.routing_light,
|
&widgets.summary.routing_light,
|
||||||
StatusLightState::from_active(matches!(state.routing, InputRouting::Remote)),
|
StatusLightState::from_active(matches!(state.routing, InputRouting::Remote)),
|
||||||
@ -597,6 +585,32 @@ impl StatusLightState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn server_light_state(state: &LauncherState, relay_live: bool) -> StatusLightState {
|
||||||
|
if relay_live {
|
||||||
|
StatusLightState::Live
|
||||||
|
} else if state.server_available {
|
||||||
|
StatusLightState::Caution
|
||||||
|
} else {
|
||||||
|
StatusLightState::Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_version_label(state: &LauncherState) -> String {
|
||||||
|
if !state.server_available {
|
||||||
|
return "-".to_string();
|
||||||
|
}
|
||||||
|
let version = state
|
||||||
|
.server_version
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|version| !version.is_empty());
|
||||||
|
match version {
|
||||||
|
Some(version) if version.starts_with('v') => version.to_string(),
|
||||||
|
Some(version) => format!("v{version}"),
|
||||||
|
None => "-".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn gpio_light_state(power: &CapturePowerStatus) -> StatusLightState {
|
fn gpio_light_state(power: &CapturePowerStatus) -> StatusLightState {
|
||||||
if !power.available || !power.enabled {
|
if !power.available || !power.enabled {
|
||||||
return StatusLightState::Idle;
|
return StatusLightState::Idle;
|
||||||
@ -1647,6 +1661,26 @@ mod tests {
|
|||||||
assert_eq!(gpio_power_label(&power), "2 Eyes");
|
assert_eq!(gpio_power_label(&power), "2 Eyes");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_chip_state_tracks_connection_not_just_reachability() {
|
||||||
|
let mut state = LauncherState::new();
|
||||||
|
assert_eq!(server_light_state(&state, false), StatusLightState::Idle);
|
||||||
|
assert_eq!(server_version_label(&state), "-");
|
||||||
|
|
||||||
|
state.set_server_available(true);
|
||||||
|
state.set_server_version(Some("0.12.3".to_string()));
|
||||||
|
assert_eq!(server_light_state(&state, false), StatusLightState::Caution);
|
||||||
|
assert_eq!(server_version_label(&state), "v0.12.3");
|
||||||
|
|
||||||
|
assert_eq!(server_light_state(&state, true), StatusLightState::Live);
|
||||||
|
|
||||||
|
state.set_server_version(Some("v0.12.4".to_string()));
|
||||||
|
assert_eq!(server_version_label(&state), "v0.12.4");
|
||||||
|
|
||||||
|
state.set_server_version(Some(" ".to_string()));
|
||||||
|
assert_eq!(server_version_label(&state), "-");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn capture_power_detail_mentions_detected_eyes_when_powered() {
|
fn capture_power_detail_mentions_detected_eyes_when_powered() {
|
||||||
let mut power = CapturePowerStatus::default();
|
let mut power = CapturePowerStatus::default();
|
||||||
|
|||||||
@ -186,6 +186,7 @@ fn format_audio_gain_for_gst(gain: f64) -> String {
|
|||||||
format!("{:.3}", clamp_audio_gain(gain))
|
format!("{:.3}", clamp_audio_gain(gain))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
fn maybe_spawn_audio_gain_control(volume: gst::Element) {
|
fn maybe_spawn_audio_gain_control(volume: gst::Element) {
|
||||||
let Ok(path) = std::env::var(AUDIO_GAIN_CONTROL_ENV) else {
|
let Ok(path) = std::env::var(AUDIO_GAIN_CONTROL_ENV) else {
|
||||||
return;
|
return;
|
||||||
@ -194,18 +195,37 @@ fn maybe_spawn_audio_gain_control(volume: gst::Element) {
|
|||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let mut last_gain = None;
|
let mut last_gain = None;
|
||||||
loop {
|
loop {
|
||||||
if let Some(gain) = read_audio_gain_control(&path)
|
apply_audio_gain_control_sample(&path, &volume, &mut last_gain);
|
||||||
&& last_gain != Some(gain)
|
|
||||||
{
|
|
||||||
volume.set_property("volume", gain);
|
|
||||||
last_gain = Some(gain);
|
|
||||||
info!("🔊 remote audio gain set to {gain:.2}x");
|
|
||||||
}
|
|
||||||
thread::sleep(Duration::from_millis(250));
|
thread::sleep(Duration::from_millis(250));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(coverage)]
|
||||||
|
fn maybe_spawn_audio_gain_control(volume: gst::Element) {
|
||||||
|
let Ok(path) = std::env::var(AUDIO_GAIN_CONTROL_ENV) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let path = PathBuf::from(path);
|
||||||
|
let mut last_gain = None;
|
||||||
|
apply_audio_gain_control_sample(&path, &volume, &mut last_gain);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_audio_gain_control_sample(
|
||||||
|
path: &StdPath,
|
||||||
|
volume: &gst::Element,
|
||||||
|
last_gain: &mut Option<f64>,
|
||||||
|
) -> Option<f64> {
|
||||||
|
let gain = read_audio_gain_control(path)?;
|
||||||
|
if *last_gain == Some(gain) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
volume.set_property("volume", gain);
|
||||||
|
*last_gain = Some(gain);
|
||||||
|
info!("🔊 remote audio gain set to {gain:.2}x");
|
||||||
|
Some(gain)
|
||||||
|
}
|
||||||
|
|
||||||
fn read_audio_gain_control(path: &StdPath) -> Option<f64> {
|
fn read_audio_gain_control(path: &StdPath) -> Option<f64> {
|
||||||
std_fs::read_to_string(path)
|
std_fs::read_to_string(path)
|
||||||
.ok()
|
.ok()
|
||||||
@ -221,6 +241,7 @@ fn live_audio_buffer(pkt: AudioPacket, timeline: &Mutex<AudioTimeline>) -> gst::
|
|||||||
timeline.last_remote_pts_us = Some(pkt.pts);
|
timeline.last_remote_pts_us = Some(pkt.pts);
|
||||||
timeline.packets = timeline.packets.saturating_add(1);
|
timeline.packets = timeline.packets.saturating_add(1);
|
||||||
if timeline.packets <= 8 || timeline.packets % 600 == 0 {
|
if timeline.packets <= 8 || timeline.packets % 600 == 0 {
|
||||||
|
#[cfg(not(coverage))]
|
||||||
debug!(
|
debug!(
|
||||||
packet = timeline.packets,
|
packet = timeline.packets,
|
||||||
remote_pts_us = pkt.pts,
|
remote_pts_us = pkt.pts,
|
||||||
|
|||||||
@ -41,8 +41,16 @@ pub fn pick_h264_decoder() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn buildable_decoder(name: &str) -> bool {
|
fn buildable_decoder(name: &str) -> bool {
|
||||||
|
#[cfg(coverage)]
|
||||||
|
if std::env::var("TEST_FAIL_GST_INIT").is_ok() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if gst::init().is_err() {
|
if gst::init().is_err() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
#[cfg(coverage)]
|
||||||
|
if std::env::var("TEST_DISABLE_H264_DECODER_FACTORY").is_ok() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
gst::ElementFactory::find(name).is_some() && gst::ElementFactory::make(name).build().is_ok()
|
gst::ElementFactory::find(name).is_some() && gst::ElementFactory::make(name).build().is_ok()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.12.2"
|
version = "0.12.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,15 @@ impl ProcessCpuSampler {
|
|||||||
pub fn sample_percent(&mut self) -> Option<f32> {
|
pub fn sample_percent(&mut self) -> Option<f32> {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let runtime_ns = read_process_runtime_ns()?;
|
let runtime_ns = read_process_runtime_ns()?;
|
||||||
|
self.sample_percent_at(now, runtime_ns)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sample_tenths_percent(&mut self) -> Option<u32> {
|
||||||
|
self.sample_percent()
|
||||||
|
.map(|pct| (pct * 10.0).clamp(0.0, u32::MAX as f32) as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_percent_at(&mut self, now: Instant, runtime_ns: u64) -> Option<f32> {
|
||||||
let previous = self.last.replace((now, runtime_ns))?;
|
let previous = self.last.replace((now, runtime_ns))?;
|
||||||
let elapsed_ns = now
|
let elapsed_ns = now
|
||||||
.saturating_duration_since(previous.0)
|
.saturating_duration_since(previous.0)
|
||||||
@ -34,11 +43,6 @@ impl ProcessCpuSampler {
|
|||||||
}
|
}
|
||||||
Some(runtime_ns.saturating_sub(previous.1) as f32 * 100.0 / elapsed_ns as f32)
|
Some(runtime_ns.saturating_sub(previous.1) as f32 * 100.0 / elapsed_ns as f32)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sample_tenths_percent(&mut self) -> Option<u32> {
|
|
||||||
self.sample_percent()
|
|
||||||
.map(|pct| (pct * 10.0).clamp(0.0, u32::MAX as f32) as u32)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_process_runtime_ns() -> Option<u64> {
|
fn read_process_runtime_ns() -> Option<u64> {
|
||||||
@ -47,18 +51,26 @@ fn read_process_runtime_ns() -> Option<u64> {
|
|||||||
|
|
||||||
fn read_process_runtime_from_stat() -> Option<u64> {
|
fn read_process_runtime_from_stat() -> Option<u64> {
|
||||||
let text = std::fs::read_to_string("/proc/self/stat").ok()?;
|
let text = std::fs::read_to_string("/proc/self/stat").ok()?;
|
||||||
|
let ticks_per_second = *clock_ticks_per_second()?;
|
||||||
|
parse_stat_runtime_ns(&text, ticks_per_second)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_stat_runtime_ns(text: &str, ticks_per_second: u64) -> Option<u64> {
|
||||||
let close = text.rfind(')')?;
|
let close = text.rfind(')')?;
|
||||||
let rest = text.get(close + 2..)?;
|
let rest = text.get(close + 2..)?;
|
||||||
let fields: Vec<&str> = rest.split_whitespace().collect();
|
let fields: Vec<&str> = rest.split_whitespace().collect();
|
||||||
let utime_ticks = fields.get(11)?.parse::<u64>().ok()?;
|
let utime_ticks = fields.get(11)?.parse::<u64>().ok()?;
|
||||||
let stime_ticks = fields.get(12)?.parse::<u64>().ok()?;
|
let stime_ticks = fields.get(12)?.parse::<u64>().ok()?;
|
||||||
let total_ticks = utime_ticks.saturating_add(stime_ticks);
|
let total_ticks = utime_ticks.saturating_add(stime_ticks);
|
||||||
let ticks_per_second = *clock_ticks_per_second()?;
|
|
||||||
Some(total_ticks.saturating_mul(1_000_000_000) / ticks_per_second.max(1))
|
Some(total_ticks.saturating_mul(1_000_000_000) / ticks_per_second.max(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_process_runtime_from_schedstat() -> Option<u64> {
|
fn read_process_runtime_from_schedstat() -> Option<u64> {
|
||||||
let text = std::fs::read_to_string("/proc/self/schedstat").ok()?;
|
let text = std::fs::read_to_string("/proc/self/schedstat").ok()?;
|
||||||
|
parse_schedstat_runtime_ns(&text)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_schedstat_runtime_ns(text: &str) -> Option<u64> {
|
||||||
text.split_whitespace().next()?.parse::<u64>().ok()
|
text.split_whitespace().next()?.parse::<u64>().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,6 +102,11 @@ mod tests {
|
|||||||
assert!(read_process_runtime_ns().is_some());
|
assert!(read_process_runtime_ns().is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schedstat_fallback_reader_is_non_panicking() {
|
||||||
|
let _ = read_process_runtime_from_schedstat();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clock_tick_lookup_reads() {
|
fn clock_tick_lookup_reads() {
|
||||||
assert!(clock_ticks_per_second().is_some());
|
assert!(clock_ticks_per_second().is_some());
|
||||||
@ -102,4 +119,51 @@ mod tests {
|
|||||||
thread::sleep(Duration::from_millis(10));
|
thread::sleep(Duration::from_millis(10));
|
||||||
let _ = sampler.sample_percent();
|
let _ = sampler.sample_percent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sampler_handles_manual_runtime_edges() {
|
||||||
|
let mut sampler = ProcessCpuSampler::new();
|
||||||
|
let now = Instant::now();
|
||||||
|
assert!(sampler.sample_percent_at(now, 1_000).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
sampler
|
||||||
|
.sample_percent_at(now + Duration::from_millis(10), 2_000)
|
||||||
|
.expect("percentage"),
|
||||||
|
0.01
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
sampler
|
||||||
|
.sample_percent_at(now + Duration::from_millis(20), 1_500)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
sampler
|
||||||
|
.sample_percent_at(now + Duration::from_millis(20), 1_500)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn procfs_parsers_handle_valid_and_malformed_samples() {
|
||||||
|
let stat = "123 (lesavka worker) S 1 2 3 4 5 6 7 8 9 10 11 12 13 21 17";
|
||||||
|
assert_eq!(parse_stat_runtime_ns(stat, 100), Some(230_000_000));
|
||||||
|
assert_eq!(parse_stat_runtime_ns("missing close paren", 100), None);
|
||||||
|
assert_eq!(parse_stat_runtime_ns("123 (name) S too short", 100), None);
|
||||||
|
assert_eq!(parse_schedstat_runtime_ns("12345 678 9"), Some(12345));
|
||||||
|
assert_eq!(parse_schedstat_runtime_ns("not-a-number 678"), None);
|
||||||
|
assert_eq!(parse_schedstat_runtime_ns(""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tenths_percent_is_clamped_to_integer_range() {
|
||||||
|
let mut sampler = ProcessCpuSampler::new();
|
||||||
|
let now = Instant::now();
|
||||||
|
assert!(sampler.sample_percent_at(now, 0).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
sampler
|
||||||
|
.sample_percent_at(now + Duration::from_millis(1), u64::MAX)
|
||||||
|
.map(|pct| (pct * 10.0).clamp(0.0, u32::MAX as f32) as u32),
|
||||||
|
Some(u32::MAX)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,6 @@
|
|||||||
This is the tracked inventory for `LESAVKA_*` knobs used by source, scripts, CI, and tests. The hygiene gate fails when a new variable is added without appearing here, which keeps operator-facing configuration from drifting into folklore.
|
This is the tracked inventory for `LESAVKA_*` knobs used by source, scripts, CI, and tests. The hygiene gate fails when a new variable is added without appearing here, which keeps operator-facing configuration from drifting into folklore.
|
||||||
|
|
||||||
Hardware-facing assumptions belong near the code that uses them; this file is the repo-wide index.
|
Hardware-facing assumptions belong near the code that uses them; this file is the repo-wide index.
|
||||||
|
|
||||||
| Variable | Notes |
|
|
||||||
| --- | --- |
|
|
||||||
| `LESAVKA_ALLOW_GADGET_CYCLE` | document near use before promoting to operator config |
|
| `LESAVKA_ALLOW_GADGET_CYCLE` | document near use before promoting to operator config |
|
||||||
| `LESAVKA_ALLOW_GADGET_RESET` | document near use before promoting to operator config |
|
| `LESAVKA_ALLOW_GADGET_RESET` | document near use before promoting to operator config |
|
||||||
| `LESAVKA_ALSA_DEV` | server hardware/device override |
|
| `LESAVKA_ALSA_DEV` | server hardware/device override |
|
||||||
@ -90,6 +87,7 @@ Hardware-facing assumptions belong near the code that uses them; this file is th
|
|||||||
| `LESAVKA_EYE_MIN_FPS` | eye preview/video transport override |
|
| `LESAVKA_EYE_MIN_FPS` | eye preview/video transport override |
|
||||||
| `LESAVKA_EYE_QUEUE_BUFFERS` | eye preview/video transport override |
|
| `LESAVKA_EYE_QUEUE_BUFFERS` | eye preview/video transport override |
|
||||||
| `LESAVKA_EYE_TESTSRC_KBIT` | eye preview/video transport override |
|
| `LESAVKA_EYE_TESTSRC_KBIT` | eye preview/video transport override |
|
||||||
|
| `LESAVKA_FAKE_SYSTEMCTL_MODE` | test/build contract variable; not runtime operator config |
|
||||||
| `LESAVKA_FOCUS_LAUNCHER_ON_LOCAL` | launcher UI/runtime override |
|
| `LESAVKA_FOCUS_LAUNCHER_ON_LOCAL` | launcher UI/runtime override |
|
||||||
| `LESAVKA_FORCE_CORE_REBUILD_WITHOUT_UDC` | document near use before promoting to operator config |
|
| `LESAVKA_FORCE_CORE_REBUILD_WITHOUT_UDC` | document near use before promoting to operator config |
|
||||||
| `LESAVKA_GADGET_CONFIGFS_ROOT` | server hardware/device override |
|
| `LESAVKA_GADGET_CONFIGFS_ROOT` | server hardware/device override |
|
||||||
@ -146,8 +144,8 @@ Hardware-facing assumptions belong near the code that uses them; this file is th
|
|||||||
| `LESAVKA_LAUNCHER_WINDOW_TITLE` | launcher UI/runtime override |
|
| `LESAVKA_LAUNCHER_WINDOW_TITLE` | launcher UI/runtime override |
|
||||||
| `LESAVKA_LIVE_KEYBOARD_REPORT_DELAY_MS` | input routing/clipboard override |
|
| `LESAVKA_LIVE_KEYBOARD_REPORT_DELAY_MS` | input routing/clipboard override |
|
||||||
| `LESAVKA_LIVE_MODIFIER_DELAY_MS` | input routing/clipboard override |
|
| `LESAVKA_LIVE_MODIFIER_DELAY_MS` | input routing/clipboard override |
|
||||||
| `LESAVKA_MEDIA_GATE_PUSHGATEWAY_JOB` | CI metrics destination override |
|
|
||||||
| `LESAVKA_MAX_SPEED` | document near use before promoting to operator config |
|
| `LESAVKA_MAX_SPEED` | document near use before promoting to operator config |
|
||||||
|
| `LESAVKA_MEDIA_GATE_PUSHGATEWAY_JOB` | CI metrics destination override |
|
||||||
| `LESAVKA_MIC_DISABLE` | client media capture/playback override |
|
| `LESAVKA_MIC_DISABLE` | client media capture/playback override |
|
||||||
| `LESAVKA_MIC_DISABLE_PIPEWIRE` | client media capture/playback override |
|
| `LESAVKA_MIC_DISABLE_PIPEWIRE` | client media capture/playback override |
|
||||||
| `LESAVKA_MIC_GAIN` | client media capture/playback override |
|
| `LESAVKA_MIC_GAIN` | client media capture/playback override |
|
||||||
@ -173,6 +171,7 @@ Hardware-facing assumptions belong near the code that uses them; this file is th
|
|||||||
| `LESAVKA_REPO_URL` | runtime/install/session override |
|
| `LESAVKA_REPO_URL` | runtime/install/session override |
|
||||||
| `LESAVKA_RGBA` | document near use before promoting to operator config |
|
| `LESAVKA_RGBA` | document near use before promoting to operator config |
|
||||||
| `LESAVKA_SERVER_ADDR` | runtime/install/session override |
|
| `LESAVKA_SERVER_ADDR` | runtime/install/session override |
|
||||||
|
| `LESAVKA_SERVER_BIND_ADDR` | server bind address override; defaults to `0.0.0.0:50051` |
|
||||||
| `LESAVKA_SERVER_GADGET_SRC` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_SERVER_GADGET_SRC` | test/build contract variable; not runtime operator config |
|
||||||
| `LESAVKA_SERVER_HOST` | manual probe override |
|
| `LESAVKA_SERVER_HOST` | manual probe override |
|
||||||
| `LESAVKA_SERVER_MAIN_SRC` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_SERVER_MAIN_SRC` | test/build contract variable; not runtime operator config |
|
||||||
@ -186,6 +185,8 @@ Hardware-facing assumptions belong near the code that uses them; this file is th
|
|||||||
| `LESAVKA_TEST_CAP_CAMERA` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_TEST_CAP_CAMERA` | test/build contract variable; not runtime operator config |
|
||||||
| `LESAVKA_TEST_CAP_MIC` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_TEST_CAP_MIC` | test/build contract variable; not runtime operator config |
|
||||||
| `LESAVKA_TEST_GATE_PUSHGATEWAY_JOB` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_TEST_GATE_PUSHGATEWAY_JOB` | test/build contract variable; not runtime operator config |
|
||||||
|
| `LESAVKA_TEST_RECOVERY_STATE` | test/build contract variable; not runtime operator config |
|
||||||
|
| `LESAVKA_TEST_RECOVERY_STATE_ERROR` | test/build contract variable; not runtime operator config |
|
||||||
| `LESAVKA_TEST_SKIP_APP` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_TEST_SKIP_APP` | test/build contract variable; not runtime operator config |
|
||||||
| `LESAVKA_TEST_SWAY_GET_OUTPUTS_EXIT` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_TEST_SWAY_GET_OUTPUTS_EXIT` | test/build contract variable; not runtime operator config |
|
||||||
| `LESAVKA_TEST_SWAY_LOG` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_TEST_SWAY_LOG` | test/build contract variable; not runtime operator config |
|
||||||
@ -194,7 +195,9 @@ Hardware-facing assumptions belong near the code that uses them; this file is th
|
|||||||
| `LESAVKA_TEST_U32` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_TEST_U32` | test/build contract variable; not runtime operator config |
|
||||||
| `LESAVKA_TEST_U32_OPT` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_TEST_U32_OPT` | test/build contract variable; not runtime operator config |
|
||||||
| `LESAVKA_TEST_U8` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_TEST_U8` | test/build contract variable; not runtime operator config |
|
||||||
|
| `LESAVKA_TEST_UDEV_CAPTURE_DEVICES` | test/build contract variable; not runtime operator config |
|
||||||
| `LESAVKA_TEST_USIZE` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_TEST_USIZE` | test/build contract variable; not runtime operator config |
|
||||||
|
| `LESAVKA_TEST_UVC_HELPER_RESTART_ERR` | test/build contract variable; not runtime operator config |
|
||||||
| `LESAVKA_TEST_VIDEO_SOURCE` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_TEST_VIDEO_SOURCE` | test/build contract variable; not runtime operator config |
|
||||||
| `LESAVKA_TOUCHPAD_SCALE` | input routing/clipboard override |
|
| `LESAVKA_TOUCHPAD_SCALE` | input routing/clipboard override |
|
||||||
| `LESAVKA_UAC_DEV` | server hardware/device override |
|
| `LESAVKA_UAC_DEV` | server hardware/device override |
|
||||||
|
|||||||
@ -21,6 +21,10 @@ The hygiene gate fails if generated output is committed, `Cargo.lock` is missing
|
|||||||
|
|
||||||
Manual probes live under `scripts/manual/`. They are useful field tools, but they are not CI dependencies unless converted into deterministic tests.
|
Manual probes live under `scripts/manual/`. They are useful field tools, but they are not CI dependencies unless converted into deterministic tests.
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
The coverage gate hard-fails when any tracked source file under a workspace `src/` tree falls below `95%` line coverage. The older strict-contract list still stays in place for the especially important media/device files, but it is now additive rather than the only hard bar.
|
||||||
|
|
||||||
## Media Reliability
|
## Media Reliability
|
||||||
|
|
||||||
`media_reliability` is not just a test alias. It protects the pieces that keep video moving without accumulating latency:
|
`media_reliability` is not just a test alias. It protects the pieces that keep video moving without accumulating latency:
|
||||||
|
|||||||
@ -11,9 +11,9 @@
|
|||||||
"loc": 132
|
"loc": 132
|
||||||
},
|
},
|
||||||
"client/src/bin/lesavka-relayctl.rs": {
|
"client/src/bin/lesavka-relayctl.rs": {
|
||||||
"clippy_warnings": 2,
|
"clippy_warnings": 3,
|
||||||
"doc_debt": 3,
|
"doc_debt": 6,
|
||||||
"loc": 140
|
"loc": 303
|
||||||
},
|
},
|
||||||
"client/src/handshake.rs": {
|
"client/src/handshake.rs": {
|
||||||
"clippy_warnings": 2,
|
"clippy_warnings": 2,
|
||||||
@ -72,13 +72,13 @@
|
|||||||
},
|
},
|
||||||
"client/src/launcher/diagnostics.rs": {
|
"client/src/launcher/diagnostics.rs": {
|
||||||
"clippy_warnings": 92,
|
"clippy_warnings": 92,
|
||||||
"doc_debt": 12,
|
"doc_debt": 16,
|
||||||
"loc": 1081
|
"loc": 1213
|
||||||
},
|
},
|
||||||
"client/src/launcher/mod.rs": {
|
"client/src/launcher/mod.rs": {
|
||||||
"clippy_warnings": 8,
|
"clippy_warnings": 8,
|
||||||
"doc_debt": 8,
|
"doc_debt": 12,
|
||||||
"loc": 497
|
"loc": 637
|
||||||
},
|
},
|
||||||
"client/src/launcher/power.rs": {
|
"client/src/launcher/power.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -88,12 +88,12 @@
|
|||||||
"client/src/launcher/preview.rs": {
|
"client/src/launcher/preview.rs": {
|
||||||
"clippy_warnings": 93,
|
"clippy_warnings": 93,
|
||||||
"doc_debt": 56,
|
"doc_debt": 56,
|
||||||
"loc": 2216
|
"loc": 2242
|
||||||
},
|
},
|
||||||
"client/src/launcher/state.rs": {
|
"client/src/launcher/state.rs": {
|
||||||
"clippy_warnings": 172,
|
"clippy_warnings": 172,
|
||||||
"doc_debt": 58,
|
"doc_debt": 60,
|
||||||
"loc": 1562
|
"loc": 1684
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui.rs": {
|
"client/src/launcher/ui.rs": {
|
||||||
"clippy_warnings": 64,
|
"clippy_warnings": 64,
|
||||||
@ -106,9 +106,9 @@
|
|||||||
"loc": 1599
|
"loc": 1599
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_runtime.rs": {
|
"client/src/launcher/ui_runtime.rs": {
|
||||||
"clippy_warnings": 74,
|
"clippy_warnings": 72,
|
||||||
"doc_debt": 44,
|
"doc_debt": 47,
|
||||||
"loc": 1923
|
"loc": 1957
|
||||||
},
|
},
|
||||||
"client/src/layout.rs": {
|
"client/src/layout.rs": {
|
||||||
"clippy_warnings": 6,
|
"clippy_warnings": 6,
|
||||||
@ -127,8 +127,8 @@
|
|||||||
},
|
},
|
||||||
"client/src/output/audio.rs": {
|
"client/src/output/audio.rs": {
|
||||||
"clippy_warnings": 11,
|
"clippy_warnings": 11,
|
||||||
"doc_debt": 12,
|
"doc_debt": 13,
|
||||||
"loc": 371
|
"loc": 392
|
||||||
},
|
},
|
||||||
"client/src/output/display.rs": {
|
"client/src/output/display.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -158,7 +158,7 @@
|
|||||||
"client/src/video_support.rs": {
|
"client/src/video_support.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 2,
|
"doc_debt": 2,
|
||||||
"loc": 48
|
"loc": 56
|
||||||
},
|
},
|
||||||
"common/src/bin/cli.rs": {
|
"common/src/bin/cli.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -191,9 +191,9 @@
|
|||||||
"loc": 132
|
"loc": 132
|
||||||
},
|
},
|
||||||
"common/src/process_metrics.rs": {
|
"common/src/process_metrics.rs": {
|
||||||
"clippy_warnings": 12,
|
"clippy_warnings": 15,
|
||||||
"doc_debt": 4,
|
"doc_debt": 5,
|
||||||
"loc": 105
|
"loc": 169
|
||||||
},
|
},
|
||||||
"server/src/audio.rs": {
|
"server/src/audio.rs": {
|
||||||
"clippy_warnings": 47,
|
"clippy_warnings": 47,
|
||||||
@ -242,8 +242,8 @@
|
|||||||
},
|
},
|
||||||
"server/src/main.rs": {
|
"server/src/main.rs": {
|
||||||
"clippy_warnings": 23,
|
"clippy_warnings": 23,
|
||||||
"doc_debt": 21,
|
"doc_debt": 23,
|
||||||
"loc": 983
|
"loc": 1024
|
||||||
},
|
},
|
||||||
"server/src/paste.rs": {
|
"server/src/paste.rs": {
|
||||||
"clippy_warnings": 8,
|
"clippy_warnings": 8,
|
||||||
@ -252,8 +252,8 @@
|
|||||||
},
|
},
|
||||||
"server/src/runtime_support.rs": {
|
"server/src/runtime_support.rs": {
|
||||||
"clippy_warnings": 22,
|
"clippy_warnings": 22,
|
||||||
"doc_debt": 20,
|
"doc_debt": 22,
|
||||||
"loc": 729
|
"loc": 827
|
||||||
},
|
},
|
||||||
"server/src/uvc_control/model.rs": {
|
"server/src/uvc_control/model.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
|
|||||||
@ -181,6 +181,11 @@ workspace_lines = float(coverage_totals['lines']['percent'])
|
|||||||
files_at_95 = sum(1 for item in files if item['line_percent'] >= 95.0)
|
files_at_95 = sum(1 for item in files if item['line_percent'] >= 95.0)
|
||||||
files_below_95 = len(files) - files_at_95
|
files_below_95 = len(files) - files_at_95
|
||||||
over_500 = sum(1 for item in files if item['loc'] > 500)
|
over_500 = sum(1 for item in files if item['loc'] > 500)
|
||||||
|
all_file_failures = [
|
||||||
|
f'{item["path"]}: requires >= {contract_min:.2f}% line coverage, found {item["line_percent"]:.2f}%'
|
||||||
|
for item in files
|
||||||
|
if item['line_percent'] + 0.01 < contract_min
|
||||||
|
]
|
||||||
|
|
||||||
def esc(value: str) -> str:
|
def esc(value: str) -> str:
|
||||||
return value.replace('\\', r'\\').replace('\n', r'\\n').replace('"', r'\"')
|
return value.replace('\\', r'\\').replace('\n', r'\\n').replace('"', r'\"')
|
||||||
@ -189,7 +194,7 @@ labels = f'suite="lesavka",branch="{esc(branch)}",commit="{esc(commit)}"'
|
|||||||
metrics = []
|
metrics = []
|
||||||
metrics.append('# HELP platform_quality_gate_runs_total Number of quality gate runs by result.')
|
metrics.append('# HELP platform_quality_gate_runs_total Number of quality gate runs by result.')
|
||||||
metrics.append('# TYPE platform_quality_gate_runs_total counter')
|
metrics.append('# TYPE platform_quality_gate_runs_total counter')
|
||||||
status_label = 'ok' if not regressions and not missing_from_baseline and not contract_failures else 'failed'
|
status_label = 'ok' if not regressions and not missing_from_baseline and not contract_failures and not all_file_failures else 'failed'
|
||||||
ok_value = 1 if status_label == 'ok' else 0
|
ok_value = 1 if status_label == 'ok' else 0
|
||||||
failed_value = 1 if status_label == 'failed' else 0
|
failed_value = 1 if status_label == 'failed' else 0
|
||||||
metrics.append(f'platform_quality_gate_runs_total{{{labels},status="{status_label}"}} 1')
|
metrics.append(f'platform_quality_gate_runs_total{{{labels},status="{status_label}"}} 1')
|
||||||
@ -277,6 +282,12 @@ if contract_files:
|
|||||||
else:
|
else:
|
||||||
lines.append(f'{path} | {current["loc"]} LOC | {current["line_percent"]:.2f}%')
|
lines.append(f'{path} | {current["loc"]} LOC | {current["line_percent"]:.2f}%')
|
||||||
|
|
||||||
|
if all_file_failures:
|
||||||
|
lines.append('')
|
||||||
|
lines.append(f'all-file coverage failures (< {contract_min:.2f}%)')
|
||||||
|
lines.append('-' * 86)
|
||||||
|
lines.extend(all_file_failures)
|
||||||
|
|
||||||
summary_path.write_text('\n'.join(lines) + '\n', encoding='utf-8')
|
summary_path.write_text('\n'.join(lines) + '\n', encoding='utf-8')
|
||||||
|
|
||||||
print(summary_path.read_text(encoding='utf-8'))
|
print(summary_path.read_text(encoding='utf-8'))
|
||||||
@ -284,11 +295,13 @@ print(summary_path.read_text(encoding='utf-8'))
|
|||||||
if missing_from_baseline:
|
if missing_from_baseline:
|
||||||
print('missing baseline entries:', ', '.join(missing_from_baseline), file=sys.stderr)
|
print('missing baseline entries:', ', '.join(missing_from_baseline), file=sys.stderr)
|
||||||
|
|
||||||
if regressions or missing_from_baseline or contract_failures:
|
if regressions or missing_from_baseline or contract_failures or all_file_failures:
|
||||||
for line in regressions:
|
for line in regressions:
|
||||||
print(line, file=sys.stderr)
|
print(line, file=sys.stderr)
|
||||||
for line in contract_failures:
|
for line in contract_failures:
|
||||||
print(line, file=sys.stderr)
|
print(line, file=sys.stderr)
|
||||||
|
for line in all_file_failures:
|
||||||
|
print(line, file=sys.stderr)
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
PY
|
PY
|
||||||
then
|
then
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"files": {
|
"files": {
|
||||||
"client/src/app.rs": {
|
"client/src/app.rs": {
|
||||||
"line_percent": 97.4,
|
"line_percent": 97.4026,
|
||||||
"loc": 816
|
"loc": 816
|
||||||
},
|
},
|
||||||
"client/src/app_support.rs": {
|
"client/src/app_support.rs": {
|
||||||
@ -9,23 +9,23 @@
|
|||||||
"loc": 132
|
"loc": 132
|
||||||
},
|
},
|
||||||
"client/src/bin/lesavka-relayctl.rs": {
|
"client/src/bin/lesavka-relayctl.rs": {
|
||||||
"line_percent": 25.24,
|
"line_percent": 97.351,
|
||||||
"loc": 140
|
"loc": 303
|
||||||
},
|
},
|
||||||
"client/src/handshake.rs": {
|
"client/src/handshake.rs": {
|
||||||
"line_percent": 57.39,
|
"line_percent": 97.3913,
|
||||||
"loc": 381
|
"loc": 381
|
||||||
},
|
},
|
||||||
"client/src/input/camera.rs": {
|
"client/src/input/camera.rs": {
|
||||||
"line_percent": 96.51,
|
"line_percent": 96.5147,
|
||||||
"loc": 717
|
"loc": 717
|
||||||
},
|
},
|
||||||
"client/src/input/inputs.rs": {
|
"client/src/input/inputs.rs": {
|
||||||
"line_percent": 96.39,
|
"line_percent": 96.3855,
|
||||||
"loc": 1166
|
"loc": 1166
|
||||||
},
|
},
|
||||||
"client/src/input/keyboard.rs": {
|
"client/src/input/keyboard.rs": {
|
||||||
"line_percent": 91.5,
|
"line_percent": 95.0,
|
||||||
"loc": 705
|
"loc": 705
|
||||||
},
|
},
|
||||||
"client/src/input/keymap.rs": {
|
"client/src/input/keymap.rs": {
|
||||||
@ -33,15 +33,15 @@
|
|||||||
"loc": 196
|
"loc": 196
|
||||||
},
|
},
|
||||||
"client/src/input/microphone.rs": {
|
"client/src/input/microphone.rs": {
|
||||||
"line_percent": 96.31,
|
"line_percent": 96.3115,
|
||||||
"loc": 398
|
"loc": 398
|
||||||
},
|
},
|
||||||
"client/src/input/mouse.rs": {
|
"client/src/input/mouse.rs": {
|
||||||
"line_percent": 97.32,
|
"line_percent": 97.3214,
|
||||||
"loc": 317
|
"loc": 317
|
||||||
},
|
},
|
||||||
"client/src/launcher/clipboard.rs": {
|
"client/src/launcher/clipboard.rs": {
|
||||||
"line_percent": 96.23,
|
"line_percent": 96.2264,
|
||||||
"loc": 178
|
"loc": 178
|
||||||
},
|
},
|
||||||
"client/src/launcher/devices.rs": {
|
"client/src/launcher/devices.rs": {
|
||||||
@ -49,52 +49,52 @@
|
|||||||
"loc": 564
|
"loc": 564
|
||||||
},
|
},
|
||||||
"client/src/launcher/diagnostics.rs": {
|
"client/src/launcher/diagnostics.rs": {
|
||||||
"line_percent": 84.98,
|
"line_percent": 98.9922,
|
||||||
"loc": 1081
|
"loc": 1213
|
||||||
},
|
},
|
||||||
"client/src/launcher/mod.rs": {
|
"client/src/launcher/mod.rs": {
|
||||||
"line_percent": 84.85,
|
"line_percent": 100.0,
|
||||||
"loc": 497
|
"loc": 637
|
||||||
},
|
},
|
||||||
"client/src/launcher/state.rs": {
|
"client/src/launcher/state.rs": {
|
||||||
"line_percent": 86.04,
|
"line_percent": 97.0468,
|
||||||
"loc": 1562
|
"loc": 1684
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui.rs": {
|
"client/src/launcher/ui.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 2650
|
"loc": 2650
|
||||||
},
|
},
|
||||||
"client/src/layout.rs": {
|
"client/src/layout.rs": {
|
||||||
"line_percent": 97.73,
|
"line_percent": 97.7273,
|
||||||
"loc": 78
|
"loc": 78
|
||||||
},
|
},
|
||||||
"client/src/main.rs": {
|
"client/src/main.rs": {
|
||||||
"line_percent": 97.2,
|
"line_percent": 97.1963,
|
||||||
"loc": 100
|
"loc": 100
|
||||||
},
|
},
|
||||||
"client/src/output/audio.rs": {
|
"client/src/output/audio.rs": {
|
||||||
"line_percent": 89.42,
|
"line_percent": 98.1735,
|
||||||
"loc": 371
|
"loc": 392
|
||||||
},
|
},
|
||||||
"client/src/output/display.rs": {
|
"client/src/output/display.rs": {
|
||||||
"line_percent": 97.62,
|
"line_percent": 97.619,
|
||||||
"loc": 81
|
"loc": 81
|
||||||
},
|
},
|
||||||
"client/src/output/layout.rs": {
|
"client/src/output/layout.rs": {
|
||||||
"line_percent": 98.98,
|
"line_percent": 98.9796,
|
||||||
"loc": 155
|
"loc": 155
|
||||||
},
|
},
|
||||||
"client/src/output/video.rs": {
|
"client/src/output/video.rs": {
|
||||||
"line_percent": 95.52,
|
"line_percent": 95.5224,
|
||||||
"loc": 585
|
"loc": 585
|
||||||
},
|
},
|
||||||
"client/src/paste.rs": {
|
"client/src/paste.rs": {
|
||||||
"line_percent": 98.28,
|
"line_percent": 98.2759,
|
||||||
"loc": 82
|
"loc": 82
|
||||||
},
|
},
|
||||||
"client/src/video_support.rs": {
|
"client/src/video_support.rs": {
|
||||||
"line_percent": 83.87,
|
"line_percent": 97.2973,
|
||||||
"loc": 48
|
"loc": 56
|
||||||
},
|
},
|
||||||
"common/src/bin/cli.rs": {
|
"common/src/bin/cli.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
@ -117,23 +117,23 @@
|
|||||||
"loc": 24
|
"loc": 24
|
||||||
},
|
},
|
||||||
"common/src/paste.rs": {
|
"common/src/paste.rs": {
|
||||||
"line_percent": 97.06,
|
"line_percent": 97.0588,
|
||||||
"loc": 132
|
"loc": 132
|
||||||
},
|
},
|
||||||
"common/src/process_metrics.rs": {
|
"common/src/process_metrics.rs": {
|
||||||
"line_percent": 89.55,
|
"line_percent": 98.2609,
|
||||||
"loc": 105
|
"loc": 169
|
||||||
},
|
},
|
||||||
"server/src/audio.rs": {
|
"server/src/audio.rs": {
|
||||||
"line_percent": 96.88,
|
"line_percent": 96.875,
|
||||||
"loc": 737
|
"loc": 737
|
||||||
},
|
},
|
||||||
"server/src/bin/lesavka-uvc.rs": {
|
"server/src/bin/lesavka-uvc.rs": {
|
||||||
"line_percent": 95.92,
|
"line_percent": 95.9184,
|
||||||
"loc": 712
|
"loc": 712
|
||||||
},
|
},
|
||||||
"server/src/camera.rs": {
|
"server/src/camera.rs": {
|
||||||
"line_percent": 96.6,
|
"line_percent": 96.6038,
|
||||||
"loc": 623
|
"loc": 623
|
||||||
},
|
},
|
||||||
"server/src/camera_runtime.rs": {
|
"server/src/camera_runtime.rs": {
|
||||||
@ -145,7 +145,7 @@
|
|||||||
"loc": 537
|
"loc": 537
|
||||||
},
|
},
|
||||||
"server/src/gadget.rs": {
|
"server/src/gadget.rs": {
|
||||||
"line_percent": 91.12,
|
"line_percent": 97.2973,
|
||||||
"loc": 513
|
"loc": 513
|
||||||
},
|
},
|
||||||
"server/src/handshake.rs": {
|
"server/src/handshake.rs": {
|
||||||
@ -153,31 +153,31 @@
|
|||||||
"loc": 45
|
"loc": 45
|
||||||
},
|
},
|
||||||
"server/src/main.rs": {
|
"server/src/main.rs": {
|
||||||
"line_percent": 79.34,
|
"line_percent": 95.2055,
|
||||||
"loc": 983
|
"loc": 1024
|
||||||
},
|
},
|
||||||
"server/src/paste.rs": {
|
"server/src/paste.rs": {
|
||||||
"line_percent": 96.32,
|
"line_percent": 96.3158,
|
||||||
"loc": 260
|
"loc": 260
|
||||||
},
|
},
|
||||||
"server/src/runtime_support.rs": {
|
"server/src/runtime_support.rs": {
|
||||||
"line_percent": 90.99,
|
"line_percent": 96.134,
|
||||||
"loc": 729
|
"loc": 827
|
||||||
},
|
},
|
||||||
"server/src/uvc_runtime.rs": {
|
"server/src/uvc_runtime.rs": {
|
||||||
"line_percent": 97.14,
|
"line_percent": 97.1429,
|
||||||
"loc": 241
|
"loc": 241
|
||||||
},
|
},
|
||||||
"server/src/video.rs": {
|
"server/src/video.rs": {
|
||||||
"line_percent": 96.6,
|
"line_percent": 96.5986,
|
||||||
"loc": 844
|
"loc": 844
|
||||||
},
|
},
|
||||||
"server/src/video_sinks.rs": {
|
"server/src/video_sinks.rs": {
|
||||||
"line_percent": 95.8,
|
"line_percent": 95.7983,
|
||||||
"loc": 679
|
"loc": 679
|
||||||
},
|
},
|
||||||
"server/src/video_support.rs": {
|
"server/src/video_support.rs": {
|
||||||
"line_percent": 97.62,
|
"line_percent": 97.619,
|
||||||
"loc": 236
|
"loc": 236
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.12.2"
|
version = "0.12.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ use anyhow::Context;
|
|||||||
use futures_util::{Stream, StreamExt};
|
use futures_util::{Stream, StreamExt};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use std::net::SocketAddr;
|
||||||
use std::os::unix::fs::FileTypeExt;
|
use std::os::unix::fs::FileTypeExt;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
@ -49,6 +50,15 @@ fn live_keyboard_report_delay() -> Duration {
|
|||||||
.unwrap_or_else(|| Duration::from_millis(8))
|
.unwrap_or_else(|| Duration::from_millis(8))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn server_bind_addr() -> anyhow::Result<SocketAddr> {
|
||||||
|
// LESAVKA_SERVER_BIND_ADDR lets tests and constrained lab benches avoid
|
||||||
|
// colliding with another live relay while keeping the field default stable.
|
||||||
|
let raw =
|
||||||
|
std::env::var("LESAVKA_SERVER_BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:50051".to_string());
|
||||||
|
raw.parse::<SocketAddr>()
|
||||||
|
.with_context(|| format!("parsing LESAVKA_SERVER_BIND_ADDR={raw:?}"))
|
||||||
|
}
|
||||||
|
|
||||||
/*──────────────── Handler ───────────────────*/
|
/*──────────────── Handler ───────────────────*/
|
||||||
struct Handler {
|
struct Handler {
|
||||||
kb: Arc<Mutex<Option<tokio::fs::File>>>,
|
kb: Arc<Mutex<Option<tokio::fs::File>>>,
|
||||||
@ -146,6 +156,7 @@ impl Handler {
|
|||||||
.count() as u32
|
.count() as u32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
fn detected_capture_devices_from_udev() -> u32 {
|
fn detected_capture_devices_from_udev() -> u32 {
|
||||||
let Ok(mut enumerator) = udev::Enumerator::new() else {
|
let Ok(mut enumerator) = udev::Enumerator::new() else {
|
||||||
return 0;
|
return 0;
|
||||||
@ -173,6 +184,15 @@ impl Handler {
|
|||||||
.min(2) as u32
|
.min(2) as u32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(coverage)]
|
||||||
|
fn detected_capture_devices_from_udev() -> u32 {
|
||||||
|
std::env::var("LESAVKA_TEST_UDEV_CAPTURE_DEVICES")
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.parse::<u32>().ok())
|
||||||
|
.unwrap_or(0)
|
||||||
|
.min(2)
|
||||||
|
}
|
||||||
|
|
||||||
async fn active_eye_source_count(&self) -> u32 {
|
async fn active_eye_source_count(&self) -> u32 {
|
||||||
self.eye_hubs
|
self.eye_hubs
|
||||||
.lock()
|
.lock()
|
||||||
@ -206,10 +226,10 @@ impl Handler {
|
|||||||
if source_id > 1 {
|
if source_id > 1 {
|
||||||
return Err(Status::invalid_argument("source id must be 0 or 1"));
|
return Err(Status::invalid_argument("source id must be 0 or 1"));
|
||||||
}
|
}
|
||||||
let dev = match source_id {
|
let dev = if source_id == 0 {
|
||||||
0 => "/dev/lesavka_l_eye",
|
"/dev/lesavka_l_eye"
|
||||||
1 => "/dev/lesavka_r_eye",
|
} else {
|
||||||
_ => return Err(Status::invalid_argument("source id must be 0 or 1")),
|
"/dev/lesavka_r_eye"
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
@ -282,6 +302,7 @@ impl Handler {
|
|||||||
}
|
}
|
||||||
take_conflicting_eye_hubs(&mut hubs, key)
|
take_conflicting_eye_hubs(&mut hubs, key)
|
||||||
};
|
};
|
||||||
|
#[cfg(not(coverage))]
|
||||||
if !stale_hubs.is_empty() {
|
if !stale_hubs.is_empty() {
|
||||||
info!(
|
info!(
|
||||||
source_id = key.source_id,
|
source_id = key.source_id,
|
||||||
@ -310,6 +331,7 @@ impl Handler {
|
|||||||
|
|
||||||
let hub = EyeHub::spawn(stream, lease);
|
let hub = EyeHub::spawn(stream, lease);
|
||||||
let mut hubs = self.eye_hubs.lock().await;
|
let mut hubs = self.eye_hubs.lock().await;
|
||||||
|
#[cfg(not(coverage))]
|
||||||
if let Some(existing) = hubs.get(&key)
|
if let Some(existing) = hubs.get(&key)
|
||||||
&& existing.running.load(Ordering::Relaxed)
|
&& existing.running.load(Ordering::Relaxed)
|
||||||
{
|
{
|
||||||
@ -359,7 +381,7 @@ impl Handler {
|
|||||||
error!("💥 restart UVC helper failed: {e:#}");
|
error!("💥 restart UVC helper failed: {e:#}");
|
||||||
return Err(Status::internal(e.to_string()));
|
return Err(Status::internal(e.to_string()));
|
||||||
}
|
}
|
||||||
match UsbGadget::current_controller_state() {
|
match current_controller_state_after_recovery() {
|
||||||
Ok((ctrl, state)) if UsbGadget::host_enumerated_state(&state) => {
|
Ok((ctrl, state)) if UsbGadget::host_enumerated_state(&state) => {
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
info!(
|
info!(
|
||||||
@ -429,7 +451,24 @@ impl Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn current_controller_state_after_recovery() -> anyhow::Result<(String, String)> {
|
||||||
|
#[cfg(coverage)]
|
||||||
|
{
|
||||||
|
if std::env::var("LESAVKA_TEST_RECOVERY_STATE_ERROR").is_ok() {
|
||||||
|
anyhow::bail!("forced recovery state read failure");
|
||||||
|
}
|
||||||
|
if let Ok(state) = std::env::var("LESAVKA_TEST_RECOVERY_STATE") {
|
||||||
|
return Ok(("coverage-ctrl".to_string(), state));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UsbGadget::current_controller_state()
|
||||||
|
}
|
||||||
|
|
||||||
fn restart_uvc_helper() -> anyhow::Result<()> {
|
fn restart_uvc_helper() -> anyhow::Result<()> {
|
||||||
|
#[cfg(coverage)]
|
||||||
|
if let Ok(message) = std::env::var("LESAVKA_TEST_UVC_HELPER_RESTART_ERR") {
|
||||||
|
anyhow::bail!("{message}");
|
||||||
|
}
|
||||||
if std::env::var("LESAVKA_GADGET_SYSFS_ROOT").is_ok()
|
if std::env::var("LESAVKA_GADGET_SYSFS_ROOT").is_ok()
|
||||||
|| std::env::var("LESAVKA_GADGET_CONFIGFS_ROOT").is_ok()
|
|| std::env::var("LESAVKA_GADGET_CONFIGFS_ROOT").is_ok()
|
||||||
{
|
{
|
||||||
@ -823,6 +862,7 @@ impl Relay for Handler {
|
|||||||
let _ = runtime_support::write_hid_report(&kb, &hid_endpoint(0), &pkt.data).await;
|
let _ = runtime_support::write_hid_report(&kb, &hid_endpoint(0), &pkt.data).await;
|
||||||
tx.send(Ok(pkt)).await.ok();
|
tx.send(Ok(pkt)).await.ok();
|
||||||
if !report_delay.is_zero() {
|
if !report_delay.is_zero() {
|
||||||
|
#[cfg(not(coverage))]
|
||||||
tokio::time::sleep(report_delay).await;
|
tokio::time::sleep(report_delay).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -962,14 +1002,15 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
let handler = Handler::new(gadget.clone()).await?;
|
let handler = Handler::new(gadget.clone()).await?;
|
||||||
|
|
||||||
info!("🌐 lesavka-server listening on 0.0.0.0:50051");
|
let bind_addr = server_bind_addr()?;
|
||||||
|
info!("🌐 lesavka-server listening on {bind_addr}");
|
||||||
Server::builder()
|
Server::builder()
|
||||||
.tcp_nodelay(true)
|
.tcp_nodelay(true)
|
||||||
.max_frame_size(Some(2 * 1024 * 1024))
|
.max_frame_size(Some(2 * 1024 * 1024))
|
||||||
.add_service(RelayServer::new(handler))
|
.add_service(RelayServer::new(handler))
|
||||||
.add_service(HandshakeSvc::server())
|
.add_service(HandshakeSvc::server())
|
||||||
.add_service(ReflBuilder::configure().build_v1().unwrap())
|
.add_service(ReflBuilder::configure().build_v1().unwrap())
|
||||||
.serve(([0, 0, 0, 0], 50051).into())
|
.serve(bind_addr)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -595,15 +595,17 @@ pub async fn write_hid_report(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
allow_gadget_cycle, detect_uac_card_candidates, next_stream_id, open_with_retry,
|
allow_gadget_cycle, detect_uac_card_candidates, init_tracing, next_stream_id,
|
||||||
parse_uac_named_card_candidates, parse_uac_numeric_card_ids, parse_uac_pcm_candidates,
|
open_ear_with_retry, open_hid_if_ready, open_with_retry, parse_uac_named_card_candidates,
|
||||||
preferred_uac_device_candidates, should_recover_hid_error, write_hid_report,
|
parse_uac_numeric_card_ids, parse_uac_pcm_candidates, preferred_uac_device_candidates,
|
||||||
|
should_recover_hid_error, write_hid_report,
|
||||||
};
|
};
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use temp_env::with_var;
|
use temp_env::with_var;
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
|
use tempfile::tempdir;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
@ -640,6 +642,15 @@ mod tests {
|
|||||||
assert_eq!(candidates, vec!["hw:7,0", "plughw:7,0"]);
|
assert_eq!(candidates, vec!["hw:7,0", "plughw:7,0"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preferred_uac_device_candidates_handles_blank_and_plughw_overrides() {
|
||||||
|
assert!(preferred_uac_device_candidates(" ").is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
preferred_uac_device_candidates(" plughw:8,2 "),
|
||||||
|
vec!["plughw:8,2", "hw:8,2"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn preferred_uac_device_candidates_expands_known_aliases() {
|
fn preferred_uac_device_candidates_expands_known_aliases() {
|
||||||
let candidates = preferred_uac_device_candidates("hw:UAC2Gadget,0");
|
let candidates = preferred_uac_device_candidates("hw:UAC2Gadget,0");
|
||||||
@ -676,6 +687,24 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_uac_card_helpers_ignore_malformed_candidates() {
|
||||||
|
let cards = "\
|
||||||
|
XX [BrokenGadget ]: USB-Audio - UAC2 Gadget\n\
|
||||||
|
03 NoBracketGadget : USB-Audio - Lesavka\n\
|
||||||
|
04 [ ]: USB-Audio - Composite\n\
|
||||||
|
05 [Composite ]: USB-Audio - Composite\n";
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parse_uac_named_card_candidates(cards),
|
||||||
|
vec!["hw:BrokenGadget,0", "hw:Composite,0"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_uac_numeric_card_ids(cards),
|
||||||
|
BTreeSet::from(["03".to_string(), "04".to_string(), "05".to_string()])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_uac_pcm_candidates_expands_all_matching_device_indexes() {
|
fn parse_uac_pcm_candidates_expands_all_matching_device_indexes() {
|
||||||
let pcm = "\
|
let pcm = "\
|
||||||
@ -690,6 +719,17 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_uac_pcm_candidates_normalizes_zeroes_and_skips_non_matching_cards() {
|
||||||
|
let pcm = "\
|
||||||
|
00-00: Zero card : playback 1 : capture 1\n\
|
||||||
|
09-03: Other card : playback 1 : capture 1\n\
|
||||||
|
bad line without separator\n";
|
||||||
|
let ids = BTreeSet::from(["0".to_string()]);
|
||||||
|
|
||||||
|
assert_eq!(parse_uac_pcm_candidates(pcm, &ids), vec!["hw:0,0"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn open_with_retry_opens_existing_file() {
|
async fn open_with_retry_opens_existing_file() {
|
||||||
@ -705,6 +745,26 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn init_tracing_returns_a_guard_under_coverage() {
|
||||||
|
let _guard = init_tracing().expect("coverage tracing guard");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn hid_open_helpers_return_contextual_errors_for_bad_paths() {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
let err = open_with_retry(dir.path().to_str().unwrap())
|
||||||
|
.await
|
||||||
|
.expect_err("directory should not open as HID file");
|
||||||
|
assert!(format!("{err:#}").contains("opening"));
|
||||||
|
|
||||||
|
let err = open_hid_if_ready(dir.path().to_str().unwrap())
|
||||||
|
.await
|
||||||
|
.expect_err("directory should be a hard open error");
|
||||||
|
assert!(format!("{err:#}").contains("opening"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn write_hid_report_writes_bytes() {
|
async fn write_hid_report_writes_bytes() {
|
||||||
@ -726,4 +786,42 @@ mod tests {
|
|||||||
.expect("read back temp file");
|
.expect("read back temp file");
|
||||||
assert_eq!(&contents, &[1, 2, 3, 4]);
|
assert_eq!(&contents, &[1, 2, 3, 4]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn write_hid_report_opens_lazily_when_handle_is_empty() {
|
||||||
|
let tmp = NamedTempFile::new().expect("temp file");
|
||||||
|
let shared = Arc::new(Mutex::new(None));
|
||||||
|
|
||||||
|
write_hid_report(&shared, tmp.path().to_str().unwrap(), &[9, 8])
|
||||||
|
.await
|
||||||
|
.expect("lazy write succeeds");
|
||||||
|
|
||||||
|
let contents = tokio::fs::read(tmp.path())
|
||||||
|
.await
|
||||||
|
.expect("read back temp file");
|
||||||
|
assert_eq!(&contents, &[9, 8]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn open_ear_with_retry_reports_bad_capture_device() {
|
||||||
|
let err = temp_env::with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_AUDIO_INIT_ATTEMPTS", Some("1")),
|
||||||
|
("LESAVKA_AUDIO_INIT_DELAY_MS", Some("0")),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
let runtime = tokio::runtime::Runtime::new().expect("test runtime");
|
||||||
|
match runtime.block_on(open_ear_with_retry(
|
||||||
|
"hw:DefinitelyMissingLesavkaDevice,99",
|
||||||
|
99,
|
||||||
|
)) {
|
||||||
|
Ok(_) => panic!("missing ALSA source should fail"),
|
||||||
|
Err(err) => err,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(!format!("{err:#}").is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,6 +84,27 @@ mod keyboard_clipboard_contract {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(coverage)]
|
||||||
|
#[serial]
|
||||||
|
fn read_clipboard_text_ignores_failed_command_even_with_stdout() {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
write_executable(
|
||||||
|
dir.path(),
|
||||||
|
"sh",
|
||||||
|
"#!/bin/sh\nprintf 'stale-clipboard'\nexit 1\n",
|
||||||
|
);
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_CLIPBOARD_CMD", Some("ignored")),
|
||||||
|
("PATH", dir.path().to_str()),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
assert!(read_clipboard_text().is_none());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
#[serial]
|
#[serial]
|
||||||
|
|||||||
@ -302,6 +302,77 @@ mod keyboard_contract_extra {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(coverage)]
|
||||||
|
#[serial]
|
||||||
|
fn trigger_clipboard_paste_covers_disabled_rpc_and_hid_fallback_paths() {
|
||||||
|
let Some(dev) =
|
||||||
|
open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-trigger-1"))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (tx, mut rx) = tokio::sync::broadcast::channel(32);
|
||||||
|
let mut disabled = KeyboardAggregator::new(dev, false, tx, None);
|
||||||
|
disabled.paste_enabled = false;
|
||||||
|
disabled.trigger_clipboard_paste();
|
||||||
|
assert!(rx.try_recv().is_err());
|
||||||
|
|
||||||
|
let Some(dev) =
|
||||||
|
open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-trigger-2"))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (paste_tx, mut paste_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||||
|
let (tx, mut rx) = tokio::sync::broadcast::channel(32);
|
||||||
|
let mut rpc = KeyboardAggregator::new(dev, false, tx, Some(paste_tx));
|
||||||
|
rpc.paste_enabled = true;
|
||||||
|
rpc.paste_rpc_enabled = true;
|
||||||
|
with_var(
|
||||||
|
"LESAVKA_CLIPBOARD_CMD",
|
||||||
|
Some("printf 'trigger-rpc'"),
|
||||||
|
|| {
|
||||||
|
rpc.trigger_clipboard_paste();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(paste_rx.try_recv().expect("rpc payload"), "trigger-rpc");
|
||||||
|
assert!(rx.try_recv().is_err());
|
||||||
|
|
||||||
|
let Some(dev) =
|
||||||
|
open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-trigger-3"))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (tx, mut rx) = tokio::sync::broadcast::channel(32);
|
||||||
|
let mut hid = KeyboardAggregator::new(dev, false, tx, None);
|
||||||
|
hid.paste_enabled = true;
|
||||||
|
hid.paste_rpc_enabled = false;
|
||||||
|
with_var("LESAVKA_CLIPBOARD_CMD", Some("printf 'b'"), || {
|
||||||
|
with_var("LESAVKA_CLIPBOARD_DELAY_MS", Some("0"), || {
|
||||||
|
hid.trigger_clipboard_paste();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
rx.try_recv().is_ok(),
|
||||||
|
"HID fallback should emit keyboard reports"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(coverage)]
|
||||||
|
#[serial]
|
||||||
|
fn disabled_send_suppresses_direct_reports() {
|
||||||
|
let Some(dev) =
|
||||||
|
open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-send-off"))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (mut agg, mut rx) = new_aggregator(dev);
|
||||||
|
agg.set_send(false);
|
||||||
|
agg.send_empty_report();
|
||||||
|
agg.emit_live_report(evdev::KeyCode::KEY_A, 1, [0, 0, 4, 0, 0, 0, 0, 0]);
|
||||||
|
assert!(rx.try_recv().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
#[serial]
|
#[serial]
|
||||||
@ -399,80 +470,4 @@ mod keyboard_contract_extra {
|
|||||||
"coverage paste path should end with an empty report"
|
"coverage paste path should end with an empty report"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[cfg(coverage)]
|
|
||||||
#[serial]
|
|
||||||
fn try_handle_paste_event_coverage_path_respects_debounce_fallthrough() {
|
|
||||||
let Some(dev) = open_any_keyboard_device()
|
|
||||||
.or_else(|| build_keyboard("lesavka-include-kbd-coverage-debounce"))
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let (tx, mut rx) = tokio::sync::broadcast::channel(32);
|
|
||||||
let mut agg = KeyboardAggregator::new(dev, false, tx, None);
|
|
||||||
agg.paste_enabled = true;
|
|
||||||
agg.paste_rpc_enabled = false;
|
|
||||||
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL);
|
|
||||||
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTALT);
|
|
||||||
agg.pressed_keys.insert(evdev::KeyCode::KEY_V);
|
|
||||||
|
|
||||||
let now_ms = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_millis() as u64;
|
|
||||||
LAST_PASTE_MS.store(now_ms, Ordering::Relaxed);
|
|
||||||
|
|
||||||
with_var("LESAVKA_CLIPBOARD_CMD", Some("printf 'a'"), || {
|
|
||||||
with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+alt+v"), || {
|
|
||||||
with_var("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("999999"), || {
|
|
||||||
assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_V, 1));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let pkt = rx
|
|
||||||
.try_recv()
|
|
||||||
.expect("debounced paste should still emit a swallowed empty report");
|
|
||||||
assert_eq!(pkt.data, vec![0; 8]);
|
|
||||||
assert!(
|
|
||||||
rx.try_recv().is_err(),
|
|
||||||
"debounced paste should not emit HID reports"
|
|
||||||
);
|
|
||||||
LAST_PASTE_MS.store(0, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[cfg(coverage)]
|
|
||||||
#[serial]
|
|
||||||
fn try_handle_paste_event_coverage_path_invokes_rpc_when_enabled() {
|
|
||||||
let Some(dev) = open_any_keyboard_device()
|
|
||||||
.or_else(|| build_keyboard("lesavka-include-kbd-coverage-rpc"))
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let (paste_tx, mut paste_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
|
||||||
let (tx, _rx) = tokio::sync::broadcast::channel(32);
|
|
||||||
let mut agg = KeyboardAggregator::new(dev, false, tx, Some(paste_tx));
|
|
||||||
agg.paste_enabled = true;
|
|
||||||
agg.paste_rpc_enabled = true;
|
|
||||||
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL);
|
|
||||||
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTALT);
|
|
||||||
agg.pressed_keys.insert(evdev::KeyCode::KEY_V);
|
|
||||||
|
|
||||||
with_var(
|
|
||||||
"LESAVKA_CLIPBOARD_CMD",
|
|
||||||
Some("printf 'rpc-coverage'"),
|
|
||||||
|| {
|
|
||||||
with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+alt+v"), || {
|
|
||||||
with_var("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("0"), || {
|
|
||||||
assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_V, 1));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let payload = paste_rx.try_recv().expect("rpc payload");
|
|
||||||
assert_eq!(payload, "rpc-coverage");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
170
testing/tests/client_keyboard_paste_rpc_contract.rs
Normal file
170
testing/tests/client_keyboard_paste_rpc_contract.rs
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
//! Coverage for keyboard paste debounce and RPC branches.
|
||||||
|
//!
|
||||||
|
//! Scope: include keyboard input source and cover paste debounce/RPC paths.
|
||||||
|
//! Targets: `client/src/input/keyboard.rs`.
|
||||||
|
//! Why: paste routing has operator-visible side effects, and keeping it in a
|
||||||
|
//! focused contract keeps keyboard coverage under the 500 LOC hygiene cap.
|
||||||
|
|
||||||
|
mod keymap {
|
||||||
|
pub use lesavka_client::input::keymap::*;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(warnings)]
|
||||||
|
mod keyboard_paste_rpc_contract {
|
||||||
|
include!(env!("LESAVKA_CLIENT_KEYBOARD_SRC"));
|
||||||
|
|
||||||
|
use serial_test::serial;
|
||||||
|
use temp_env::with_var;
|
||||||
|
|
||||||
|
fn open_any_keyboard_device() -> Option<evdev::Device> {
|
||||||
|
let entries = std::fs::read_dir("/dev/input").ok()?;
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
let name = path.file_name()?.to_string_lossy();
|
||||||
|
if !name.starts_with("event") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dev = evdev::Device::open(path).ok()?;
|
||||||
|
let _ = dev.set_nonblocking(true);
|
||||||
|
let looks_like_keyboard = dev
|
||||||
|
.supported_keys()
|
||||||
|
.map(|keys| {
|
||||||
|
keys.contains(evdev::KeyCode::KEY_A)
|
||||||
|
&& keys.contains(evdev::KeyCode::KEY_ENTER)
|
||||||
|
&& keys.contains(evdev::KeyCode::KEY_LEFTCTRL)
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
if looks_like_keyboard {
|
||||||
|
return Some(dev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_keyboard(name: &str) -> Option<evdev::Device> {
|
||||||
|
let mut keys = evdev::AttributeSet::<evdev::KeyCode>::new();
|
||||||
|
for key in [
|
||||||
|
evdev::KeyCode::KEY_A,
|
||||||
|
evdev::KeyCode::KEY_B,
|
||||||
|
evdev::KeyCode::KEY_V,
|
||||||
|
evdev::KeyCode::KEY_LEFTCTRL,
|
||||||
|
evdev::KeyCode::KEY_LEFTALT,
|
||||||
|
] {
|
||||||
|
keys.insert(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut vdev = evdev::uinput::VirtualDevice::builder()
|
||||||
|
.ok()?
|
||||||
|
.name(name)
|
||||||
|
.with_keys(&keys)
|
||||||
|
.ok()?
|
||||||
|
.build()
|
||||||
|
.ok()?;
|
||||||
|
for _ in 0..40 {
|
||||||
|
if let Ok(mut nodes) = vdev.enumerate_dev_nodes_blocking() {
|
||||||
|
if let Some(Ok(path)) = nodes.next() {
|
||||||
|
let dev = evdev::Device::open(path).ok()?;
|
||||||
|
dev.set_nonblocking(true).ok()?;
|
||||||
|
return Some(dev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn live_modifier_report_honors_configured_staging_delay() {
|
||||||
|
let (tx, mut rx) = tokio::sync::broadcast::channel(4);
|
||||||
|
with_var("LESAVKA_LIVE_MODIFIER_DELAY_MS", Some("1"), || {
|
||||||
|
emit_live_keyboard_report(
|
||||||
|
&tx,
|
||||||
|
evdev::KeyCode::KEY_A,
|
||||||
|
1,
|
||||||
|
[0x01, 0, 0x04, 0, 0, 0, 0, 0],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let staged = rx.try_recv().expect("modifier-only report");
|
||||||
|
assert_eq!(staged.data, vec![0x01, 0, 0, 0, 0, 0, 0, 0]);
|
||||||
|
let final_report = rx.try_recv().expect("final key report");
|
||||||
|
assert_eq!(final_report.data, vec![0x01, 0, 0x04, 0, 0, 0, 0, 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(coverage)]
|
||||||
|
#[serial]
|
||||||
|
fn try_handle_paste_event_coverage_path_respects_debounce_fallthrough() {
|
||||||
|
let Some(dev) = open_any_keyboard_device()
|
||||||
|
.or_else(|| build_keyboard("lesavka-include-kbd-coverage-debounce"))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (tx, mut rx) = tokio::sync::broadcast::channel(32);
|
||||||
|
let mut agg = KeyboardAggregator::new(dev, false, tx, None);
|
||||||
|
agg.paste_enabled = true;
|
||||||
|
agg.paste_rpc_enabled = false;
|
||||||
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL);
|
||||||
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTALT);
|
||||||
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_V);
|
||||||
|
|
||||||
|
let now_ms = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as u64;
|
||||||
|
LAST_PASTE_MS.store(now_ms, Ordering::Relaxed);
|
||||||
|
|
||||||
|
with_var("LESAVKA_CLIPBOARD_CMD", Some("printf 'a'"), || {
|
||||||
|
with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+alt+v"), || {
|
||||||
|
with_var("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("999999"), || {
|
||||||
|
assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_V, 1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let pkt = rx
|
||||||
|
.try_recv()
|
||||||
|
.expect("debounced paste should still emit a swallowed empty report");
|
||||||
|
assert_eq!(pkt.data, vec![0; 8]);
|
||||||
|
assert!(
|
||||||
|
rx.try_recv().is_err(),
|
||||||
|
"debounced paste should not emit HID reports"
|
||||||
|
);
|
||||||
|
LAST_PASTE_MS.store(0, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(coverage)]
|
||||||
|
#[serial]
|
||||||
|
fn try_handle_paste_event_coverage_path_invokes_rpc_when_enabled() {
|
||||||
|
let Some(dev) = open_any_keyboard_device()
|
||||||
|
.or_else(|| build_keyboard("lesavka-include-kbd-coverage-rpc"))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (paste_tx, mut paste_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||||
|
let (tx, _rx) = tokio::sync::broadcast::channel(32);
|
||||||
|
let mut agg = KeyboardAggregator::new(dev, false, tx, Some(paste_tx));
|
||||||
|
agg.paste_enabled = true;
|
||||||
|
agg.paste_rpc_enabled = true;
|
||||||
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL);
|
||||||
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTALT);
|
||||||
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_V);
|
||||||
|
|
||||||
|
with_var(
|
||||||
|
"LESAVKA_CLIPBOARD_CMD",
|
||||||
|
Some("printf 'rpc-coverage'"),
|
||||||
|
|| {
|
||||||
|
with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+alt+v"), || {
|
||||||
|
with_var("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("0"), || {
|
||||||
|
assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_V, 1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let payload = paste_rx.try_recv().expect("rpc payload");
|
||||||
|
assert_eq!(payload, "rpc-coverage");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,8 @@ const CAMERA_SRC: &str = include_str!("../../client/src/input/camera.rs");
|
|||||||
const MICROPHONE_SRC: &str = include_str!("../../client/src/input/microphone.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 LAUNCHER_MOD_SRC: &str = include_str!("../../client/src/launcher/mod.rs");
|
||||||
const MAIN_SRC: &str = include_str!("../../client/src/main.rs");
|
const MAIN_SRC: &str = include_str!("../../client/src/main.rs");
|
||||||
|
const UI_COMPONENTS_SRC: &str = include_str!("../../client/src/launcher/ui_components.rs");
|
||||||
|
const PREVIEW_SRC: &str = include_str!("../../client/src/launcher/preview.rs");
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn relay_child_gets_parent_identity_from_launcher() {
|
fn relay_child_gets_parent_identity_from_launcher() {
|
||||||
@ -123,3 +125,43 @@ fn launcher_webcam_quality_selection_reaches_preview_and_relay_env() {
|
|||||||
assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_CAM_WIDTH\""));
|
assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_CAM_WIDTH\""));
|
||||||
assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_CAM_H264_KBIT\""));
|
assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_CAM_H264_KBIT\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_chip_distinguishes_reachable_from_connected() {
|
||||||
|
assert!(UI_RUNTIME_SRC.contains("fn server_light_state("));
|
||||||
|
assert!(UI_RUNTIME_SRC.contains("if relay_live"));
|
||||||
|
assert!(UI_RUNTIME_SRC.contains("} else if state.server_available {"));
|
||||||
|
assert!(UI_RUNTIME_SRC.contains("StatusLightState::Caution"));
|
||||||
|
assert!(UI_RUNTIME_SRC.contains("fn server_version_label("));
|
||||||
|
assert!(UI_RUNTIME_SRC.contains("return \"-\".to_string();"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn launcher_brand_uses_readable_icon_size() {
|
||||||
|
assert!(UI_COMPONENTS_SRC.contains("brand_icon.set_pixel_size(44);"));
|
||||||
|
assert!(UI_COMPONENTS_SRC.contains("brand_icon.set_valign(gtk::Align::Center);"));
|
||||||
|
assert!(UI_COMPONENTS_SRC.contains("heading.set_valign(gtk::Align::Center);"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preview_logs_are_actionable_with_short_mythic_flavor() {
|
||||||
|
assert!(PREVIEW_SRC.contains("waking {eye} eye preview"));
|
||||||
|
assert!(PREVIEW_SRC.contains("connecting {eye} eye feed"));
|
||||||
|
assert!(PREVIEW_SRC.contains("waiting for first frame"));
|
||||||
|
assert!(PREVIEW_SRC.contains("Preview stream error. See session log."));
|
||||||
|
assert!(PREVIEW_SRC.contains("«У лукоморья дуб зелёный; златая цепь на дубе том…»"));
|
||||||
|
assert!(PREVIEW_SRC.contains("«Там чудеса: там леший бродит, русалка на ветвях сидит…»"));
|
||||||
|
assert!(PREVIEW_SRC.contains("«Подымите мне веки: не вижу!»"));
|
||||||
|
assert!(PREVIEW_SRC.contains("«Идёт направо — песнь заводит, налево — сказку говорит…»"));
|
||||||
|
assert!(
|
||||||
|
PREVIEW_SRC
|
||||||
|
.contains("«Там царь Кащей над златом чахнет; там русский дух… там Русью пахнет!»")
|
||||||
|
);
|
||||||
|
assert!(PREVIEW_SRC.contains("«Там на неведомых дорожках следы невиданных зверей…»"));
|
||||||
|
assert!(PREVIEW_SRC.contains("«Избушка, избушка! Встань к лесу задом, ко мне передом.»"));
|
||||||
|
assert!(PREVIEW_SRC.contains("«Фу-фу! Русским духом пахнет!»"));
|
||||||
|
assert!(PREVIEW_SRC.contains("«Дела давно минувших дней, преданья старины глубокой…»"));
|
||||||
|
assert!(!PREVIEW_SRC.contains("is waking the preview spell"));
|
||||||
|
assert!(!PREVIEW_SRC.contains("opens the watchfire"));
|
||||||
|
assert!(!PREVIEW_SRC.contains("log spellbook"));
|
||||||
|
}
|
||||||
|
|||||||
@ -201,6 +201,14 @@ exit 0
|
|||||||
assert!(desc.contains("fakesink sync=false"));
|
assert!(desc.contains("fakesink sync=false"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downstream_audio_pipeline_can_tee_a_debug_aac_tap() {
|
||||||
|
let desc = audio_output_pipeline_desc("fakesink sync=false", 1.0, true);
|
||||||
|
assert!(desc.contains("tee name=t"));
|
||||||
|
assert!(desc.contains("filesink location=/tmp/lesavka-audio.aac"));
|
||||||
|
assert!(desc.contains("volume name=remote_audio_gain volume=1.000"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn audio_gain_env_defaults_and_clamps_for_soft_remote_audio() {
|
fn audio_gain_env_defaults_and_clamps_for_soft_remote_audio() {
|
||||||
@ -232,6 +240,36 @@ exit 0
|
|||||||
assert_eq!(read_audio_gain_control(&path), None);
|
assert_eq!(read_audio_gain_control(&path), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_gain_control_sample_applies_only_changed_values() {
|
||||||
|
let _ = gst::init();
|
||||||
|
let volume = gst::ElementFactory::make("volume")
|
||||||
|
.build()
|
||||||
|
.expect("volume element");
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("gain.control");
|
||||||
|
let mut last_gain = None;
|
||||||
|
|
||||||
|
fs::write(&path, "1.5\n").expect("write gain");
|
||||||
|
assert_eq!(
|
||||||
|
apply_audio_gain_control_sample(&path, &volume, &mut last_gain),
|
||||||
|
Some(1.5)
|
||||||
|
);
|
||||||
|
assert_eq!(last_gain, Some(1.5));
|
||||||
|
assert_eq!(volume.property::<f64>("volume"), 1.5);
|
||||||
|
assert_eq!(
|
||||||
|
apply_audio_gain_control_sample(&path, &volume, &mut last_gain),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::write(&path, "2.25\n").expect("write changed gain");
|
||||||
|
assert_eq!(
|
||||||
|
apply_audio_gain_control_sample(&path, &volume, &mut last_gain),
|
||||||
|
Some(2.25)
|
||||||
|
);
|
||||||
|
assert_eq!(volume.property::<f64>("volume"), 2.25);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn audio_gain_parsing_and_formatting_are_stable() {
|
fn audio_gain_parsing_and_formatting_are_stable() {
|
||||||
assert_eq!(parse_audio_gain("3.5 ignored"), Some(3.5));
|
assert_eq!(parse_audio_gain("3.5 ignored"), Some(3.5));
|
||||||
@ -275,5 +313,55 @@ exit 0
|
|||||||
assert_eq!(sink_state_rank("IDLE"), 1);
|
assert_eq!(sink_state_rank("IDLE"), 1);
|
||||||
assert_eq!(sink_state_rank("SUSPENDED"), 2);
|
assert_eq!(sink_state_rank("SUSPENDED"), 2);
|
||||||
assert_eq!(sink_state_rank("UNKNOWN"), 3);
|
assert_eq!(sink_state_rank("UNKNOWN"), 3);
|
||||||
|
|
||||||
|
let sinks = parse_pactl_short_sinks(
|
||||||
|
"1 default.sink module IDLE\n2 other.sink module IDLE\n",
|
||||||
|
Some("default.sink"),
|
||||||
|
);
|
||||||
|
assert_eq!(sinks[0], ("default.sink".to_string(), "IDLE".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn list_pw_sinks_uses_default_when_short_sink_listing_fails() {
|
||||||
|
let script = r#"#!/usr/bin/env sh
|
||||||
|
if [ "$1" = "info" ]; then
|
||||||
|
echo "Default Sink: fallback.default"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "$1" = "list" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
"#;
|
||||||
|
with_fake_pactl(script, || {
|
||||||
|
with_var("LESAVKA_AUDIO_SINK", None::<&str>, || {
|
||||||
|
assert_eq!(
|
||||||
|
list_pw_sinks(),
|
||||||
|
vec![("fallback.default".to_string(), "DEFAULT".to_string())]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn live_audio_buffer_logs_periodic_packets_after_remote_gap() {
|
||||||
|
let _ = gst::init();
|
||||||
|
let timeline = std::sync::Mutex::new(AudioTimeline {
|
||||||
|
last_remote_pts_us: Some(1_000),
|
||||||
|
packets: 599,
|
||||||
|
});
|
||||||
|
let buffer = live_audio_buffer(
|
||||||
|
AudioPacket {
|
||||||
|
id: 0,
|
||||||
|
pts: 1_500,
|
||||||
|
data: vec![0xFF, 0xF1, 0x50, 0x80, 0x00, 0x1F, 0xFC],
|
||||||
|
},
|
||||||
|
&timeline,
|
||||||
|
);
|
||||||
|
assert_eq!(buffer.size(), 7);
|
||||||
|
let timeline = timeline.lock().expect("timeline");
|
||||||
|
assert_eq!(timeline.last_remote_pts_us, Some(1_500));
|
||||||
|
assert_eq!(timeline.packets, 600);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,14 @@ fn decoder_override_accepts_decodebin_without_factory_lookup() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn decoder_override_accepts_buildable_element() {
|
||||||
|
with_var("LESAVKA_H264_DECODER", Some("fakesink"), || {
|
||||||
|
assert_eq!(video_support::pick_h264_decoder(), "fakesink");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn decoder_override_ignores_blank_or_unknown_values() {
|
fn decoder_override_ignores_blank_or_unknown_values() {
|
||||||
@ -29,3 +37,19 @@ fn decoder_override_ignores_blank_or_unknown_values() {
|
|||||||
assert!(!video_support::pick_h264_decoder().trim().is_empty());
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
//! client and server agree on output mode, so it deserves a centralized
|
//! client and server agree on output mode, so it deserves a centralized
|
||||||
//! contract test.
|
//! contract test.
|
||||||
|
|
||||||
use lesavka_client::handshake::{PeerCaps, negotiate};
|
use lesavka_client::handshake::{HandshakeProbe, PeerCaps, negotiate, probe};
|
||||||
use lesavka_common::lesavka::{
|
use lesavka_common::lesavka::{
|
||||||
Empty, HandshakeSet,
|
Empty, HandshakeSet,
|
||||||
handshake_server::{Handshake, HandshakeServer},
|
handshake_server::{Handshake, HandshakeServer},
|
||||||
@ -47,6 +47,32 @@ where
|
|||||||
caps
|
caps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn probe_against_service<S>(service: S) -> HandshakeProbe
|
||||||
|
where
|
||||||
|
S: Handshake + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind local handshake listener");
|
||||||
|
let addr = listener.local_addr().expect("listener addr");
|
||||||
|
drop(listener);
|
||||||
|
|
||||||
|
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
Server::builder()
|
||||||
|
.add_service(HandshakeServer::new(service))
|
||||||
|
.serve_with_shutdown(addr, async move {
|
||||||
|
let _ = shutdown_rx.await;
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("serve handshake server");
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
let result = probe(&format!("http://{addr}")).await;
|
||||||
|
let _ = shutdown_tx.send(());
|
||||||
|
let _ = server.await;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
async fn negotiate_against_local_server() -> PeerCaps {
|
async fn negotiate_against_local_server() -> PeerCaps {
|
||||||
negotiate_against_service(lesavka_server::handshake::HandshakeSvc).await
|
negotiate_against_service(lesavka_server::handshake::HandshakeSvc).await
|
||||||
}
|
}
|
||||||
@ -65,6 +91,12 @@ fn assert_default_caps(caps: &PeerCaps) {
|
|||||||
assert_eq!(caps.eye_fps, None);
|
assert_eq!(caps.eye_fps, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn assert_default_probe(probe_result: &HandshakeProbe) {
|
||||||
|
assert_default_caps(&probe_result.caps);
|
||||||
|
assert_eq!(probe_result.rtt_ms, None);
|
||||||
|
assert!(!probe_result.reachable);
|
||||||
|
}
|
||||||
|
|
||||||
struct UnimplementedHandshakeSvc;
|
struct UnimplementedHandshakeSvc;
|
||||||
|
|
||||||
#[tonic::async_trait]
|
#[tonic::async_trait]
|
||||||
@ -268,6 +300,86 @@ fn handshake_times_out_slow_capabilities_call() {
|
|||||||
assert_default_caps(&caps);
|
assert_default_caps(&caps);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn handshake_probe_reports_reachable_caps_and_rtt() {
|
||||||
|
with_var("LESAVKA_CAM_OUTPUT", Some("uvc"), || {
|
||||||
|
let rt = Runtime::new().expect("create runtime");
|
||||||
|
let probe_result = rt.block_on(probe_against_service(
|
||||||
|
lesavka_server::handshake::HandshakeSvc,
|
||||||
|
));
|
||||||
|
assert!(probe_result.reachable);
|
||||||
|
assert!(probe_result.rtt_ms.is_some_and(|rtt| rtt >= 0.0));
|
||||||
|
assert!(probe_result.caps.camera);
|
||||||
|
assert!(probe_result.caps.microphone);
|
||||||
|
assert_eq!(probe_result.caps.camera_output.as_deref(), Some("uvc"));
|
||||||
|
assert_eq!(probe_result.caps.camera_codec.as_deref(), Some("mjpeg"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn handshake_probe_marks_unimplemented_service_reachable() {
|
||||||
|
let rt = Runtime::new().expect("create runtime");
|
||||||
|
let probe_result = rt.block_on(probe_against_service(UnimplementedHandshakeSvc));
|
||||||
|
assert!(probe_result.reachable);
|
||||||
|
assert!(probe_result.rtt_ms.is_some());
|
||||||
|
assert_default_caps(&probe_result.caps);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn handshake_probe_marks_internal_error_service_reachable() {
|
||||||
|
let rt = Runtime::new().expect("create runtime");
|
||||||
|
let probe_result = rt.block_on(probe_against_service(InternalErrorHandshakeSvc));
|
||||||
|
assert!(probe_result.reachable);
|
||||||
|
assert!(probe_result.rtt_ms.is_some());
|
||||||
|
assert_default_caps(&probe_result.caps);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn handshake_probe_maps_empty_optional_fields_to_none() {
|
||||||
|
let rt = Runtime::new().expect("create runtime");
|
||||||
|
let probe_result = rt.block_on(probe_against_service(SparseHandshakeSvc));
|
||||||
|
assert!(probe_result.reachable);
|
||||||
|
assert!(probe_result.caps.camera);
|
||||||
|
assert!(!probe_result.caps.microphone);
|
||||||
|
assert_eq!(probe_result.caps.camera_output, None);
|
||||||
|
assert_eq!(probe_result.caps.camera_codec, None);
|
||||||
|
assert_eq!(probe_result.caps.camera_width, None);
|
||||||
|
assert_eq!(probe_result.caps.camera_height, None);
|
||||||
|
assert_eq!(probe_result.caps.camera_fps, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn handshake_probe_returns_defaults_when_server_is_unreachable() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
|
||||||
|
let addr = listener.local_addr().expect("listener addr");
|
||||||
|
drop(listener);
|
||||||
|
|
||||||
|
let rt = Runtime::new().expect("create runtime");
|
||||||
|
let probe_result = rt.block_on(probe(&format!("http://{addr}")));
|
||||||
|
assert_default_probe(&probe_result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn handshake_probe_returns_defaults_for_invalid_endpoint() {
|
||||||
|
let rt = Runtime::new().expect("create runtime");
|
||||||
|
let probe_result = rt.block_on(probe("not a uri"));
|
||||||
|
assert_default_probe(&probe_result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn handshake_probe_times_out_slow_capabilities_call() {
|
||||||
|
let rt = Runtime::new().expect("create runtime");
|
||||||
|
let probe_result = rt.block_on(probe_against_service(SlowHandshakeSvc));
|
||||||
|
assert_default_probe(&probe_result);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn handshake_service_direct_call_reports_capabilities() {
|
fn handshake_service_direct_call_reports_capabilities() {
|
||||||
|
|||||||
@ -123,12 +123,45 @@ mod gadget_include_contract {
|
|||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn wait_state_any_accepts_configured_and_not_attached_fake_states() {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
let ctrl = "fake-ctrl.usb";
|
||||||
|
build_fake_tree(dir.path(), ctrl, "lesavka-test", "configured");
|
||||||
|
|
||||||
|
with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || {
|
||||||
|
assert_eq!(
|
||||||
|
UsbGadget::wait_state_any(ctrl, 0).expect("configured state"),
|
||||||
|
"configured"
|
||||||
|
);
|
||||||
|
let state_path = dir.path().join(format!("sys/class/udc/{ctrl}/state"));
|
||||||
|
std::fs::write(state_path, "not attached\n").expect("flip state");
|
||||||
|
assert_eq!(
|
||||||
|
UsbGadget::wait_state_any(ctrl, 0).expect("not attached state"),
|
||||||
|
"not attached"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wait_state_times_out_for_missing_controller() {
|
fn wait_state_times_out_for_missing_controller() {
|
||||||
let result = UsbGadget::wait_state("definitely-missing-udc", "configured", 0);
|
let result = UsbGadget::wait_state("definitely-missing-udc", "configured", 0);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn wait_state_accepts_matching_fake_state() {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
let ctrl = "fake-ctrl.usb";
|
||||||
|
build_fake_tree(dir.path(), ctrl, "lesavka-test", "configured");
|
||||||
|
|
||||||
|
with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || {
|
||||||
|
UsbGadget::wait_state(ctrl, "configured", 0).expect("configured state");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn write_attr_writes_value_with_trailing_newline() {
|
fn write_attr_writes_value_with_trailing_newline() {
|
||||||
let file = NamedTempFile::new().expect("temp file");
|
let file = NamedTempFile::new().expect("temp file");
|
||||||
@ -224,6 +257,26 @@ mod gadget_include_contract {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn cycle_short_circuits_when_state_file_disappears_without_force() {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
let ctrl = "fake-ctrl.usb";
|
||||||
|
std::fs::create_dir_all(dir.path().join(format!("sys/class/udc/{ctrl}")))
|
||||||
|
.expect("create fake controller without state");
|
||||||
|
write_file(
|
||||||
|
&dir.path().join("cfg/lesavka-test/UDC"),
|
||||||
|
&format!("{ctrl}\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || {
|
||||||
|
let gadget = UsbGadget::new("lesavka-test");
|
||||||
|
gadget
|
||||||
|
.cycle()
|
||||||
|
.expect("missing state should allow standby short-circuit");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn cycle_force_mode_completes_on_fake_tree_when_state_stays_not_attached() {
|
fn cycle_force_mode_completes_on_fake_tree_when_state_stays_not_attached() {
|
||||||
@ -387,6 +440,31 @@ echo noop core helper >&2
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn run_forced_core_rebuild_reports_helper_failure_and_truncates_tail() {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
let helper = dir.path().join("fake-core-fail.sh");
|
||||||
|
write_helper(
|
||||||
|
&helper,
|
||||||
|
r#"#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf '%*s\n' 1400 '' | tr ' ' x
|
||||||
|
exit 42
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
with_fast_recovery_env(&helper, || {
|
||||||
|
let gadget = UsbGadget::new("lesavka-test");
|
||||||
|
let err = gadget
|
||||||
|
.run_forced_core_rebuild()
|
||||||
|
.expect_err("failing helper should report stdout/stderr");
|
||||||
|
let message = format!("{err:#}");
|
||||||
|
assert!(message.contains("exited with"), "{message}");
|
||||||
|
assert!(message.contains("..."), "{message}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn probe_platform_udc_reads_fake_platform_tree() {
|
fn probe_platform_udc_reads_fake_platform_tree() {
|
||||||
|
|||||||
@ -74,6 +74,27 @@ mod server_main_binary {
|
|||||||
panic!("failed to connect to local tonic server");
|
panic!("failed to connect to local tonic server");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn server_bind_addr_defaults_and_reports_bad_env() {
|
||||||
|
with_var("LESAVKA_SERVER_BIND_ADDR", None::<&str>, || {
|
||||||
|
assert_eq!(
|
||||||
|
server_bind_addr().expect("default bind addr"),
|
||||||
|
"0.0.0.0:50051".parse().expect("socket addr")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
with_var("LESAVKA_SERVER_BIND_ADDR", Some("127.0.0.1:0"), || {
|
||||||
|
assert_eq!(
|
||||||
|
server_bind_addr().expect("override bind addr"),
|
||||||
|
"127.0.0.1:0".parse().expect("socket addr")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
with_var("LESAVKA_SERVER_BIND_ADDR", Some("not-an-address"), || {
|
||||||
|
let err = server_bind_addr().expect_err("bad bind addr should fail");
|
||||||
|
assert!(format!("{err:#}").contains("LESAVKA_SERVER_BIND_ADDR"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn handler_new_tolerates_missing_hid_nodes_without_cycle() {
|
fn handler_new_tolerates_missing_hid_nodes_without_cycle() {
|
||||||
@ -241,6 +262,30 @@ mod server_main_binary {
|
|||||||
assert_eq!(err.code(), tonic::Code::InvalidArgument);
|
assert_eq!(err.code(), tonic::Code::InvalidArgument);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn capture_video_rejects_invalid_source_id() {
|
||||||
|
let (_dir, handler) = build_handler_for_tests();
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
|
let result = rt.block_on(async {
|
||||||
|
handler
|
||||||
|
.capture_video(tonic::Request::new(MonitorRequest {
|
||||||
|
id: 0,
|
||||||
|
max_bitrate: 4_000,
|
||||||
|
requested_width: 0,
|
||||||
|
requested_height: 0,
|
||||||
|
requested_fps: 0,
|
||||||
|
source_id: Some(9),
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
let err = match result {
|
||||||
|
Ok(_) => panic!("invalid source id must be rejected"),
|
||||||
|
Err(err) => err,
|
||||||
|
};
|
||||||
|
assert_eq!(err.code(), tonic::Code::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn paste_text_rejects_plaintext_requests() {
|
fn paste_text_rejects_plaintext_requests() {
|
||||||
|
|||||||
@ -407,57 +407,4 @@ mod server_main_binary_extra {
|
|||||||
server.abort();
|
server.abort();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn stream_camera_reports_error_or_terminates_cleanly_without_camera_hardware() {
|
|
||||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
|
||||||
rt.block_on(async {
|
|
||||||
let (_dir, handler) = build_handler_for_tests();
|
|
||||||
|
|
||||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
|
||||||
let addr = listener.local_addr().expect("addr");
|
|
||||||
drop(listener);
|
|
||||||
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
let _ = tonic::transport::Server::builder()
|
|
||||||
.add_service(RelayServer::new(handler))
|
|
||||||
.serve(addr)
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
|
|
||||||
let channel = connect_with_retry(addr).await;
|
|
||||||
let mut cli = RelayClient::new(channel);
|
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel(4);
|
|
||||||
tx.send(VideoPacket {
|
|
||||||
id: 2,
|
|
||||||
pts: 1,
|
|
||||||
data: vec![0, 1, 2, 3],
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("send camera packet");
|
|
||||||
drop(tx);
|
|
||||||
|
|
||||||
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
|
||||||
let result = cli.stream_camera(tonic::Request::new(outbound)).await;
|
|
||||||
match result {
|
|
||||||
Ok(mut stream) => {
|
|
||||||
let _ = stream.get_mut().message().await;
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
err.code(),
|
|
||||||
tonic::Code::Internal | tonic::Code::Unavailable | tonic::Code::Unknown
|
|
||||||
),
|
|
||||||
"unexpected camera stream error code: {}",
|
|
||||||
err.code()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
server.abort();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
137
testing/tests/server_main_eye_hub_contract.rs
Normal file
137
testing/tests/server_main_eye_hub_contract.rs
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
//! 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 {
|
||||||
|
include!(env!("LESAVKA_SERVER_MAIN_SRC"));
|
||||||
|
|
||||||
|
use futures_util::stream;
|
||||||
|
use temp_env::with_var;
|
||||||
|
|
||||||
|
fn with_capture_power_disabled(f: impl FnOnce()) {
|
||||||
|
with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), f);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_eye_hub_forwards_inner_packets() {
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
|
with_capture_power_disabled(|| {
|
||||||
|
rt.block_on(async {
|
||||||
|
let lease = CapturePowerManager::new().acquire().await;
|
||||||
|
let packet = VideoPacket {
|
||||||
|
id: 2,
|
||||||
|
pts: 42,
|
||||||
|
data: vec![9, 8, 7],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let (packet_tx, packet_rx) = tokio::sync::mpsc::channel(1);
|
||||||
|
let hub = EyeHub::spawn(ReceiverStream::new(packet_rx), lease);
|
||||||
|
hub.subscribers
|
||||||
|
.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||||
|
let mut rx = hub.tx.subscribe();
|
||||||
|
packet_tx
|
||||||
|
.send(Ok(packet.clone()))
|
||||||
|
.await
|
||||||
|
.expect("send synthetic packet");
|
||||||
|
let observed = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
|
||||||
|
.await
|
||||||
|
.expect("hub packet timeout")
|
||||||
|
.expect("hub packet");
|
||||||
|
assert_eq!(observed.id, packet.id);
|
||||||
|
assert_eq!(observed.pts, packet.pts);
|
||||||
|
assert_eq!(observed.data, packet.data);
|
||||||
|
drop(packet_tx);
|
||||||
|
for _ in 0..20 {
|
||||||
|
if !hub.running.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!hub.running.load(std::sync::atomic::Ordering::Relaxed),
|
||||||
|
"hub should stop after the synthetic packet stream closes"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conflicting_eye_hubs_for_the_same_source_are_pruned_before_reopen() {
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
|
with_capture_power_disabled(|| {
|
||||||
|
rt.block_on(async {
|
||||||
|
let requested_key = EyeHubKey {
|
||||||
|
source_id: 1,
|
||||||
|
requested_width: 1280,
|
||||||
|
requested_height: 720,
|
||||||
|
requested_fps: 60,
|
||||||
|
};
|
||||||
|
let stale_same_source_key = EyeHubKey {
|
||||||
|
source_id: 1,
|
||||||
|
requested_width: 1920,
|
||||||
|
requested_height: 1080,
|
||||||
|
requested_fps: 60,
|
||||||
|
};
|
||||||
|
let keep_other_source_key = EyeHubKey {
|
||||||
|
source_id: 0,
|
||||||
|
requested_width: 1920,
|
||||||
|
requested_height: 1080,
|
||||||
|
requested_fps: 60,
|
||||||
|
};
|
||||||
|
let stale_same_source = EyeHub::spawn(
|
||||||
|
stream::pending::<Result<VideoPacket, tonic::Status>>(),
|
||||||
|
CapturePowerManager::new().acquire().await,
|
||||||
|
);
|
||||||
|
let stopped_other_source = EyeHub::spawn(
|
||||||
|
stream::pending::<Result<VideoPacket, tonic::Status>>(),
|
||||||
|
CapturePowerManager::new().acquire().await,
|
||||||
|
);
|
||||||
|
stopped_other_source.shutdown();
|
||||||
|
let keep_other_source = EyeHub::spawn(
|
||||||
|
stream::pending::<Result<VideoPacket, tonic::Status>>(),
|
||||||
|
CapturePowerManager::new().acquire().await,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut hubs = std::collections::HashMap::new();
|
||||||
|
hubs.insert(stale_same_source_key, stale_same_source.clone());
|
||||||
|
hubs.insert(
|
||||||
|
EyeHubKey {
|
||||||
|
source_id: 0,
|
||||||
|
requested_width: 1280,
|
||||||
|
requested_height: 720,
|
||||||
|
requested_fps: 60,
|
||||||
|
},
|
||||||
|
stopped_other_source,
|
||||||
|
);
|
||||||
|
hubs.insert(keep_other_source_key, keep_other_source.clone());
|
||||||
|
|
||||||
|
let removed = take_conflicting_eye_hubs(&mut hubs, requested_key);
|
||||||
|
|
||||||
|
assert_eq!(removed.len(), 2);
|
||||||
|
assert!(!hubs.contains_key(&stale_same_source_key));
|
||||||
|
assert!(hubs.contains_key(&keep_other_source_key));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eye_hub_shutdown_marks_the_hub_as_not_running() {
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
|
with_capture_power_disabled(|| {
|
||||||
|
rt.block_on(async {
|
||||||
|
let hub = EyeHub::spawn(
|
||||||
|
stream::pending::<Result<VideoPacket, tonic::Status>>(),
|
||||||
|
CapturePowerManager::new().acquire().await,
|
||||||
|
);
|
||||||
|
assert!(hub.running.load(std::sync::atomic::Ordering::Relaxed));
|
||||||
|
hub.shutdown();
|
||||||
|
assert!(!hub.running.load(std::sync::atomic::Ordering::Relaxed));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
343
testing/tests/server_main_media_extra_contract.rs
Normal file
343
testing/tests/server_main_media_extra_contract.rs
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
//! 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 {
|
||||||
|
include!(env!("LESAVKA_SERVER_MAIN_SRC"));
|
||||||
|
|
||||||
|
use futures_util::stream;
|
||||||
|
use lesavka_common::lesavka::relay_client::RelayClient;
|
||||||
|
use serial_test::serial;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::path::Path;
|
||||||
|
use temp_env::with_var;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
async fn connect_with_retry(addr: std::net::SocketAddr) -> tonic::transport::Channel {
|
||||||
|
let endpoint = tonic::transport::Endpoint::from_shared(format!("http://{addr}"))
|
||||||
|
.expect("endpoint")
|
||||||
|
.tcp_nodelay(true);
|
||||||
|
for _ in 0..40 {
|
||||||
|
if let Ok(channel) = endpoint.clone().connect().await {
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||||
|
}
|
||||||
|
panic!("failed to connect to local tonic server");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_file(path: &Path, content: &str) {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).expect("create parent");
|
||||||
|
}
|
||||||
|
std::fs::write(path, content).expect("write file");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_helper(path: &Path, body: &str) {
|
||||||
|
write_file(path, body);
|
||||||
|
let mut perms = std::fs::metadata(path)
|
||||||
|
.expect("helper metadata")
|
||||||
|
.permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
std::fs::set_permissions(path, perms).expect("chmod helper");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_capture_power_disabled(f: impl FnOnce()) {
|
||||||
|
with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), f);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_handler_for_tests_with_modes(
|
||||||
|
kb_writable: bool,
|
||||||
|
ms_writable: bool,
|
||||||
|
) -> (tempfile::TempDir, Handler) {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
let kb_path = dir.path().join("hidg0.bin");
|
||||||
|
let ms_path = dir.path().join("hidg1.bin");
|
||||||
|
std::fs::write(&kb_path, []).expect("create kb file");
|
||||||
|
std::fs::write(&ms_path, []).expect("create ms file");
|
||||||
|
|
||||||
|
let kb = tokio::fs::File::from_std(
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(kb_writable)
|
||||||
|
.create(kb_writable)
|
||||||
|
.truncate(kb_writable)
|
||||||
|
.open(&kb_path)
|
||||||
|
.expect("open kb"),
|
||||||
|
);
|
||||||
|
let ms = tokio::fs::File::from_std(
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(ms_writable)
|
||||||
|
.create(ms_writable)
|
||||||
|
.truncate(ms_writable)
|
||||||
|
.open(&ms_path)
|
||||||
|
.expect("open ms"),
|
||||||
|
);
|
||||||
|
|
||||||
|
(
|
||||||
|
dir,
|
||||||
|
Handler {
|
||||||
|
kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
|
||||||
|
ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
|
||||||
|
gadget: UsbGadget::new("lesavka"),
|
||||||
|
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
|
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||||
|
capture_power: CapturePowerManager::new(),
|
||||||
|
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
||||||
|
std::collections::HashMap::new(),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_handler_for_tests() -> (tempfile::TempDir, Handler) {
|
||||||
|
build_handler_for_tests_with_modes(true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn stream_camera_reports_error_or_terminates_cleanly_without_camera_hardware() {
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
|
rt.block_on(async {
|
||||||
|
let (_dir, handler) = build_handler_for_tests();
|
||||||
|
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
drop(listener);
|
||||||
|
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let _ = tonic::transport::Server::builder()
|
||||||
|
.add_service(RelayServer::new(handler))
|
||||||
|
.serve(addr)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let channel = connect_with_retry(addr).await;
|
||||||
|
let mut cli = RelayClient::new(channel);
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel(4);
|
||||||
|
tx.send(VideoPacket {
|
||||||
|
id: 2,
|
||||||
|
pts: 1,
|
||||||
|
data: vec![0, 1, 2, 3],
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("send camera packet");
|
||||||
|
drop(tx);
|
||||||
|
|
||||||
|
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||||
|
let result = cli.stream_camera(tonic::Request::new(outbound)).await;
|
||||||
|
match result {
|
||||||
|
Ok(mut stream) => {
|
||||||
|
let _ = stream.get_mut().message().await;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err.code(),
|
||||||
|
tonic::Code::Internal | tonic::Code::Unavailable | tonic::Code::Unknown
|
||||||
|
),
|
||||||
|
"unexpected camera stream error code: {}",
|
||||||
|
err.code()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.abort();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn shared_eye_hub_covers_conflict_idle_and_error_shutdown_paths() {
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
|
with_capture_power_disabled(|| {
|
||||||
|
rt.block_on(async {
|
||||||
|
let (_dir, handler) = build_handler_for_tests();
|
||||||
|
let first_key = EyeHubKey {
|
||||||
|
source_id: 0,
|
||||||
|
requested_width: 640,
|
||||||
|
requested_height: 480,
|
||||||
|
requested_fps: 30,
|
||||||
|
};
|
||||||
|
let conflicting_key = EyeHubKey {
|
||||||
|
source_id: 0,
|
||||||
|
requested_width: 1280,
|
||||||
|
requested_height: 720,
|
||||||
|
requested_fps: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(coverage)]
|
||||||
|
{
|
||||||
|
let first = handler
|
||||||
|
.eye_hub("testsrc", first_key, 3_000)
|
||||||
|
.await
|
||||||
|
.expect("first eye hub");
|
||||||
|
assert_eq!(handler.active_eye_source_count().await, 1);
|
||||||
|
let replacement = handler
|
||||||
|
.eye_hub("testsrc", conflicting_key, 3_000)
|
||||||
|
.await
|
||||||
|
.expect("replacement eye hub");
|
||||||
|
assert!(!std::sync::Arc::ptr_eq(&first, &replacement));
|
||||||
|
assert!(!first.running.load(std::sync::atomic::Ordering::Relaxed));
|
||||||
|
replacement.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
{
|
||||||
|
let first_lease = handler.capture_power.acquire().await;
|
||||||
|
let first = EyeHub::spawn(
|
||||||
|
stream::pending::<Result<VideoPacket, tonic::Status>>(),
|
||||||
|
first_lease,
|
||||||
|
);
|
||||||
|
handler
|
||||||
|
.eye_hubs
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(first_key, std::sync::Arc::clone(&first));
|
||||||
|
assert_eq!(handler.active_eye_source_count().await, 1);
|
||||||
|
let stale = {
|
||||||
|
let mut hubs = handler.eye_hubs.lock().await;
|
||||||
|
take_conflicting_eye_hubs(&mut hubs, conflicting_key)
|
||||||
|
};
|
||||||
|
assert_eq!(stale.len(), 1);
|
||||||
|
assert!(std::sync::Arc::ptr_eq(&first, &stale[0]));
|
||||||
|
for hub in stale {
|
||||||
|
hub.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let manager = CapturePowerManager::new();
|
||||||
|
let idle_lease = manager.acquire().await;
|
||||||
|
let idle_packets = (0..61).map(|idx| {
|
||||||
|
Ok(VideoPacket {
|
||||||
|
id: 0,
|
||||||
|
pts: idx,
|
||||||
|
data: vec![idx as u8],
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let idle_hub = EyeHub::spawn(stream::iter(idle_packets), idle_lease);
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
assert!(!idle_hub.running.load(std::sync::atomic::Ordering::Relaxed));
|
||||||
|
|
||||||
|
let error_lease = manager.acquire().await;
|
||||||
|
let error_hub = EyeHub::spawn(
|
||||||
|
stream::iter(vec![Err(tonic::Status::internal("boom"))]),
|
||||||
|
error_lease,
|
||||||
|
);
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
assert!(!error_hub.running.load(std::sync::atomic::Ordering::Relaxed));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn uvc_helper_restart_systemctl_branches_are_classified() {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
let systemctl = dir.path().join("systemctl");
|
||||||
|
write_helper(
|
||||||
|
&systemctl,
|
||||||
|
r#"#!/usr/bin/env sh
|
||||||
|
case "$*" in
|
||||||
|
"reset-failed lesavka-uvc.service")
|
||||||
|
case "$LESAVKA_FAKE_SYSTEMCTL_MODE" in
|
||||||
|
resetfail) echo "reset failed first" >&2; exit 1 ;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
"restart lesavka-uvc.service")
|
||||||
|
case "$LESAVKA_FAKE_SYSTEMCTL_MODE" in
|
||||||
|
refused) echo "Operation refused, unit may be requested by dependency only" >&2; exit 1 ;;
|
||||||
|
fail) echo "stderr detail" >&2; echo "stdout detail"; exit 1 ;;
|
||||||
|
stdout-only) echo "stdout detail"; exit 1 ;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
exit 1
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
let prior = std::env::var("PATH").unwrap_or_default();
|
||||||
|
let path = if prior.is_empty() {
|
||||||
|
dir.path().display().to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}:{prior}", dir.path().display())
|
||||||
|
};
|
||||||
|
|
||||||
|
with_var("PATH", Some(path), || {
|
||||||
|
with_var("LESAVKA_GADGET_SYSFS_ROOT", None::<&str>, || {
|
||||||
|
with_var("LESAVKA_GADGET_CONFIGFS_ROOT", None::<&str>, || {
|
||||||
|
with_var("LESAVKA_FAKE_SYSTEMCTL_MODE", None::<&str>, || {
|
||||||
|
restart_uvc_helper().expect("successful fake restart");
|
||||||
|
});
|
||||||
|
with_var("LESAVKA_FAKE_SYSTEMCTL_MODE", Some("refused"), || {
|
||||||
|
restart_uvc_helper().expect("dependency-managed restart is acceptable");
|
||||||
|
});
|
||||||
|
with_var("LESAVKA_FAKE_SYSTEMCTL_MODE", Some("fail"), || {
|
||||||
|
let err = restart_uvc_helper().expect_err("generic restart failure");
|
||||||
|
let message = err.to_string();
|
||||||
|
assert!(message.contains("stderr detail"));
|
||||||
|
assert!(message.contains("also see stdout"));
|
||||||
|
});
|
||||||
|
with_var("LESAVKA_FAKE_SYSTEMCTL_MODE", Some("stdout-only"), || {
|
||||||
|
let err = restart_uvc_helper().expect_err("stdout-only restart failure");
|
||||||
|
let message = err.to_string();
|
||||||
|
assert!(message.contains("stdout detail"));
|
||||||
|
assert!(!message.contains("also see stdout"));
|
||||||
|
});
|
||||||
|
with_var("LESAVKA_FAKE_SYSTEMCTL_MODE", Some("resetfail"), || {
|
||||||
|
let err = restart_uvc_helper().expect_err("reset-failed should propagate");
|
||||||
|
assert!(err.to_string().contains("reset failed first"));
|
||||||
|
});
|
||||||
|
with_var("LESAVKA_FAKE_SYSTEMCTL_MODE", Some("stdout-only"), || {
|
||||||
|
let err = run_systemctl(&["restart", "lesavka-uvc.service"])
|
||||||
|
.expect_err("stdout-only direct systemctl failure");
|
||||||
|
let message = err.to_string();
|
||||||
|
assert!(message.contains("stdout detail"));
|
||||||
|
assert!(!message.contains("also see stdout"));
|
||||||
|
});
|
||||||
|
assert!(uvc_helper_restart_was_dependency_refused(
|
||||||
|
"unit may be requested by dependency only"
|
||||||
|
));
|
||||||
|
assert!(!uvc_helper_restart_was_dependency_refused("plain failure"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn coverage_main_and_status_helpers_report_expected_edges() {
|
||||||
|
assert_eq!(
|
||||||
|
remote_audio_status("remote USB gadget is not attached".to_string()).code(),
|
||||||
|
tonic::Code::Unavailable
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
remote_audio_status("alsa failed".to_string()).code(),
|
||||||
|
tonic::Code::Internal
|
||||||
|
);
|
||||||
|
with_var("LESAVKA_LIVE_KEYBOARD_REPORT_DELAY_MS", Some("12"), || {
|
||||||
|
assert_eq!(
|
||||||
|
live_keyboard_report_delay(),
|
||||||
|
std::time::Duration::from_millis(12)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(coverage)]
|
||||||
|
{
|
||||||
|
let err = main().expect_err("coverage main should skip live gRPC serve loop");
|
||||||
|
assert!(
|
||||||
|
err.to_string()
|
||||||
|
.contains("coverage mode skips live gRPC serve loop")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,8 +8,10 @@
|
|||||||
|
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::net::TcpListener;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
use std::process::{Command, Stdio};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
fn cargo_binary(name: &str) -> Option<PathBuf> {
|
fn cargo_binary(name: &str) -> Option<PathBuf> {
|
||||||
@ -35,13 +37,46 @@ fn candidate_dirs() -> Vec<PathBuf> {
|
|||||||
dirs
|
dirs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn workspace_root() -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.parent()
|
||||||
|
.expect("workspace root")
|
||||||
|
.to_path_buf()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_current_binary(name: &str) -> Option<PathBuf> {
|
||||||
|
if name != "lesavka-server" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let cargo = option_env!("CARGO").unwrap_or("cargo");
|
||||||
|
let target_dir = workspace_root().join("target/process-contract-debug");
|
||||||
|
let mut command = Command::new(cargo);
|
||||||
|
let status = command
|
||||||
|
.current_dir(workspace_root())
|
||||||
|
.env_remove("CARGO_ENCODED_RUSTFLAGS")
|
||||||
|
.env_remove("RUSTFLAGS")
|
||||||
|
.env_remove("LLVM_PROFILE_FILE")
|
||||||
|
.arg("build")
|
||||||
|
.arg("--target-dir")
|
||||||
|
.arg(&target_dir)
|
||||||
|
.args(["-p", "lesavka_server", "--bin", name])
|
||||||
|
.status()
|
||||||
|
.ok()?;
|
||||||
|
status
|
||||||
|
.success()
|
||||||
|
.then(|| target_dir.join("debug").join(name))
|
||||||
|
.filter(|path| path.exists() && path.is_file())
|
||||||
|
}
|
||||||
|
|
||||||
fn find_binary(name: &str) -> Option<PathBuf> {
|
fn find_binary(name: &str) -> Option<PathBuf> {
|
||||||
cargo_binary(name).or_else(|| {
|
build_current_binary(name)
|
||||||
candidate_dirs()
|
.or_else(|| cargo_binary(name))
|
||||||
.into_iter()
|
.or_else(|| {
|
||||||
.map(|dir| dir.join(name))
|
candidate_dirs()
|
||||||
.find(|path| path.exists() && path.is_file())
|
.into_iter()
|
||||||
})
|
.map(|dir| dir.join(name))
|
||||||
|
.find(|path| path.exists() && path.is_file())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn server_package_version() -> Option<String> {
|
fn server_package_version() -> Option<String> {
|
||||||
@ -81,9 +116,54 @@ fn server_binary_stays_up_with_missing_hid_nodes_and_current_version() {
|
|||||||
};
|
};
|
||||||
let log_path = PathBuf::from("/tmp/lesavka-server.log");
|
let log_path = PathBuf::from("/tmp/lesavka-server.log");
|
||||||
let _ = fs::remove_file(&log_path);
|
let _ = fs::remove_file(&log_path);
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("reserve local test port");
|
||||||
|
let bind_addr = listener.local_addr().expect("local test addr");
|
||||||
|
drop(listener);
|
||||||
|
|
||||||
|
if cfg!(coverage) {
|
||||||
|
let mut child = Command::new(bin)
|
||||||
|
.env("LESAVKA_DISABLE_UVC", "1")
|
||||||
|
.env("LESAVKA_SERVER_BIND_ADDR", bind_addr.to_string())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.expect("spawn coverage-mode lesavka-server");
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(3);
|
||||||
|
loop {
|
||||||
|
if let Some(status) = child.try_wait().expect("poll coverage-mode server") {
|
||||||
|
let mut stderr = String::new();
|
||||||
|
if let Some(mut pipe) = child.stderr.take() {
|
||||||
|
let _ = pipe.read_to_string(&mut stderr);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!status.success(),
|
||||||
|
"coverage-mode server should not report success before the live-loop proof"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("coverage mode skips live gRPC serve loop"),
|
||||||
|
"coverage-mode server should report the intentional live-loop skip; stderr was:\n{stderr}"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let log = fs::read_to_string(&log_path).unwrap_or_default();
|
||||||
|
if log.contains(
|
||||||
|
"HID endpoints are not ready; relay will keep running and open them lazily",
|
||||||
|
) {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"timed out waiting for coverage stub exit or live startup log; log was:\n{log}"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut child = Command::new(bin)
|
let mut child = Command::new(bin)
|
||||||
.env("LESAVKA_DISABLE_UVC", "1")
|
.env("LESAVKA_DISABLE_UVC", "1")
|
||||||
|
.env("LESAVKA_SERVER_BIND_ADDR", bind_addr.to_string())
|
||||||
.spawn()
|
.spawn()
|
||||||
.expect("spawn lesavka-server");
|
.expect("spawn lesavka-server");
|
||||||
|
|
||||||
|
|||||||
@ -175,6 +175,61 @@ mod server_main_rpc {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(coverage)]
|
||||||
|
#[serial]
|
||||||
|
fn capture_video_stream_drop_releases_shared_hub() {
|
||||||
|
let (_dir, handler) = build_handler_for_tests();
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
|
with_var(
|
||||||
|
"LESAVKA_TEST_VIDEO_SOURCE",
|
||||||
|
Some("/dev/lesavka_l_eye"),
|
||||||
|
|| {
|
||||||
|
rt.block_on(async {
|
||||||
|
let mut stream = handler
|
||||||
|
.capture_video(tonic::Request::new(MonitorRequest {
|
||||||
|
id: 0,
|
||||||
|
max_bitrate: 3_000,
|
||||||
|
requested_width: 0,
|
||||||
|
requested_height: 0,
|
||||||
|
requested_fps: 0,
|
||||||
|
source_id: None,
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.expect("coverage video stream")
|
||||||
|
.into_inner();
|
||||||
|
|
||||||
|
let packet = stream.next().await.expect("first item").expect("packet");
|
||||||
|
assert_eq!(packet.id, 0);
|
||||||
|
drop(stream);
|
||||||
|
|
||||||
|
let hub = handler
|
||||||
|
.eye_hubs
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.cloned()
|
||||||
|
.expect("active preview hub");
|
||||||
|
let _ = hub.tx.send(VideoPacket {
|
||||||
|
id: 0,
|
||||||
|
pts: 2,
|
||||||
|
data: vec![0, 0, 0, 1, 0x65],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
for _ in 0..40 {
|
||||||
|
if handler.active_eye_source_count().await == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||||
|
}
|
||||||
|
panic!("dropping a preview stream should release its shared hub");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
#[serial]
|
#[serial]
|
||||||
@ -309,6 +364,17 @@ mod server_main_rpc {
|
|||||||
let (_dir, handler) = build_handler_for_tests();
|
let (_dir, handler) = build_handler_for_tests();
|
||||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
|
|
||||||
|
with_var(
|
||||||
|
"LESAVKA_TEST_UDEV_CAPTURE_DEVICES",
|
||||||
|
Some("not-a-number"),
|
||||||
|
|| {
|
||||||
|
assert_eq!(Handler::detected_capture_devices_from_udev(), 0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
with_var("LESAVKA_TEST_UDEV_CAPTURE_DEVICES", Some("9"), || {
|
||||||
|
assert_eq!(Handler::detected_capture_devices_from_udev(), 2);
|
||||||
|
});
|
||||||
|
|
||||||
let snapshot = rt
|
let snapshot = rt
|
||||||
.block_on(async {
|
.block_on(async {
|
||||||
handler
|
handler
|
||||||
@ -381,78 +447,4 @@ mod server_main_rpc {
|
|||||||
assert!(legacy_fallback.enabled);
|
assert!(legacy_fallback.enabled);
|
||||||
assert_eq!(legacy_fallback.mode, "forced-on");
|
assert_eq!(legacy_fallback.mode, "forced-on");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[cfg(coverage)]
|
|
||||||
#[serial]
|
|
||||||
fn reset_usb_tolerates_missing_hid_after_successful_cycle() {
|
|
||||||
let dir = tempdir().expect("tempdir");
|
|
||||||
std::fs::write(dir.path().join("hidg0.bin"), "").expect("create kb file");
|
|
||||||
std::fs::write(dir.path().join("hidg1.bin"), "").expect("create ms file");
|
|
||||||
std::fs::create_dir_all(dir.path().join("sys/class/udc/fake-ctrl.usb"))
|
|
||||||
.expect("create udc dir");
|
|
||||||
std::fs::create_dir_all(dir.path().join("cfg/lesavka")).expect("create cfg dir");
|
|
||||||
std::fs::write(
|
|
||||||
dir.path().join("sys/class/udc/fake-ctrl.usb/state"),
|
|
||||||
"configured\n",
|
|
||||||
)
|
|
||||||
.expect("write state");
|
|
||||||
std::fs::write(dir.path().join("cfg/lesavka/UDC"), "fake-ctrl.usb\n").expect("write udc");
|
|
||||||
|
|
||||||
let kb = tokio::fs::File::from_std(
|
|
||||||
std::fs::OpenOptions::new()
|
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.open(dir.path().join("hidg0.bin"))
|
|
||||||
.expect("open kb"),
|
|
||||||
);
|
|
||||||
let ms = tokio::fs::File::from_std(
|
|
||||||
std::fs::OpenOptions::new()
|
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.open(dir.path().join("hidg1.bin"))
|
|
||||||
.expect("open ms"),
|
|
||||||
);
|
|
||||||
|
|
||||||
with_var(
|
|
||||||
"LESAVKA_GADGET_SYSFS_ROOT",
|
|
||||||
Some(dir.path().join("sys").to_string_lossy().to_string()),
|
|
||||||
|| {
|
|
||||||
with_var(
|
|
||||||
"LESAVKA_GADGET_CONFIGFS_ROOT",
|
|
||||||
Some(dir.path().join("cfg").to_string_lossy().to_string()),
|
|
||||||
|| {
|
|
||||||
let handler = Handler {
|
|
||||||
kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
|
|
||||||
ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
|
|
||||||
gadget: UsbGadget::new("lesavka"),
|
|
||||||
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
|
|
||||||
false,
|
|
||||||
)),
|
|
||||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
|
||||||
capture_power: CapturePowerManager::new(),
|
|
||||||
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
|
||||||
std::collections::HashMap::new(),
|
|
||||||
)),
|
|
||||||
};
|
|
||||||
|
|
||||||
with_var(
|
|
||||||
"LESAVKA_HID_DIR",
|
|
||||||
Some(dir.path().join("missing").to_string_lossy().to_string()),
|
|
||||||
|| {
|
|
||||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
|
||||||
let reply = rt
|
|
||||||
.block_on(async {
|
|
||||||
handler.reset_usb(tonic::Request::new(Empty {})).await
|
|
||||||
})
|
|
||||||
.expect("missing HID should not fail USB reset")
|
|
||||||
.into_inner();
|
|
||||||
assert!(reply.ok);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
181
testing/tests/server_main_rpc_reset_contract.rs
Normal file
181
testing/tests/server_main_rpc_reset_contract.rs
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
//! 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 {
|
||||||
|
include!(env!("LESAVKA_SERVER_MAIN_SRC"));
|
||||||
|
|
||||||
|
use serial_test::serial;
|
||||||
|
use temp_env::with_var;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn build_handler_for_tests() -> (tempfile::TempDir, Handler) {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
let kb_path = dir.path().join("hidg0.bin");
|
||||||
|
let ms_path = dir.path().join("hidg1.bin");
|
||||||
|
std::fs::write(&kb_path, []).expect("create kb file");
|
||||||
|
std::fs::write(&ms_path, []).expect("create ms file");
|
||||||
|
|
||||||
|
let kb = tokio::fs::File::from_std(
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(&kb_path)
|
||||||
|
.expect("open kb"),
|
||||||
|
);
|
||||||
|
let ms = tokio::fs::File::from_std(
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(&ms_path)
|
||||||
|
.expect("open ms"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let handler = with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || Handler {
|
||||||
|
kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
|
||||||
|
ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
|
||||||
|
gadget: UsbGadget::new("lesavka"),
|
||||||
|
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
|
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||||
|
capture_power: CapturePowerManager::new(),
|
||||||
|
eye_hubs: std::sync::Arc::new(
|
||||||
|
tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
(dir, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(coverage)]
|
||||||
|
#[serial]
|
||||||
|
fn reset_usb_tolerates_missing_hid_after_successful_cycle() {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
std::fs::write(dir.path().join("hidg0.bin"), "").expect("create kb file");
|
||||||
|
std::fs::write(dir.path().join("hidg1.bin"), "").expect("create ms file");
|
||||||
|
std::fs::create_dir_all(dir.path().join("sys/class/udc/fake-ctrl.usb"))
|
||||||
|
.expect("create udc dir");
|
||||||
|
std::fs::create_dir_all(dir.path().join("cfg/lesavka")).expect("create cfg dir");
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("sys/class/udc/fake-ctrl.usb/state"),
|
||||||
|
"configured\n",
|
||||||
|
)
|
||||||
|
.expect("write state");
|
||||||
|
std::fs::write(dir.path().join("cfg/lesavka/UDC"), "fake-ctrl.usb\n").expect("write udc");
|
||||||
|
|
||||||
|
let kb = tokio::fs::File::from_std(
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(dir.path().join("hidg0.bin"))
|
||||||
|
.expect("open kb"),
|
||||||
|
);
|
||||||
|
let ms = tokio::fs::File::from_std(
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(dir.path().join("hidg1.bin"))
|
||||||
|
.expect("open ms"),
|
||||||
|
);
|
||||||
|
|
||||||
|
with_var(
|
||||||
|
"LESAVKA_GADGET_SYSFS_ROOT",
|
||||||
|
Some(dir.path().join("sys").to_string_lossy().to_string()),
|
||||||
|
|| {
|
||||||
|
with_var(
|
||||||
|
"LESAVKA_GADGET_CONFIGFS_ROOT",
|
||||||
|
Some(dir.path().join("cfg").to_string_lossy().to_string()),
|
||||||
|
|| {
|
||||||
|
let handler = Handler {
|
||||||
|
kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
|
||||||
|
ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
|
||||||
|
gadget: UsbGadget::new("lesavka"),
|
||||||
|
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||||
|
capture_power: CapturePowerManager::new(),
|
||||||
|
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
||||||
|
std::collections::HashMap::new(),
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
|
||||||
|
with_var(
|
||||||
|
"LESAVKA_HID_DIR",
|
||||||
|
Some(dir.path().join("missing").to_string_lossy().to_string()),
|
||||||
|
|| {
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
|
let reply = rt
|
||||||
|
.block_on(async {
|
||||||
|
handler.reset_usb(tonic::Request::new(Empty {})).await
|
||||||
|
})
|
||||||
|
.expect("missing HID should not fail USB reset")
|
||||||
|
.into_inner();
|
||||||
|
assert!(reply.ok);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(coverage)]
|
||||||
|
#[serial]
|
||||||
|
fn reset_usb_reports_reopen_hid_failure_after_successful_cycle() {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
std::fs::create_dir_all(dir.path().join("bad-hid/hidg0")).expect("create bad hidg0 dir");
|
||||||
|
std::fs::write(dir.path().join("bad-hid/hidg1"), "").expect("create hidg1 file");
|
||||||
|
std::fs::create_dir_all(dir.path().join("sys/class/udc/fake-ctrl.usb"))
|
||||||
|
.expect("create udc dir");
|
||||||
|
std::fs::create_dir_all(dir.path().join("cfg/lesavka")).expect("create cfg dir");
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("sys/class/udc/fake-ctrl.usb/state"),
|
||||||
|
"configured\n",
|
||||||
|
)
|
||||||
|
.expect("write state");
|
||||||
|
std::fs::write(dir.path().join("cfg/lesavka/UDC"), "fake-ctrl.usb\n").expect("write udc");
|
||||||
|
|
||||||
|
let (_hid_dir, handler) = build_handler_for_tests();
|
||||||
|
with_var(
|
||||||
|
"LESAVKA_GADGET_SYSFS_ROOT",
|
||||||
|
Some(dir.path().join("sys").to_string_lossy().to_string()),
|
||||||
|
|| {
|
||||||
|
with_var(
|
||||||
|
"LESAVKA_GADGET_CONFIGFS_ROOT",
|
||||||
|
Some(dir.path().join("cfg").to_string_lossy().to_string()),
|
||||||
|
|| {
|
||||||
|
with_var(
|
||||||
|
"LESAVKA_HID_DIR",
|
||||||
|
Some(dir.path().join("bad-hid").to_string_lossy().to_string()),
|
||||||
|
|| {
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
|
let err = rt
|
||||||
|
.block_on(async {
|
||||||
|
handler.reset_usb(tonic::Request::new(Empty {})).await
|
||||||
|
})
|
||||||
|
.expect_err("bad HID path should fail reopen");
|
||||||
|
assert_eq!(err.code(), tonic::Code::Internal);
|
||||||
|
assert!(
|
||||||
|
err.message().contains("opening")
|
||||||
|
&& err.message().contains("bad-hid/hidg0"),
|
||||||
|
"unexpected reopen error: {}",
|
||||||
|
err.message()
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -130,6 +130,44 @@ mod server_main_binary_extra {
|
|||||||
build_handler_for_tests_with_modes(true, true)
|
build_handler_for_tests_with_modes(true, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handler_for_hid_dir(hid_dir: &Path) -> Handler {
|
||||||
|
let kb = tokio::fs::File::from_std(
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(hid_dir.join("hidg0"))
|
||||||
|
.expect("open hidg0"),
|
||||||
|
);
|
||||||
|
let ms = tokio::fs::File::from_std(
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(hid_dir.join("hidg1"))
|
||||||
|
.expect("open hidg1"),
|
||||||
|
);
|
||||||
|
Handler {
|
||||||
|
kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
|
||||||
|
ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
|
||||||
|
gadget: UsbGadget::new("lesavka"),
|
||||||
|
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
|
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||||
|
capture_power: CapturePowerManager::new(),
|
||||||
|
eye_hubs: std::sync::Arc::new(
|
||||||
|
tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_reset_fixture(state: &str) -> (tempfile::TempDir, std::path::PathBuf) {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
let hid_dir = dir.path().join("hid");
|
||||||
|
std::fs::create_dir_all(&hid_dir).expect("create hid dir");
|
||||||
|
std::fs::write(hid_dir.join("hidg0"), "").expect("create hidg0");
|
||||||
|
std::fs::write(hid_dir.join("hidg1"), "").expect("create hidg1");
|
||||||
|
build_fake_gadget_tree(dir.path(), "fake-ctrl.usb", "lesavka", state);
|
||||||
|
(dir, hid_dir)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn reset_usb_reports_host_not_attached_after_fake_cycle() {
|
fn reset_usb_reports_host_not_attached_after_fake_cycle() {
|
||||||
@ -266,120 +304,98 @@ printf 'configured\n' > "$LESAVKA_GADGET_SYSFS_ROOT/class/udc/fake-ctrl.usb/stat
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shared_eye_hub_forwards_inner_packets() {
|
#[cfg(coverage)]
|
||||||
|
#[serial]
|
||||||
|
fn reset_usb_reports_non_enumerated_and_unreadable_state_after_recovery() {
|
||||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
with_capture_power_disabled(|| {
|
let (dir, hid_dir) = prepare_reset_fixture("configured");
|
||||||
rt.block_on(async {
|
with_fake_gadget_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || {
|
||||||
let lease = CapturePowerManager::new().acquire().await;
|
with_var(
|
||||||
let packet = VideoPacket {
|
"LESAVKA_HID_DIR",
|
||||||
id: 2,
|
Some(hid_dir.to_string_lossy().to_string()),
|
||||||
pts: 42,
|
|| {
|
||||||
data: vec![9, 8, 7],
|
let handler = handler_for_hid_dir(&hid_dir);
|
||||||
..Default::default()
|
with_var("LESAVKA_TEST_RECOVERY_STATE", Some("not attached"), || {
|
||||||
};
|
let err = rt
|
||||||
let (packet_tx, packet_rx) = tokio::sync::mpsc::channel(1);
|
.block_on(async {
|
||||||
let hub = EyeHub::spawn(ReceiverStream::new(packet_rx), lease);
|
handler.reset_usb(tonic::Request::new(Empty {})).await
|
||||||
hub.subscribers
|
})
|
||||||
.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
.expect_err("non-enumerated recovery state");
|
||||||
let mut rx = hub.tx.subscribe();
|
assert_eq!(err.code(), tonic::Code::FailedPrecondition);
|
||||||
packet_tx
|
assert!(err.message().contains("still not attached"));
|
||||||
.send(Ok(packet.clone()))
|
});
|
||||||
.await
|
|
||||||
.expect("send synthetic packet");
|
with_var("LESAVKA_TEST_RECOVERY_STATE_ERROR", Some("1"), || {
|
||||||
let observed = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
|
let err = rt
|
||||||
.await
|
.block_on(async {
|
||||||
.expect("hub packet timeout")
|
handler.reset_usb(tonic::Request::new(Empty {})).await
|
||||||
.expect("hub packet");
|
})
|
||||||
assert_eq!(observed.id, packet.id);
|
.expect_err("unreadable recovery state");
|
||||||
assert_eq!(observed.pts, packet.pts);
|
assert_eq!(err.code(), tonic::Code::FailedPrecondition);
|
||||||
assert_eq!(observed.data, packet.data);
|
assert!(err.message().contains("cannot read UDC state"));
|
||||||
drop(packet_tx);
|
});
|
||||||
for _ in 0..20 {
|
},
|
||||||
if !hub.running.load(std::sync::atomic::Ordering::Relaxed) {
|
);
|
||||||
return;
|
|
||||||
}
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
!hub.running.load(std::sync::atomic::Ordering::Relaxed),
|
|
||||||
"hub should stop after the synthetic packet stream closes"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn conflicting_eye_hubs_for_the_same_source_are_pruned_before_reopen() {
|
#[cfg(coverage)]
|
||||||
|
#[serial]
|
||||||
|
fn reset_usb_reports_uvc_helper_restart_failure_after_recovery() {
|
||||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
with_capture_power_disabled(|| {
|
let (dir, hid_dir) = prepare_reset_fixture("configured");
|
||||||
rt.block_on(async {
|
with_fake_gadget_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || {
|
||||||
let requested_key = EyeHubKey {
|
with_var(
|
||||||
source_id: 1,
|
"LESAVKA_HID_DIR",
|
||||||
requested_width: 1280,
|
Some(hid_dir.to_string_lossy().to_string()),
|
||||||
requested_height: 720,
|
|| {
|
||||||
requested_fps: 60,
|
with_var(
|
||||||
};
|
"LESAVKA_TEST_UVC_HELPER_RESTART_ERR",
|
||||||
let stale_same_source_key = EyeHubKey {
|
Some("synthetic restart failure"),
|
||||||
source_id: 1,
|
|| {
|
||||||
requested_width: 1920,
|
let handler = handler_for_hid_dir(&hid_dir);
|
||||||
requested_height: 1080,
|
let err = rt
|
||||||
requested_fps: 60,
|
.block_on(async {
|
||||||
};
|
handler.reset_usb(tonic::Request::new(Empty {})).await
|
||||||
let keep_other_source_key = EyeHubKey {
|
})
|
||||||
source_id: 0,
|
.expect_err("restart helper failure");
|
||||||
requested_width: 1920,
|
assert_eq!(err.code(), tonic::Code::Internal);
|
||||||
requested_height: 1080,
|
assert!(err.message().contains("synthetic restart failure"));
|
||||||
requested_fps: 60,
|
},
|
||||||
};
|
);
|
||||||
let stale_same_source = EyeHub::spawn(
|
},
|
||||||
stream::pending::<Result<VideoPacket, tonic::Status>>(),
|
);
|
||||||
CapturePowerManager::new().acquire().await,
|
|
||||||
);
|
|
||||||
let stopped_other_source = EyeHub::spawn(
|
|
||||||
stream::pending::<Result<VideoPacket, tonic::Status>>(),
|
|
||||||
CapturePowerManager::new().acquire().await,
|
|
||||||
);
|
|
||||||
stopped_other_source.shutdown();
|
|
||||||
let keep_other_source = EyeHub::spawn(
|
|
||||||
stream::pending::<Result<VideoPacket, tonic::Status>>(),
|
|
||||||
CapturePowerManager::new().acquire().await,
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut hubs = std::collections::HashMap::new();
|
|
||||||
hubs.insert(stale_same_source_key, stale_same_source.clone());
|
|
||||||
hubs.insert(
|
|
||||||
EyeHubKey {
|
|
||||||
source_id: 0,
|
|
||||||
requested_width: 1280,
|
|
||||||
requested_height: 720,
|
|
||||||
requested_fps: 60,
|
|
||||||
},
|
|
||||||
stopped_other_source,
|
|
||||||
);
|
|
||||||
hubs.insert(keep_other_source_key, keep_other_source.clone());
|
|
||||||
|
|
||||||
let removed = take_conflicting_eye_hubs(&mut hubs, requested_key);
|
|
||||||
|
|
||||||
assert_eq!(removed.len(), 2);
|
|
||||||
assert!(!hubs.contains_key(&stale_same_source_key));
|
|
||||||
assert!(hubs.contains_key(&keep_other_source_key));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn eye_hub_shutdown_marks_the_hub_as_not_running() {
|
#[cfg(coverage)]
|
||||||
|
#[serial]
|
||||||
|
fn reset_usb_reports_reopen_hid_failure_after_successful_recovery() {
|
||||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
with_capture_power_disabled(|| {
|
let (dir, hid_dir) = prepare_reset_fixture("configured");
|
||||||
rt.block_on(async {
|
let bad_hid_dir = dir.path().join("bad-hid");
|
||||||
let hub = EyeHub::spawn(
|
std::fs::create_dir_all(bad_hid_dir.join("hidg0")).expect("create bad hidg0 dir");
|
||||||
stream::pending::<Result<VideoPacket, tonic::Status>>(),
|
std::fs::create_dir_all(bad_hid_dir.join("hidg1")).expect("create bad hidg1 dir");
|
||||||
CapturePowerManager::new().acquire().await,
|
|
||||||
);
|
with_fake_gadget_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || {
|
||||||
assert!(hub.running.load(std::sync::atomic::Ordering::Relaxed));
|
with_var(
|
||||||
hub.shutdown();
|
"LESAVKA_HID_DIR",
|
||||||
assert!(!hub.running.load(std::sync::atomic::Ordering::Relaxed));
|
Some(bad_hid_dir.to_string_lossy().to_string()),
|
||||||
});
|
|| {
|
||||||
|
let handler = handler_for_hid_dir(&hid_dir);
|
||||||
|
let err = rt
|
||||||
|
.block_on(async { handler.reset_usb(tonic::Request::new(Empty {})).await })
|
||||||
|
.expect_err("bad HID paths should fail after USB recovery");
|
||||||
|
assert_eq!(err.code(), tonic::Code::Internal);
|
||||||
|
assert!(
|
||||||
|
err.message().contains("opening"),
|
||||||
|
"unexpected reset error: {}",
|
||||||
|
err.message()
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -168,4 +168,57 @@ mod server_upstream_media {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn stream_camera_stops_a_superseded_session_cleanly() {
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
|
with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || {
|
||||||
|
with_var("LESAVKA_DISABLE_UVC", None::<&str>, || {
|
||||||
|
rt.block_on(async {
|
||||||
|
let (_dir, handler) = build_handler_for_tests();
|
||||||
|
let (server, mut cli) = serve_handler(handler).await;
|
||||||
|
let (first_tx, first_rx) = tokio::sync::mpsc::channel(4);
|
||||||
|
let (second_tx, second_rx) = tokio::sync::mpsc::channel(1);
|
||||||
|
|
||||||
|
let mut first = cli
|
||||||
|
.stream_camera(tonic::Request::new(
|
||||||
|
tokio_stream::wrappers::ReceiverStream::new(first_rx),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("first camera stream")
|
||||||
|
.into_inner();
|
||||||
|
|
||||||
|
let _second = cli
|
||||||
|
.stream_camera(tonic::Request::new(
|
||||||
|
tokio_stream::wrappers::ReceiverStream::new(second_rx),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("second camera stream supersedes first");
|
||||||
|
|
||||||
|
first_tx
|
||||||
|
.send(VideoPacket {
|
||||||
|
id: 2,
|
||||||
|
pts: 99,
|
||||||
|
data: vec![0, 0, 0, 1, 0x65],
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("send packet to first stream");
|
||||||
|
drop(first_tx);
|
||||||
|
|
||||||
|
let ack =
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(1), first.message())
|
||||||
|
.await
|
||||||
|
.expect("superseded camera ack timeout")
|
||||||
|
.expect("superseded camera ack grpc")
|
||||||
|
.expect("superseded camera ack item");
|
||||||
|
assert_eq!(ack, Empty {});
|
||||||
|
drop(second_tx);
|
||||||
|
|
||||||
|
server.abort();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user