use chromiumoxide::browser::{Browser, BrowserConfig, HeadlessMode};
use chromiumoxide::layout::Point;
use futures_util::StreamExt;
use serde_json::Value;
use std::path::PathBuf;
use tokio::time::{timeout, Duration};
fn try_browser_config() -> Option<BrowserConfig> {
BrowserConfig::builder().build().ok()
}
fn temp_profile_dir(test_name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"chromey-mouse-{test_name}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
std::fs::create_dir_all(&dir).expect("create temp profile dir");
dir
}
fn headless_config(test_name: &str) -> BrowserConfig {
let profile_dir = temp_profile_dir(test_name);
BrowserConfig::builder()
.user_data_dir(&profile_dir)
.arg("--no-first-run")
.arg("--no-default-browser-check")
.arg("--disable-extensions")
.headless_mode(HeadlessMode::True)
.launch_timeout(Duration::from_secs(30))
.build()
.expect("headless browser config")
}
async fn launch(config: BrowserConfig) -> Browser {
let (browser, mut handler) = Browser::launch(config).await.expect("launch browser");
tokio::spawn(async move { while let Some(_event) = handler.next().await {} });
browser
}
const INSTALL_TRAIL_JS: &str = "\
window.__mouseTrail = [];\
document.addEventListener('mousemove', (e) => {\
window.__mouseTrail.push([e.clientX, e.clientY]);\
}, { passive: true });\
true";
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn move_mouse_smooth_delivers_path_events_end_to_end() {
if try_browser_config().is_none() {
eprintln!("skipping: no Chrome/Chromium executable found");
return;
}
let browser = launch(headless_config("smooth-path")).await;
let page = timeout(Duration::from_secs(30), browser.new_page("about:blank"))
.await
.expect("new_page should not time out")
.expect("new_page should resolve");
timeout(Duration::from_secs(30), page.wait_for_dom_content_loaded())
.await
.expect("wait_for_dom_content_loaded should not time out")
.expect("wait_for_dom_content_loaded should succeed");
page.evaluate(INSTALL_TRAIL_JS)
.await
.expect("install mousemove listener");
page.move_mouse(Point::new(0.0, 0.0))
.await
.expect("seed move should succeed");
tokio::time::sleep(Duration::from_millis(100)).await;
page.evaluate("window.__mouseTrail = []")
.await
.expect("reset trail");
let target = Point::new(420.0, 300.0);
page.move_mouse_smooth(target)
.await
.expect("move_mouse_smooth should succeed");
let trail = timeout(Duration::from_secs(5), async {
let mut prev_len = 0usize;
let mut stable_ticks = 0u32;
loop {
let len_val: Value = page
.evaluate("window.__mouseTrail.length")
.await
.expect("evaluate length")
.into_value()
.expect("length is number");
let len = len_val.as_u64().unwrap_or(0) as usize;
if len == prev_len && len > 0 {
stable_ticks += 1;
if stable_ticks >= 3 {
break;
}
} else {
stable_ticks = 0;
}
prev_len = len;
tokio::time::sleep(Duration::from_millis(50)).await;
}
let trail: Value = page
.evaluate("JSON.stringify(window.__mouseTrail)")
.await
.expect("evaluate trail")
.into_value()
.expect("trail is string");
let s = trail.as_str().expect("trail as str");
serde_json::from_str::<Vec<(f64, f64)>>(s).expect("parse trail")
})
.await
.expect("trail should stabilize within 5s");
assert!(
trail.len() >= 4,
"smooth path should generate ≥4 intermediate mousemove events, got {}",
trail.len()
);
let (lx, ly) = *trail.last().expect("non-empty");
assert!(
(lx - target.x).abs() <= 1.0 && (ly - target.y).abs() <= 1.0,
"last observed point {:?} should match target ({}, {})",
(lx, ly),
target.x,
target.y
);
let tracked = page.mouse_position();
assert!(
(tracked.x - target.x).abs() <= 1.0 && (tracked.y - target.y).abs() <= 1.0,
"SmartMouse tracked position {:?} should match target",
tracked,
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn move_mouse_smooth_wall_clock_tracks_step_delays() {
if try_browser_config().is_none() {
eprintln!("skipping: no Chrome/Chromium executable found");
return;
}
let browser = launch(headless_config("smooth-timing")).await;
let page = timeout(Duration::from_secs(30), browser.new_page("about:blank"))
.await
.expect("new_page")
.expect("resolved");
page.move_mouse(Point::new(0.0, 0.0))
.await
.expect("seed move");
let start = std::time::Instant::now();
page.move_mouse_smooth(Point::new(500.0, 400.0))
.await
.expect("smooth move");
let single = start.elapsed();
assert!(
single < Duration::from_secs(3),
"move_mouse_smooth took {single:?} — fire-and-forget regression?"
);
let start2 = std::time::Instant::now();
page.move_mouse_smooth(Point::new(50.0, 50.0))
.await
.expect("smooth back");
page.move_mouse_smooth(Point::new(500.0, 400.0))
.await
.expect("smooth forth");
let double = start2.elapsed();
assert!(
double < single * 3,
"two move_mouse_smooth calls took {double:?} vs single {single:?} — drift regression?"
);
}