use std::sync::Arc;
use std::time::Duration;
use waydriver::{CompositorRuntime, Error, InputBackend, Session, SessionConfig};
use waydriver_capture_mutter::MutterCapture;
use waydriver_compositor_mutter::{MutterCompositor, MutterState};
use waydriver_input_mutter::MutterInput;
fn extract_png(raw: &[u8]) -> Vec<u8> {
let png_start = raw
.windows(4)
.position(|w| w == [0x89, b'P', b'N', b'G'])
.expect("no PNG magic found in screenshot data");
raw[png_start..].to_vec()
}
fn diff_png_pixels(a: &[u8], b: &[u8]) -> anyhow::Result<usize> {
let img1 = image::load_from_memory(a)?.to_rgba8();
let img2 = image::load_from_memory(b)?.to_rgba8();
Ok(img1
.pixels()
.zip(img2.pixels())
.filter(|(p1, p2)| p1 != p2)
.count())
}
async fn kill(session: Arc<Session>) -> anyhow::Result<()> {
let inner = Arc::try_unwrap(session).map_err(|_| {
anyhow::anyhow!("session arc still referenced when killing — a Locator outlived the test")
})?;
inner.kill().await?;
Ok(())
}
fn fixture_binary() -> std::path::PathBuf {
let mut path = std::env::current_exe()
.expect("current_exe")
.parent() .unwrap()
.parent() .unwrap()
.to_path_buf();
path.push("waydriver-fixture-gtk");
path
}
async fn start_fixture_session(section: &str) -> anyhow::Result<(Arc<Session>, Arc<MutterState>)> {
let mut compositor = MutterCompositor::new();
compositor.start(None).await?;
let state = compositor.state();
let input = MutterInput::new(state.clone());
let capture = MutterCapture::new(state.clone());
let fixture_bin = fixture_binary();
assert!(
fixture_bin.exists(),
"fixture binary missing at {fixture_bin:?}; run `cargo build -p waydriver-fixture-gtk` first"
);
let session = Session::start(
Box::new(compositor),
Box::new(input),
Box::new(capture),
SessionConfig {
command: fixture_bin.to_string_lossy().into_owned(),
args: vec![format!("--section={section}")],
cwd: None,
app_name: "waydriver-fixture-gtk".into(),
video_output: None,
video_bitrate: None,
},
)
.await?;
tokio::time::sleep(Duration::from_secs(1)).await;
Ok((Arc::new(session), state))
}
async fn assert_widgets_exist(session: &Arc<Session>, section: &str, expected: &[&str]) {
let tree = session.dump_tree().await.expect("dump_tree");
eprintln!(
"── fixture tree ({section}) ─────────────────────────────\n{tree}\n\
────────────────────────────────────────────────────────────"
);
for expected_name in expected {
let count = session
.locate(&format!("//*[@name='{expected_name}']"))
.count()
.await
.unwrap_or(usize::MAX);
assert!(
count >= 1,
"expected named widget '{expected_name}' to be in the tree (count: {count})"
);
}
}
fn init_tracing() {
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.try_init()
.ok();
}
#[tokio::test]
#[ignore = "spawns mutter + pipewire; run manually with --ignored"]
async fn fixture_exposes_gtk4_widgets() -> anyhow::Result<()> {
init_tracing();
let (session, _state) = start_fixture_session("gtk4").await?;
assert_widgets_exist(
&session,
"gtk4",
&[
"primary-button",
"mode-toggle",
"agree-check",
"text-entry",
"main-menu",
"open-dialog",
],
)
.await;
kill(session).await?;
Ok(())
}
#[tokio::test]
#[ignore = "spawns mutter + pipewire; run manually with --ignored"]
async fn fixture_exposes_adw_widgets() -> anyhow::Result<()> {
init_tracing();
let (session, _state) = start_fixture_session("adw").await?;
assert_widgets_exist(
&session,
"adw",
&[
"adw-prefs-group",
"adw-entry-row",
"adw-combo-row",
"adw-switch-row",
"adw-action-row",
"open-adw-dialog",
"main-menu",
],
)
.await;
kill(session).await?;
Ok(())
}
#[tokio::test]
#[ignore = "spawns mutter + pipewire; run manually with --ignored"]
async fn fixture_exposes_dnd_widgets() -> anyhow::Result<()> {
init_tracing();
let (session, _state) = start_fixture_session("dnd").await?;
assert_widgets_exist(
&session,
"dnd",
&["drag-source", "drop-target", "drop-status", "main-menu"],
)
.await;
kill(session).await?;
Ok(())
}
#[tokio::test]
#[ignore = "spawns mutter + pipewire; run manually with --ignored"]
async fn fixture_click_emits_stdout_event() -> anyhow::Result<()> {
init_tracing();
let (session, _state) = start_fixture_session("gtk4").await?;
let cursor = session.stdout_cursor();
session
.locate("//Button[@name='primary-button']")
.click()
.await?;
let line = session
.wait_for_stdout_line(
cursor,
|l| l.contains("clicked primary-button"),
Duration::from_secs(3),
)
.await?;
eprintln!("observed stdout event: {line}");
assert!(
line.starts_with("fixture-event: clicked primary-button"),
"unexpected line: {line}"
);
kill(session).await?;
Ok(())
}
#[tokio::test]
#[ignore = "spawns mutter + pipewire; run manually with --ignored"]
async fn fixture_toggle_changes_screenshot() -> anyhow::Result<()> {
init_tracing();
let (session, _state) = start_fixture_session("gtk4").await?;
let baseline = extract_png(&session.take_screenshot().await?);
assert!(baseline.len() > 1000, "baseline screenshot too small");
let cursor = session.stdout_cursor();
session
.locate("//ToggleButton[@name='mode-toggle']")
.click()
.await?;
session
.wait_for_stdout_line(
cursor,
|l| l.contains("toggled mode-toggle"),
Duration::from_secs(3),
)
.await?;
tokio::time::sleep(Duration::from_millis(200)).await;
let after = extract_png(&session.take_screenshot().await?);
let diff_pixels = diff_png_pixels(&baseline, &after)?;
eprintln!("pixel diff: {diff_pixels}");
kill(session).await?;
assert!(
diff_pixels > 100,
"screenshot should change after toggling mode-toggle (only {diff_pixels} pixels differ)"
);
Ok(())
}
#[tokio::test]
#[ignore = "spawns mutter + pipewire; run manually with --ignored"]
async fn fixture_tree_and_locator_features() -> anyhow::Result<()> {
init_tracing();
let (session, _state) = start_fixture_session("gtk4").await?;
let tree = session.dump_tree().await?;
assert!(!tree.is_empty(), "accessibility tree should not be empty");
assert!(
tree.contains("<?xml"),
"tree should start with XML declaration, got:\n{tree}"
);
assert!(
tree.contains("<Button"),
"tree should contain Button elements, got:\n{tree}"
);
assert!(
session
.locate("//Button[@name='primary-button']")
.count()
.await?
>= 1,
"should find primary-button"
);
let err = session
.locate("//Button[@name='nonexistent_xyz_12345']")
.with_timeout(Duration::from_millis(250))
.click()
.await
.unwrap_err();
assert!(
matches!(err, Error::ElementNotFound { .. }),
"expected ElementNotFound, got: {err}"
);
session
.locate("//Button[@name='primary-button']")
.wait_for_visible()
.await?;
let button_count = session.locate("//Button").count().await?;
assert!(button_count > 0, "expected some buttons, got 0");
session
.locate("//Button")
.wait_for_count(button_count)
.await?;
kill(session).await?;
Ok(())
}
#[tokio::test]
#[ignore = "spawns mutter + pipewire; run manually with --ignored"]
async fn fixture_keyboard_chord_dispatches_modifiers() -> anyhow::Result<()> {
init_tracing();
let (session, _state) = start_fixture_session("gtk4").await?;
session
.wait_for_stdout_line(
0,
|l| l.contains("focus-acquired text-entry"),
Duration::from_secs(5),
)
.await?;
let mut routed = false;
let warmup_start = session.stdout_cursor();
for _ in 0..15 {
session.press_chord("a").await?;
if session
.wait_for_stdout_line(
warmup_start,
|l| l.contains("text-changed text-entry"),
Duration::from_millis(250),
)
.await
.is_ok()
{
routed = true;
break;
}
}
assert!(
routed,
"keystrokes never reached text-entry despite focus-acquired"
);
tokio::time::sleep(Duration::from_millis(300)).await;
session.press_chord("Ctrl+A").await?;
session.press_chord("BackSpace").await?;
tokio::time::sleep(Duration::from_millis(300)).await;
let cursor = session.stdout_cursor();
for ch in ['h', 'i'] {
session.press_chord(&ch.to_string()).await?;
}
session
.wait_for_stdout_line(
cursor,
|l| l.contains("text-changed text-entry") && l.contains("\"hi\""),
Duration::from_secs(3),
)
.await?;
let shift_cursor = session.stdout_cursor();
session.press_chord("Shift+j").await?;
session
.wait_for_stdout_line(
shift_cursor,
|l| l.contains("text-changed text-entry") && l.contains("\"hiJ\""),
Duration::from_secs(3),
)
.await?;
let unstuck_cursor = session.stdout_cursor();
session.press_chord("k").await?;
session
.wait_for_stdout_line(
unstuck_cursor,
|l| l.contains("text-changed text-entry") && l.contains("\"hiJk\""),
Duration::from_secs(3),
)
.await?;
kill(session).await?;
Ok(())
}
#[tokio::test]
#[ignore = "spawns mutter + pipewire; run manually with --ignored"]
async fn fixture_main_menu_opens_auto_waits() -> anyhow::Result<()> {
init_tracing();
let (session, _state) = start_fixture_session("gtk4").await?;
session
.locate("//ToggleButton[@name='main-menu']")
.click()
.await?;
session.press_keysym(0xffe1).await?;
session
.locate("//Button[@name='main-menu' and @expanded='true']")
.wait_for_visible()
.await?;
let tree = session.dump_tree().await?;
eprintln!(
"── tree after opening menu ─────────────────────────────────\n{tree}\n\
────────────────────────────────────────────────────────────"
);
kill(session).await?;
Ok(())
}
#[tokio::test]
#[ignore = "spawns mutter + pipewire; run manually with --ignored"]
async fn fixture_pointer_input_operations() -> anyhow::Result<()> {
init_tracing();
let (session, state) = start_fixture_session("gtk4").await?;
assert!(
session.wayland_display().starts_with("wayland-wd-"),
"unexpected display name: {}",
session.wayland_display()
);
let pointer = MutterInput::new(state);
pointer.pointer_motion_relative(100.0, 100.0).await?;
tokio::time::sleep(Duration::from_millis(50)).await;
pointer.pointer_button(0x110).await?;
tokio::time::sleep(Duration::from_millis(200)).await;
pointer.pointer_motion_relative(-50.0, -50.0).await?;
tokio::time::sleep(Duration::from_millis(50)).await;
let screenshot = session.take_screenshot().await?;
let png = extract_png(&screenshot);
assert!(png.len() > 1000, "screenshot after pointer ops too small");
kill(session).await?;
Ok(())
}