use super::*;
#[tokio::test]
async fn streamed_mouse_motion_rejects_cancel_before_begin() {
let mut requests = tokio_stream::iter([Ok(proto::StreamMouseMotionRequest {
event: Some(proto::stream_mouse_motion_request::Event::Cancel(proto::StreamMouseMotionCancel {})),
})]);
let (sender, _receiver) = tokio::sync::mpsc::channel(1);
let status = collect_mouse_motion(&auv_driver::open_local().unwrap(), &mut requests, &sender).await.expect_err("cancel must follow begin");
assert_eq!(status.code(), tonic::Code::InvalidArgument);
assert_eq!(status.message(), "moveMouse cancel requires begin");
}
#[test]
fn overlay_thread_guard_rejects_a_different_execution_thread_without_ui() {
let owner = std::thread::current().id();
ensure_overlay_owner_thread(owner).expect("owner thread");
let status =
std::thread::spawn(move || ensure_overlay_owner_thread(owner).expect_err("different thread must fail")).join().expect("thread");
assert_eq!(status.code(), tonic::Code::FailedPrecondition);
}
#[test]
fn overlay_mapper_uses_owner_defaults_for_absent_optional_messages() {
let overlay = overlay_from_proto(proto::Overlay {
layers: vec![proto::OverlayLayer {
layer: Some(proto::overlay_layer::Layer::Outline(proto::Outline {
rect: Some(proto::ScreenRect {
x: 10.0,
y: 20.0,
width: 30.0,
height: 40.0,
}),
label: None,
label_visible: false,
style: None,
})),
}],
})
.expect("owner defaults");
assert_eq!(overlay.layers().len(), 1);
assert_eq!(overlay_options_from_proto(None).expect("default options"), auv_driver::overlay::ShowOptions::new());
}
#[test]
fn overlay_mapper_rejects_malformed_values_before_native_rendering() {
let invalid_point = proto::Overlay {
layers: vec![proto::OverlayLayer {
layer: Some(proto::overlay_layer::Layer::Cursor(proto::Cursor {
point: Some(proto::ScreenPoint {
x: f64::NAN,
y: 0.0,
}),
..Default::default()
})),
}],
};
assert_eq!(overlay_from_proto(invalid_point).expect_err("nonfinite point").code(), tonic::Code::InvalidArgument);
let oversized_svg = proto::Overlay {
layers: vec![proto::OverlayLayer {
layer: Some(proto::overlay_layer::Layer::Cursor(proto::Cursor {
point: Some(proto::ScreenPoint { x: 0.0, y: 0.0 }),
image: Some(proto::CursorImage {
image: Some(proto::cursor_image::Image::Svg("x".repeat(256 * 1024 + 1))),
}),
..Default::default()
})),
}],
};
assert_eq!(overlay_from_proto(oversized_svg).expect_err("SVG bound").code(), tonic::Code::InvalidArgument);
let unknown_easing = proto::ShowOptions {
motion: Some(proto::MotionOptions {
duration: None,
easing: Some(999),
}),
lifecycle: None,
};
assert_eq!(overlay_options_from_proto(Some(unknown_easing)).expect_err("unknown easing").code(), tonic::Code::InvalidArgument);
let negative_duration = proto::ShowOptions {
motion: Some(proto::MotionOptions {
duration: Some(prost_types::Duration {
seconds: -1,
nanos: 0,
}),
easing: None,
}),
lifecycle: None,
};
assert_eq!(overlay_options_from_proto(Some(negative_duration)).expect_err("negative duration").code(), tonic::Code::InvalidArgument);
}
#[test]
fn permission_probe_mapper_preserves_every_status() {
let mapped = permission_probe_to_proto(auv_driver::PermissionProbe {
screen_recording: auv_driver::PermissionStatus::Granted,
screen_capture_kit: auv_driver::PermissionStatus::Missing,
accessibility: auv_driver::PermissionStatus::Unknown,
automation_to_system_events: auv_driver::PermissionStatus::Granted,
});
assert_eq!(mapped.screen_recording, macos_proto::PermissionStatus::Granted as i32);
assert_eq!(mapped.screen_capture_kit, macos_proto::PermissionStatus::Missing as i32);
assert_eq!(mapped.accessibility, macos_proto::PermissionStatus::Unknown as i32);
assert_eq!(mapped.automation_to_system_events, macos_proto::PermissionStatus::Granted as i32);
}
#[test]
fn application_activation_mapper_preserves_each_verification_variant() {
use auv_api_proto::auv::api::driver::macos::v1::application_activation_verification::Verification;
let cases = [
auv_driver::ApplicationActivationVerification::VerifiedForeground {
observed_bundle_id: "com.example.Verified".to_string(),
},
auv_driver::ApplicationActivationVerification::ForegroundMismatch {
observed_bundle_id: "com.example.Other".to_string(),
},
auv_driver::ApplicationActivationVerification::Unavailable {
reason: "observation unavailable".to_string(),
},
];
for verification in cases {
let mapped = application_activation_to_proto(auv_driver::ApplicationActivationResult {
requested_bundle_id: "com.example.Requested".to_string(),
verification,
});
assert_eq!(mapped.requested_bundle_id, "com.example.Requested");
assert!(matches!(
mapped.verification.and_then(|verification| verification.verification),
Some(Verification::VerifiedForeground(_) | Verification::ForegroundMismatch(_) | Verification::Unavailable(_))
));
}
}
#[test]
fn application_request_validation_rejects_blank_bundle_and_invalid_duration() {
assert_eq!(
duration_from_proto(
Some(prost_types::Duration {
seconds: -1,
nanos: 0,
}),
std::time::Duration::from_millis(150),
"settle",
)
.expect_err("negative settle must fail before activation")
.code(),
tonic::Code::InvalidArgument
);
assert_eq!(application_bundle_id(" ").expect_err("blank bundle id").code(), tonic::Code::InvalidArgument);
}
#[test]
fn accessibility_request_validation_rejects_malformed_selector_before_native_capture() {
for request in [
macos_proto::FocusTextRequest::default(),
macos_proto::FocusTextRequest {
application: "com.example.Editor".to_string(),
selector: Some(macos_proto::focus_text_request::Selector::Query("".to_string())),
..Default::default()
},
macos_proto::FocusTextRequest {
application: "com.example.Editor".to_string(),
selector: Some(macos_proto::focus_text_request::Selector::Path(" ".to_string())),
..Default::default()
},
macos_proto::FocusTextRequest {
application: "com.example.Editor".to_string(),
selector: Some(macos_proto::focus_text_request::Selector::Query("Search".to_string())),
expected_role: Some("".to_string()),
..Default::default()
},
] {
assert_eq!(focus_text_options_from_proto(request).expect_err("malformed focus request").code(), tonic::Code::InvalidArgument);
}
}
#[test]
fn now_playing_mapper_preserves_owner_state_and_optional_presence() {
let mapped = now_playing_to_proto(auv_media_macos::NowPlayingState {
present: true,
is_playing: true,
source_bundle_id: Some("com.apple.Music".to_string()),
title: Some("Current Song".to_string()),
artist: Some("The Artist".to_string()),
album: None,
duration_seconds: Some(245.5),
elapsed_seconds: Some(61.25),
playback_rate: Some(1.0),
content_item_id: Some("track-42".to_string()),
supports_like: Some(true),
is_liked: None,
})
.expect("finite owner state");
assert!(mapped.present);
assert!(mapped.is_playing);
assert_eq!(mapped.source_bundle_id.as_deref(), Some("com.apple.Music"));
assert_eq!(mapped.title.as_deref(), Some("Current Song"));
assert_eq!(mapped.artist.as_deref(), Some("The Artist"));
assert_eq!(mapped.album, None);
assert_eq!(mapped.duration_seconds, Some(245.5));
assert_eq!(mapped.elapsed_seconds, Some(61.25));
assert_eq!(mapped.playback_rate, Some(1.0));
assert_eq!(mapped.content_item_id.as_deref(), Some("track-42"));
assert_eq!(mapped.supports_like, Some(true));
assert_eq!(mapped.is_liked, None);
}
#[test]
fn now_playing_mapper_rejects_non_finite_backend_numbers() {
for (field, state) in [
(
"duration_seconds",
auv_media_macos::NowPlayingState {
duration_seconds: Some(f64::NAN),
..Default::default()
},
),
(
"elapsed_seconds",
auv_media_macos::NowPlayingState {
elapsed_seconds: Some(f64::INFINITY),
..Default::default()
},
),
(
"playback_rate",
auv_media_macos::NowPlayingState {
playback_rate: Some(f64::NEG_INFINITY),
..Default::default()
},
),
] {
let error = now_playing_to_proto(state).expect_err("non-finite backend value must fail closed");
assert_eq!(error.code(), tonic::Code::Internal);
assert!(error.message().contains(field));
}
}
#[test]
fn unsupported_media_backend_maps_to_unimplemented() {
assert_eq!(media_status(auv_media_macos::MediaError::Unsupported).code(), tonic::Code::Unimplemented);
}
#[test]
fn uncertain_media_control_failure_is_not_exposed_as_retryable_unavailable() {
let status = media_control_status(auv_media_macos::MediaError::Native {
message: "verification read failed".to_string(),
recovery_hint: "inspect state before retrying".to_string(),
});
assert_eq!(status.code(), tonic::Code::Unknown);
assert!(status.message().contains("do not retry automatically"));
}
#[test]
fn media_control_outcome_mapper_preserves_before_after_and_verification() {
let before = auv_media_macos::NowPlayingState {
present: true,
title: Some("Before".to_string()),
is_playing: false,
..Default::default()
};
let after = auv_media_macos::NowPlayingState {
present: true,
title: Some("After".to_string()),
is_playing: true,
..Default::default()
};
let mapped = media_control_outcome_to_proto(auv_media_macos::output::MediaControlOutcome {
command: "play",
before: auv_media_macos::output::build_now_playing_output(&before),
after: auv_media_macos::output::build_now_playing_output(&after),
verified: true,
})
.expect("valid outcome");
assert_eq!(mapped.before.and_then(|state| state.title).as_deref(), Some("Before"));
assert_eq!(mapped.after.and_then(|state| state.title).as_deref(), Some("After"));
assert!(mapped.verified);
}
#[test]
fn captured_rgba_frame_preserves_alpha_and_screen_bounds() {
let capture = auv_driver::Capture {
origin: Some(auv_driver::Position::in_window(
&auv_driver::WindowRef {
id: "window-7".into(),
},
auv_driver::WindowPoint::new(0.0, 0.0),
)),
image: image::RgbaImage::from_raw(2, 1, vec![1, 2, 3, 4, 5, 6, 7, 8]).expect("valid RGBA fixture"),
bounds: auv_driver::Rect::new(10.0, 20.0, 1.0, 0.5),
scale_factor: 2.0,
backend: "fixture".to_string(),
fallback_reason: Some("fallback".to_string()),
};
let frame = capture_to_proto(capture.clone());
assert_eq!(capture_from_proto(frame.clone()).unwrap(), capture);
assert_eq!(frame.image.as_ref().expect("image").data, vec![1, 2, 3, 4, 5, 6, 7, 8]);
assert_eq!(
frame.bounds,
Some(proto::ScreenRect {
x: 10.0,
y: 20.0,
width: 1.0,
height: 0.5
})
);
assert_eq!(frame.scale_factor, 2.0);
assert_eq!(frame.backend, "fixture");
assert_eq!(frame.fallback_reason.as_deref(), Some("fallback"));
}
#[test]
fn text_recognition_capture_rejects_malformed_rgba_before_ocr() {
let error = capture_from_proto(proto::CapturedFrame {
origin: None,
image: Some(auv_api_proto::auv::api::image::v1::RgbaFrame {
width: 2,
height: 1,
data: vec![0; 7],
}),
bounds: Some(proto::ScreenRect {
x: 0.0,
y: 0.0,
width: 2.0,
height: 1.0,
}),
scale_factor: 1.0,
..Default::default()
})
.expect_err("malformed RGBA frame");
assert_eq!(error.code(), tonic::Code::InvalidArgument);
assert!(error.message().contains("expected 8"));
}
#[test]
fn text_recognition_region_must_stay_inside_normalized_bounds() {
let error = ratio_rect_from_proto(Some(auv_api_proto::auv::api::image::v1::NormalizedRect {
x: 0.8,
y: 0.0,
width: 0.3,
height: 1.0,
}))
.expect_err("out-of-bounds region");
assert_eq!(error.code(), tonic::Code::InvalidArgument);
assert_eq!(ratio_rect_from_proto(None).unwrap(), auv_driver::RatioRect::new(0.0, 0.0, 1.0, 1.0));
}
#[test]
fn recognized_text_mapper_preserves_screen_bounds_and_confidence() {
let response = recognition_to_proto(auv_driver::TextRecognition {
origin: Some(auv_driver::Position::in_window(
&auv_driver::WindowRef {
id: "window-7".into(),
},
auv_driver::WindowPoint::new(-10.0, -20.0),
)),
text: "hello".to_string(),
regions: vec![auv_driver::RecognizedText {
text: "hello".to_string(),
bounds: auv_driver::Rect::new(10.0, 20.0, 30.0, 40.0),
confidence: Some(0.75),
}],
});
assert_eq!(
position_from_proto(response.origin.unwrap()).unwrap().coordinate_space,
auv_driver::CoordinateSpace::Window("window-7".into())
);
assert_eq!(response.text, "hello");
assert_eq!(response.regions[0].confidence, Some(0.75));
assert_eq!(response.regions[0].bounds.as_ref().map(|bounds| bounds.x), Some(10.0));
}
#[test]
fn input_options_reject_malformed_values_before_delivery() {
let count_error = click_options_from_proto(Some(proto::ClickOptions {
click: Some(proto::Click {
count: 256,
interval: Some(prost_types::Duration {
seconds: 0,
nanos: 75_000_000,
}),
}),
..Default::default()
}))
.expect_err("click count outside the driver u8 contract");
assert_eq!(count_error.code(), tonic::Code::InvalidArgument);
let duration_error = type_text_options_from_proto(Some(proto::TypeTextOptions {
inter_char_delay: Some(prost_types::Duration {
seconds: -1,
nanos: 0,
}),
..Default::default()
}))
.expect_err("negative protobuf duration");
assert_eq!(duration_error.code(), tonic::Code::InvalidArgument);
let point_error = window_point_from_proto(proto::WindowPoint {
x: f64::NAN,
y: 0.0,
})
.expect_err("non-finite point");
assert_eq!(point_error.code(), tonic::Code::InvalidArgument);
let screen_point_error = screen_point_from_proto(proto::ScreenPoint {
x: 0.0,
y: f64::INFINITY,
})
.expect_err("non-finite screen point must fail before native input delivery");
assert_eq!(screen_point_error.code(), tonic::Code::InvalidArgument);
let empty_paste = paste_text_options_from_proto(String::new(), Some(Default::default()))
.expect_err("empty paste text must fail before clipboard capture or mutation");
assert_eq!(empty_paste.code(), tonic::Code::InvalidArgument);
let unknown_submit = paste_text_options_from_proto(
"text".to_string(),
Some(proto::PasteTextOptions {
submit: 99,
..Default::default()
}),
)
.expect_err("unknown paste submit enum must fail before clipboard mutation");
assert_eq!(unknown_submit.code(), tonic::Code::InvalidArgument);
let negative_settle = paste_text_options_from_proto(
"text".to_string(),
Some(proto::PasteTextOptions {
settle: Some(prost_types::Duration {
seconds: -1,
nanos: 0,
}),
..Default::default()
}),
)
.expect_err("negative paste settle must fail before clipboard mutation");
assert_eq!(negative_settle.code(), tonic::Code::InvalidArgument);
}
#[test]
fn click_rpc_preserves_modifiers_for_window_and_screen_delivery() {
let modifiers = proto::ClickModifiers {
shift: true,
control: true,
alt: true,
meta: true,
};
let window = click_options_from_proto(Some(proto::ClickOptions {
modifiers: Some(modifiers),
..Default::default()
}))
.unwrap();
let (_, _, screen) = screen_click_options_from_proto(Some(proto::ScreenClickOptions {
modifiers: Some(modifiers),
..Default::default()
}))
.unwrap();
assert_eq!(
window.modifiers,
auv_driver::ClickModifiers {
shift: true,
control: true,
alt: true,
meta: true
}
);
assert_eq!(screen, window.modifiers);
assert!(click_options_from_proto(None).unwrap().modifiers.is_empty());
assert!(screen_click_options_from_proto(Some(Default::default())).unwrap().2.is_empty());
}
#[test]
fn input_action_mapper_preserves_attempts_and_disturbance() {
let action = input_action_to_proto(auv_driver::InputActionResult {
selected_path: auv_driver::InputDeliveryPath::ClipboardPaste,
attempts: vec![
auv_driver::InputAttempt::failure(auv_driver::InputDeliveryPath::WindowTargetedKeyboard, "background unavailable"),
auv_driver::InputAttempt::success(auv_driver::InputDeliveryPath::ClipboardPaste),
],
verified: false,
mouse_disturbance: auv_driver::DisturbanceLevel::None,
focus_disturbance: auv_driver::DisturbanceLevel::Foreground,
clipboard_disturbance: auv_driver::DisturbanceLevel::Temporary,
})
.expect("valid canonical action");
assert_eq!(action.selected_path, proto::InputDeliveryPath::ClipboardPaste as i32);
assert_eq!(action.attempts.len(), 2);
assert_eq!(action.attempts[0].message.as_deref(), Some("background unavailable"));
assert_eq!(action.focus_disturbance, proto::DisturbanceLevel::Foreground as i32);
assert_eq!(action.clipboard_disturbance, proto::DisturbanceLevel::Temporary as i32);
}
#[test]
fn driver_errors_keep_their_grpc_semantics() {
assert_eq!(driver_status(auv_driver::DriverError::unsupported("vision.ocr")).code(), tonic::Code::Unimplemented);
assert_eq!(
driver_status(auv_driver::DriverError::PermissionDenied {
permission: "screen-recording",
message: None,
recovery: None,
})
.code(),
tonic::Code::PermissionDenied
);
}
#[test]
fn overlay_shadow_mapper_preserves_native_dimensions_and_rejects_invalid_blur() {
let make_style = |blur_radius| proto::CursorStyle {
label_foreground: Some(proto::Color {
red: 1.0,
green: 1.0,
blue: 1.0,
alpha: 1.0,
}),
label_background: Some(proto::Color {
red: 0.0,
green: 0.0,
blue: 0.0,
alpha: 1.0,
}),
label_padding: Some(proto::Insets::default()),
sprite_size: 24.0,
shadow: Some(proto::Shadow {
color: Some(proto::Color {
red: 1.0,
green: 0.6,
blue: 0.15,
alpha: 0.65,
}),
blur_radius,
offset_x: -1.0,
offset_y: 2.0,
}),
..Default::default()
};
let style = cursor_style_from_proto(make_style(8.0)).unwrap();
assert_eq!(style.sprite_size, 24.0);
assert_eq!(style.shadow.unwrap().offset_x, -1.0);
assert_eq!(style.shadow.unwrap().blur_radius, 8.0);
assert_eq!(cursor_style_from_proto(make_style(-1.0)).unwrap_err().code(), tonic::Code::InvalidArgument);
}
#[tokio::test]
async fn keyboard_rpc_requires_explicit_recipient_before_delivery() {
let service = LocalInputService {
session: auv_driver::open_local().unwrap(),
};
let error = service
.input_keyboard(Request::new(proto::InputKeyboardRequest {
inputs: vec![keyboard_press_request("a", 1)],
..Default::default()
}))
.await
.expect_err("missing target must not become global input");
assert_eq!(error.code(), tonic::Code::InvalidArgument);
assert_eq!(error.message(), "target is required");
}
fn keyboard_press_request(key: &str, count: u32) -> proto::KeyboardInput {
proto::KeyboardInput {
action: Some(proto::keyboard_input::Action::Press(proto::KeyboardPress {
policy: proto::InputPolicy::ForegroundPreferred as i32,
options: Some(proto::PressKeysOptions {
keys: vec![key.into()],
count: Some(count),
..Default::default()
}),
})),
}
}
#[tokio::test]
#[ignore = "requires a live macOS WindowServer"]
#[cfg(target_os = "macos")]
async fn targeted_keyboard_rpc_rejects_changed_window_owner() {
let session = auv_driver::open_local().unwrap();
let window = session.window().list().unwrap().into_iter().find(|window| window.process_id.is_some()).unwrap();
let service = LocalInputService { session };
for dry_run in [false, true] {
let error = service
.input_keyboard(Request::new(proto::InputKeyboardRequest {
target: Some(proto::InputTarget {
recipient: Some(proto::input_target::Recipient::Window(proto::Window {
r#ref: Some(proto::WindowRef {
window_id: window.reference.id.clone(),
}),
process_id: Some(window.process_id.unwrap() + 1),
..Default::default()
})),
}),
dry_run,
inputs: vec![keyboard_press_request("a", 1)],
}))
.await
.unwrap_err();
assert_eq!(error.code(), tonic::Code::FailedPrecondition);
assert_eq!(error.message(), "target window owner changed; resolve the target again");
}
}
#[tokio::test]
#[cfg(target_os = "macos")]
async fn keyboard_rpc_retains_failed_action_index_before_any_delivery() {
use prost::Message;
let service = LocalInputService {
session: auv_driver::open_local().unwrap(),
};
let error = service
.input_keyboard(Request::new(proto::InputKeyboardRequest {
target: Some(proto::InputTarget {
recipient: Some(proto::input_target::Recipient::Foreground(true)),
}),
inputs: vec![
keyboard_press_request("a", 1),
keyboard_press_request("not-a-key", 1),
],
dry_run: false,
}))
.await
.unwrap_err();
assert_eq!(error.code(), tonic::Code::InvalidArgument);
let progress = proto::KeyboardInputProgress::decode(error.details()).unwrap();
assert_eq!(progress.action_index, Some(1));
assert_eq!(progress.completed_presses, 0);
assert!(progress.completed.is_empty());
}
#[tokio::test]
#[cfg(target_os = "macos")]
async fn press_keys_rpc_uses_the_keyboard_repeat_validation_contract() {
use prost::Message;
let service = LocalInputService {
session: auv_driver::open_local().unwrap(),
};
let error = service
.press_keys(Request::new(proto::PressKeysRequest {
target: Some(proto::InputTarget {
recipient: Some(proto::input_target::Recipient::Foreground(true)),
}),
options: Some(proto::PressKeysOptions {
keys: vec!["a".into()],
count: Some(2),
..Default::default()
}),
policy: proto::InputPolicy::ForegroundPreferred as i32,
dry_run: true,
}))
.await
.unwrap_err();
assert_eq!(error.code(), tonic::Code::InvalidArgument);
assert!(error.message().contains("interval"));
assert_eq!(proto::KeyboardInputProgress::decode(error.details()).unwrap().action_index, Some(0));
}
#[tokio::test]
async fn keyboard_hold_rpc_rejects_missing_deadline_before_delivery() {
let service = LocalInputService {
session: auv_driver::open_local().unwrap(),
};
let target = proto::InputTarget {
recipient: Some(proto::input_target::Recipient::Foreground(true)),
};
let down = service
.key_down(Request::new(proto::KeyDownRequest {
target: Some(target.clone()),
keys: vec!["shift".into()],
policy: proto::InputPolicy::ForegroundPreferred as i32,
timeout: None,
}))
.await
.unwrap_err();
assert_eq!(down.code(), tonic::Code::InvalidArgument);
assert!(down.message().contains("timeout"));
let hold = service
.hold_keys(Request::new(proto::HoldKeysRequest {
target: Some(target),
keys: vec!["shift".into()],
policy: proto::InputPolicy::ForegroundPreferred as i32,
duration: None,
}))
.await
.unwrap_err();
assert_eq!(hold.code(), tonic::Code::InvalidArgument);
assert!(hold.message().contains("duration"));
}
#[tokio::test]
async fn keyboard_rpc_reports_wire_validation_position_without_delivering_prefix() {
use prost::Message;
let service = LocalInputService {
session: auv_driver::open_local().unwrap(),
};
let error = service
.input_keyboard(Request::new(proto::InputKeyboardRequest {
target: Some(proto::InputTarget {
recipient: Some(proto::input_target::Recipient::Foreground(true)),
}),
inputs: vec![
keyboard_press_request("a", 1),
proto::KeyboardInput { action: None },
],
dry_run: false,
}))
.await
.unwrap_err();
assert_eq!(error.code(), tonic::Code::InvalidArgument);
let progress = proto::KeyboardInputProgress::decode(error.details()).unwrap();
assert_eq!(progress.action_index, Some(1));
assert!(progress.completed.is_empty());
}
#[cfg(target_os = "linux")]
mod linux_keyboard_tests {
use super::super::*;
#[tokio::test]
async fn keyboard_rpc_reaches_linux_batch_validation_and_preserves_error_progress() {
use auv_driver::Driver;
let service = LocalInputService {
session: auv_driver::LocalDriver::new().open_local().unwrap(),
};
let press = |key: &str| proto::KeyboardInput {
action: Some(proto::keyboard_input::Action::Press(proto::KeyboardPress {
options: Some(proto::PressKeysOptions {
keys: vec![key.into()],
count: Some(1),
..Default::default()
}),
policy: proto::InputPolicy::ForegroundPreferred as i32,
})),
};
let target = Some(proto::InputTarget {
recipient: Some(proto::input_target::Recipient::Foreground(true)),
});
let result = service
.input_keyboard(Request::new(proto::InputKeyboardRequest {
target: target.clone(),
inputs: vec![press("a")],
dry_run: true,
}))
.await
.unwrap();
assert!(result.into_inner().actions.is_empty());
let error = service
.input_keyboard(Request::new(proto::InputKeyboardRequest {
target,
inputs: vec![press("a"), press("invalid-key")],
dry_run: false,
}))
.await
.unwrap_err();
assert_eq!(error.code(), tonic::Code::InvalidArgument);
use prost::Message;
let progress = proto::KeyboardInputProgress::decode(error.details()).unwrap();
assert_eq!(progress.action_index, Some(1));
assert_eq!(progress.completed_presses, 0);
assert!(progress.completed.is_empty());
}
}
#[test]
fn click_rpc_decodes_all_buttons_and_rejects_unknown_before_delivery() {
for (wire, button) in [
(proto::MouseButton::Unspecified, auv_driver::MouseButton::Left),
(proto::MouseButton::Left, auv_driver::MouseButton::Left),
(proto::MouseButton::Right, auv_driver::MouseButton::Right),
(proto::MouseButton::Middle, auv_driver::MouseButton::Middle),
] {
assert_eq!(
click_options_from_proto(Some(proto::ClickOptions {
button: wire as i32,
..Default::default()
}))
.unwrap()
.button,
button
);
assert_eq!(
screen_click_options_from_proto(Some(proto::ScreenClickOptions {
button: wire as i32,
..Default::default()
}))
.unwrap()
.0,
button
);
}
assert_eq!(click_options_from_proto(None).unwrap().button, auv_driver::MouseButton::Left);
assert_eq!(
click_options_from_proto(Some(proto::ClickOptions {
button: 99,
..Default::default()
}))
.unwrap_err()
.code(),
tonic::Code::InvalidArgument
);
assert_eq!(
screen_click_options_from_proto(Some(proto::ScreenClickOptions {
button: 99,
..Default::default()
}))
.unwrap_err()
.code(),
tonic::Code::InvalidArgument
);
}
#[test]
fn malformed_position_is_an_invalid_argument() {
let error = position_from_proto(proto::Position::default()).unwrap_err();
assert_eq!(error.code(), tonic::Code::InvalidArgument);
assert_eq!(error.message(), auv::protocol::position::DecodeError::InvalidCoordinateSpace.to_string());
}
#[tokio::test]
async fn slow_feedback_does_not_block_native_motion_or_completion() {
let (native_done_tx, native_done_rx) = tokio::sync::oneshot::channel();
let allow_feedback = std::sync::Arc::new(tokio::sync::Notify::new());
let gate = allow_feedback.clone();
let completed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let observed = completed.clone();
let relay = tokio::spawn(run_mouse_motion(
move |mut notify| {
let point = auv_driver::Point::new(1.0, 2.0);
assert!(notify(auv_driver::mouse_input::MotionEvent::Started {
point,
samples: 10_000,
duration: std::time::Duration::ZERO
}));
for index in 0..10_000 {
assert!(notify(auv_driver::mouse_input::MotionEvent::Progress {
index,
sample: auv_driver::MouseMotionSample {
point,
elapsed: std::time::Duration::ZERO
}
}));
}
let _ = native_done_tx.send(());
Ok((point, auv_driver::InputActionResult::single_success(auv_driver::InputDeliveryPath::Noop)))
},
move |event| {
let gate = gate.clone();
let observed = observed.clone();
async move {
match event.unwrap() {
MouseMotionEvent::Started(_) => gate.notified().await,
MouseMotionEvent::Completed(_) => observed.store(true, std::sync::atomic::Ordering::Release),
_ => {}
}
Ok(())
}
},
));
tokio::time::timeout(std::time::Duration::from_secs(5), native_done_rx).await.unwrap().unwrap();
assert!(!relay.is_finished());
allow_feedback.notify_one();
relay.await.unwrap();
assert!(completed.load(std::sync::atomic::Ordering::Acquire));
}
#[tokio::test]
async fn dropped_feedback_relay_wakes_and_releases_native_hold() {
struct Receiver {
pressed: tokio::sync::Notify,
released: tokio::sync::Notify,
}
impl auv_driver::mouse_input::MouseBackend for Receiver {
fn move_to(&self, _: auv_driver::Point, _: Option<auv_driver::MouseButton>) -> auv_driver::DriverResult<auv_driver::InputActionResult> {
Ok(auv_driver::InputActionResult::single_success(auv_driver::InputDeliveryPath::Noop))
}
fn button(
&self,
_: auv_driver::Point,
_: auv_driver::MouseButton,
down: bool,
) -> auv_driver::DriverResult<auv_driver::InputActionResult> {
if down {
self.pressed.notify_one();
} else {
self.released.notify_one();
}
Ok(auv_driver::InputActionResult::single_success(auv_driver::InputDeliveryPath::Noop))
}
}
let receiver = std::sync::Arc::new(Receiver {
pressed: tokio::sync::Notify::new(),
released: tokio::sync::Notify::new(),
});
let backend = receiver.clone();
let relay = tokio::spawn(run_mouse_motion(
move |mut notify| {
let coordinator = std::sync::Arc::new(auv_driver::mouse_input::MouseCoordinator::default());
let point = auv_driver::Point::new(1.0, 2.0);
notify(auv_driver::mouse_input::MotionEvent::Started {
point,
samples: 1,
duration: std::time::Duration::from_secs(3600),
});
coordinator.hold(0, point, auv_driver::MouseButton::Left, std::time::Duration::from_secs(3600), backend).map(|action| (point, action))
},
|_| std::future::pending::<Result<(), ()>>(),
));
tokio::time::timeout(std::time::Duration::from_secs(5), receiver.pressed.notified()).await.unwrap();
relay.abort();
assert!(relay.await.unwrap_err().is_cancelled());
tokio::time::timeout(std::time::Duration::from_secs(5), receiver.released.notified()).await.unwrap();
}