#![cfg(target_os = "linux")]
use std::sync::{Mutex, MutexGuard};
use std::time::Duration;
use wdotool_test_harness::{HarnessError, HeadlessSway, Observer, Prime};
static SUITE_LOCK: Mutex<()> = Mutex::new(());
fn fresh_session() -> Option<(HeadlessSway, Observer, MutexGuard<'static, ()>)> {
let guard = SUITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let sway = match HeadlessSway::start() {
Ok(s) => s,
Err(HarnessError::SwayUnavailable(_)) => {
println!(
"skipping round-trip test: sway is not installed. \
install with `pacman -S sway` (Arch) or `apt install sway` (Debian/Ubuntu)."
);
return None;
}
Err(other) => panic!("sway failed to start: {other}"),
};
let observer = sway.spawn_observer().expect("spawn observer");
observer
.wait_for_ready(Duration::from_secs(30))
.expect("observer reached ready");
let _ = observer.collect_events(Duration::from_millis(50));
Some((sway, observer, guard))
}
fn lines_starting_with(events: &[String], prefix: &str) -> Vec<String> {
events
.iter()
.filter(|l| l.starts_with(prefix))
.cloned()
.collect()
}
const KEY_LEFTCTRL: u32 = 29;
const KEY_A: u32 = 30;
const KEY_LEFTSHIFT: u32 = 42;
fn parse_key_line(line: &str) -> Option<(u32, &str, &str)> {
let mut parts = line.split_whitespace();
if parts.next()? != "key" {
return None;
}
let kc: u32 = parts.next()?.parse().ok()?;
let name = parts.next()?;
let action = parts.next()?;
Some((kc, name, action))
}
#[test]
fn observer_reaches_ready_inside_headless_sway() {
let Some((_sway, _observer, _guard)) = fresh_session() else {
return;
};
}
#[test]
fn prime_keeps_seat_capabilities_up_across_calls() {
let Some((sway, observer, _guard)) = fresh_session() else {
return;
};
let _prime: Prime = sway.spawn_prime().expect("spawn prime");
let _ = observer.collect_events(Duration::from_millis(200));
for _ in 0..3 {
let out = sway.run_wdotool(&["key", "a"]).expect("run wdotool");
assert!(out.status.success(), "wdotool failed: {out:?}");
}
let events = observer.collect_events(Duration::from_millis(500));
let cap_drops_to_zero = events.iter().filter(|l| l == &"seat_caps 0x0").count();
assert_eq!(
cap_drops_to_zero, 0,
"seat caps dropped to 0 while prime was running. events: {events:?}"
);
let presses = events
.iter()
.filter(|l| l.starts_with("key ") && l.ends_with(" press"))
.count();
let releases = events
.iter()
.filter(|l| l.starts_with("key ") && l.ends_with(" release"))
.count();
assert_eq!(presses, 3, "expected 3 presses, got {presses}: {events:?}");
assert_eq!(
releases, 3,
"expected 3 releases, got {releases}: {events:?}"
);
}
#[test]
fn replay_keyboard_trace_round_trips_to_observer() {
use std::io::Write as _;
let Some((sway, observer, _guard)) = fresh_session() else {
return;
};
let dir = tempfile::tempdir().unwrap();
let trace_path = dir.path().join("trace.json");
let mut f = std::fs::File::create(&trace_path).unwrap();
f.write_all(
br#"[
{"kind":"key","t_ms":0,"chord":"a"},
{"kind":"gap","t_ms":1,"ms":10},
{"kind":"key","t_ms":11,"chord":"ctrl+l"},
{"kind":"gap","t_ms":12,"ms":10},
{"kind":"key","t_ms":22,"chord":"Return"}
]"#,
)
.unwrap();
drop(f);
let out = sway
.run_wdotool(&["replay", trace_path.to_str().unwrap()])
.expect("run wdotool");
assert!(out.status.success(), "wdotool replay failed: {out:?}");
let events = observer.collect_events(Duration::from_millis(400));
let keys = lines_starting_with(&events, "key ");
const KEY_L: u32 = 38;
const KEY_RETURN: u32 = 28;
let parsed: Vec<(u32, &str, &str)> = keys
.iter()
.map(|l| parse_key_line(l).unwrap_or_else(|| panic!("parse {l:?}")))
.collect();
let expected = [
(KEY_A, "press"),
(KEY_A, "release"),
(KEY_LEFTCTRL, "press"),
(KEY_L, "press"),
(KEY_L, "release"),
(KEY_LEFTCTRL, "release"),
(KEY_RETURN, "press"),
(KEY_RETURN, "release"),
];
assert_eq!(
parsed.len(),
expected.len(),
"expected {} key events, got {}: {events:?}",
expected.len(),
parsed.len()
);
for (i, (exp_kc, exp_act)) in expected.iter().enumerate() {
assert_eq!(
parsed[i].0, *exp_kc,
"keys[{i}] keycode mismatch: {}",
keys[i]
);
assert_eq!(
parsed[i].2, *exp_act,
"keys[{i}] action mismatch: {}",
keys[i]
);
}
}
#[test]
fn key_a_round_trips_through_wlr_protocols_backend() {
let Some((sway, observer, _guard)) = fresh_session() else {
return;
};
let out = sway.run_wdotool(&["key", "a"]).expect("run wdotool");
assert!(out.status.success(), "wdotool failed: {out:?}");
let events = observer.collect_events(Duration::from_millis(300));
let keys = lines_starting_with(&events, "key ");
assert_eq!(keys.len(), 2, "expected exactly press+release: {events:?}");
let (kc0, _, action0) = parse_key_line(&keys[0]).expect("parse press");
let (kc1, _, action1) = parse_key_line(&keys[1]).expect("parse release");
assert_eq!(kc0, KEY_A, "press keycode: {}", keys[0]);
assert_eq!(action0, "press", "first action: {}", keys[0]);
assert_eq!(kc1, KEY_A, "release keycode: {}", keys[1]);
assert_eq!(action1, "release", "second action: {}", keys[1]);
}
#[test]
fn key_ctrl_shift_a_emits_modifiers_in_xdotool_order() {
let Some((sway, observer, _guard)) = fresh_session() else {
return;
};
let out = sway
.run_wdotool(&["key", "ctrl+shift+a"])
.expect("run wdotool");
assert!(out.status.success(), "wdotool failed: {out:?}");
let events = observer.collect_events(Duration::from_millis(300));
let keys = lines_starting_with(&events, "key ");
assert_eq!(keys.len(), 6, "events: {events:?}");
let parsed: Vec<(u32, &str, &str)> = keys
.iter()
.map(|l| parse_key_line(l).unwrap_or_else(|| panic!("parse {l:?}")))
.collect();
let expected = [
(KEY_LEFTCTRL, "press"),
(KEY_LEFTSHIFT, "press"),
(KEY_A, "press"),
(KEY_A, "release"),
(KEY_LEFTSHIFT, "release"),
(KEY_LEFTCTRL, "release"),
];
for (i, (exp_kc, exp_act)) in expected.iter().enumerate() {
assert_eq!(parsed[i].0, *exp_kc, "keys[{i}] keycode: {}", keys[i]);
assert_eq!(parsed[i].2, *exp_act, "keys[{i}] action: {}", keys[i]);
}
}
#[ignore = "wlr-protocols backend doesn't preserve held-key state across process invocations"]
#[test]
fn keydown_then_keyup_round_trip_holds_then_releases() {
let Some((sway, observer, _guard)) = fresh_session() else {
return;
};
let out = sway.run_wdotool(&["keydown", "a"]).expect("run wdotool");
assert!(out.status.success(), "keydown failed: {out:?}");
let post_keydown = observer.collect_events(Duration::from_millis(200));
let keys = lines_starting_with(&post_keydown, "key ");
assert_eq!(
keys.len(),
1,
"keydown should emit exactly press: {post_keydown:?}"
);
assert!(keys[0].contains(" a ") && keys[0].ends_with(" press"));
let out = sway.run_wdotool(&["keyup", "a"]).expect("run wdotool");
assert!(out.status.success(), "keyup failed: {out:?}");
let post_keyup = observer.collect_events(Duration::from_millis(200));
let keys = lines_starting_with(&post_keyup, "key ");
assert_eq!(
keys.len(),
1,
"keyup should emit exactly release: {post_keyup:?}"
);
assert!(keys[0].contains(" a ") && keys[0].ends_with(" release"));
}
#[test]
fn type_hello_arrives_as_individual_characters() {
let Some((sway, observer, _guard)) = fresh_session() else {
return;
};
let out = sway
.run_wdotool(&["type", "--delay", "0", "hello"])
.expect("run wdotool");
assert!(out.status.success(), "wdotool failed: {out:?}");
let events = observer.collect_events(Duration::from_millis(800));
assert!(
events.iter().any(|l| l.starts_with("keymap_received ")),
"expected at least one keymap_received during type: {events:?}"
);
let key_lines = lines_starting_with(&events, "key ");
let presses: Vec<_> = key_lines
.iter()
.filter_map(|l| parse_key_line(l))
.filter(|(_, _, action)| *action == "press")
.collect();
let releases: Vec<_> = key_lines
.iter()
.filter_map(|l| parse_key_line(l))
.filter(|(_, _, action)| *action == "release")
.collect();
assert_eq!(
presses.len(),
5,
"expected 5 press events for 'hello': {events:?}"
);
assert_eq!(
releases.len(),
5,
"expected 5 release events for 'hello': {events:?}"
);
}
#[ignore = "sway-headless cursor doesn't fire wl_pointer events for virtual_pointer.motion; see file header"]
#[test]
fn mousemove_absolute_lands_pointer_at_coords() {
let Some((sway, observer, _guard)) = fresh_session() else {
return;
};
let out = sway
.run_wdotool(&["mousemove", "100", "80"])
.expect("run wdotool");
assert!(out.status.success(), "wdotool failed: {out:?}");
let events = observer.collect_events(Duration::from_millis(300));
let motions = lines_starting_with(&events, "pointer_motion ");
assert!(
!motions.is_empty(),
"expected at least one pointer_motion: {events:?}"
);
let last = motions.last().unwrap();
let mut parts = last.split_whitespace();
parts.next(); let x: f64 = parts.next().unwrap().parse().unwrap();
let y: f64 = parts.next().unwrap().parse().unwrap();
assert!(
(x - 100.0).abs() < 5.0 && (y - 80.0).abs() < 5.0,
"expected pointer near (100, 80), got ({x}, {y}). All motions: {motions:?}"
);
}
#[ignore = "sway-headless cursor doesn't fire wl_pointer events for virtual_pointer.motion; see file header"]
#[test]
fn mousemove_relative_emits_motion_delta() {
let Some((sway, observer, _guard)) = fresh_session() else {
return;
};
sway.run_wdotool(&["mousemove", "200", "150"])
.expect("run wdotool");
let _ = observer.collect_events(Duration::from_millis(150));
let out = sway
.run_wdotool(&["mousemove", "--relative", "20", "-10"])
.expect("run wdotool");
assert!(out.status.success(), "wdotool failed: {out:?}");
let events = observer.collect_events(Duration::from_millis(300));
let motions = lines_starting_with(&events, "pointer_motion ");
assert!(
!motions.is_empty(),
"expected at least one pointer_motion: {events:?}"
);
let last = motions.last().unwrap();
let mut parts = last.split_whitespace();
parts.next();
let x: f64 = parts.next().unwrap().parse().unwrap();
let y: f64 = parts.next().unwrap().parse().unwrap();
assert!(
(x - 220.0).abs() < 5.0 && (y - 140.0).abs() < 5.0,
"expected pointer near (220, 140) after relative move, got ({x}, {y})"
);
}
#[ignore = "sway-headless cursor doesn't fire wl_pointer events for virtual_pointer.motion; see file header"]
#[test]
fn click_1_emits_left_button_press_release() {
let Some((sway, observer, _guard)) = fresh_session() else {
return;
};
let out = sway.run_wdotool(&["click", "1"]).expect("run wdotool");
assert!(out.status.success(), "wdotool failed: {out:?}");
let events = observer.collect_events(Duration::from_millis(300));
let buttons = lines_starting_with(&events, "pointer_button ");
assert_eq!(buttons.len(), 2, "expected press+release: {events:?}");
assert!(
buttons[0].starts_with("pointer_button 272 ") && buttons[0].ends_with(" press"),
"press: {}",
buttons[0]
);
assert!(
buttons[1].starts_with("pointer_button 272 ") && buttons[1].ends_with(" release"),
"release: {}",
buttons[1]
);
}
#[ignore = "sway-headless cursor doesn't fire wl_pointer events for virtual_pointer.motion; see file header"]
#[test]
fn mousedown_then_mouseup_emit_press_then_release() {
let Some((sway, observer, _guard)) = fresh_session() else {
return;
};
sway.run_wdotool(&["mousedown", "1"]).expect("run wdotool");
let post_down = observer.collect_events(Duration::from_millis(200));
let buttons = lines_starting_with(&post_down, "pointer_button ");
assert_eq!(
buttons.len(),
1,
"mousedown should emit only press: {post_down:?}"
);
assert!(
buttons[0].ends_with(" press"),
"expected press, got: {}",
buttons[0]
);
sway.run_wdotool(&["mouseup", "1"]).expect("run wdotool");
let post_up = observer.collect_events(Duration::from_millis(200));
let buttons = lines_starting_with(&post_up, "pointer_button ");
assert_eq!(
buttons.len(),
1,
"mouseup should emit only release: {post_up:?}"
);
assert!(
buttons[0].ends_with(" release"),
"expected release, got: {}",
buttons[0]
);
}
#[ignore = "sway-headless cursor doesn't fire wl_pointer events for virtual_pointer.motion; see file header"]
#[test]
fn scroll_positive_dy_emits_vertical_axis_with_positive_value() {
let Some((sway, observer, _guard)) = fresh_session() else {
return;
};
let out = sway
.run_wdotool(&["scroll", "0", "3"])
.expect("run wdotool");
assert!(out.status.success(), "wdotool failed: {out:?}");
let events = observer.collect_events(Duration::from_millis(300));
let axis = events
.iter()
.find(|l| l.starts_with("pointer_axis vertical "))
.unwrap_or_else(|| panic!("no vertical axis event in: {events:?}"));
let value: f64 = axis.split_whitespace().nth(2).unwrap().parse().unwrap();
assert!(
value > 0.0,
"expected positive vertical scroll, got {value}"
);
}
#[ignore = "sway-headless cursor doesn't fire wl_pointer events for virtual_pointer.motion; see file header"]
#[test]
fn scroll_negative_dy_emits_vertical_axis_with_negative_value() {
let Some((sway, observer, _guard)) = fresh_session() else {
return;
};
let out = sway
.run_wdotool(&["scroll", "0", "-2"])
.expect("run wdotool");
assert!(out.status.success(), "wdotool failed: {out:?}");
let events = observer.collect_events(Duration::from_millis(300));
let axis = events
.iter()
.find(|l| l.starts_with("pointer_axis vertical "))
.unwrap_or_else(|| panic!("no vertical axis event in: {events:?}"));
let value: f64 = axis.split_whitespace().nth(2).unwrap().parse().unwrap();
assert!(
value < 0.0,
"expected negative vertical scroll, got {value}"
);
}
#[ignore = "sway-headless cursor doesn't fire wl_pointer events for virtual_pointer.motion; see file header"]
#[test]
fn scroll_horizontal_axis_routes_to_horizontal_label() {
let Some((sway, observer, _guard)) = fresh_session() else {
return;
};
let out = sway
.run_wdotool(&["scroll", "2", "0"])
.expect("run wdotool");
assert!(out.status.success(), "wdotool failed: {out:?}");
let events = observer.collect_events(Duration::from_millis(300));
assert!(
events
.iter()
.any(|l| l.starts_with("pointer_axis horizontal ")),
"expected horizontal axis event: {events:?}"
);
assert!(
!events
.iter()
.any(|l| l.starts_with("pointer_axis vertical ")),
"unexpected vertical axis event: {events:?}"
);
}