use chromiumoxide::cdp::browser_protocol::input::{
DispatchMouseEventParams, DispatchMouseEventType,
};
use chromiumoxide::Page;
use rand::{rngs::StdRng, Rng, SeedableRng};
use std::sync::OnceLock;
use std::time::Duration;
#[derive(Debug, Clone, Copy)]
pub struct Trace {
pub name: &'static str,
pub points: &'static [TracePoint],
}
#[derive(Debug, Clone, Copy)]
pub struct TracePoint {
pub x_norm: f32,
pub y_norm: f32,
pub dt_ms: u16,
}
const fn pt(x: f32, y: f32, dt: u16) -> TracePoint {
TracePoint {
x_norm: x,
y_norm: y,
dt_ms: dt,
}
}
pub const DIRECT_FAST: &[TracePoint] = &[
pt(0.000, 0.000, 0),
pt(0.018, 0.001, 14),
pt(0.052, 0.004, 16),
pt(0.108, 0.009, 18),
pt(0.182, 0.016, 19),
pt(0.272, 0.022, 20),
pt(0.372, 0.026, 22),
pt(0.474, 0.026, 21),
pt(0.566, 0.024, 24),
pt(0.642, 0.020, 26),
pt(0.700, 0.015, 38),
pt(0.748, 0.010, 22),
pt(0.798, 0.006, 21),
pt(0.848, 0.003, 20),
pt(0.892, 0.001, 18),
pt(0.928, 0.000, 17),
pt(0.958, -0.001, 16),
pt(0.978, -0.001, 14),
pt(0.992, 0.000, 12),
pt(1.000, 0.000, 10),
];
pub const SLOW_CAREFUL: &[TracePoint] = &[
pt(0.000, 0.000, 0),
pt(0.012, 0.003, 22),
pt(0.030, 0.006, 24),
pt(0.058, 0.009, 26),
pt(0.094, 0.011, 28),
pt(0.138, 0.012, 30),
pt(0.186, 0.012, 30),
pt(0.236, 0.011, 28),
pt(0.286, 0.009, 26),
pt(0.334, 0.007, 24),
pt(0.380, 0.005, 22),
pt(0.422, 0.003, 20),
pt(0.460, 0.002, 22),
pt(0.488, 0.004, 28),
pt(0.512, 0.002, 30),
pt(0.538, 0.005, 32),
pt(0.566, 0.003, 30),
pt(0.598, 0.006, 28),
pt(0.634, 0.004, 26),
pt(0.674, 0.003, 30),
pt(0.714, 0.002, 32),
pt(0.754, 0.001, 32),
pt(0.794, 0.000, 32),
pt(0.832, -0.001, 30),
pt(0.866, -0.001, 28),
pt(0.896, 0.000, 26),
pt(0.922, 0.001, 24),
pt(0.946, 0.001, 22),
pt(0.966, 0.000, 20),
pt(0.982, 0.000, 18),
pt(0.994, 0.000, 16),
pt(1.000, 0.000, 14),
];
pub const ARC_ABOVE: &[TracePoint] = &[
pt(0.000, 0.000, 0),
pt(0.022, 0.012, 16),
pt(0.058, 0.028, 18),
pt(0.106, 0.046, 20),
pt(0.166, 0.066, 22),
pt(0.232, 0.084, 24),
pt(0.302, 0.098, 26),
pt(0.376, 0.108, 28),
pt(0.450, 0.114, 28),
pt(0.522, 0.114, 28),
pt(0.592, 0.108, 26),
pt(0.658, 0.098, 24),
pt(0.720, 0.084, 22),
pt(0.776, 0.066, 22),
pt(0.826, 0.048, 22),
pt(0.870, 0.030, 22),
pt(0.908, 0.016, 22),
pt(0.940, 0.006, 20),
pt(0.964, 0.000, 18),
pt(0.982, -0.002, 16),
pt(0.994, -0.001, 14),
pt(1.000, 0.000, 12),
];
pub const ARC_BELOW: &[TracePoint] = &[
pt(0.000, 0.000, 0),
pt(0.020, -0.010, 16),
pt(0.054, -0.024, 18),
pt(0.100, -0.040, 20),
pt(0.158, -0.058, 22),
pt(0.224, -0.074, 24),
pt(0.296, -0.088, 26),
pt(0.372, -0.098, 28),
pt(0.448, -0.104, 28),
pt(0.522, -0.104, 28),
pt(0.592, -0.100, 26),
pt(0.658, -0.090, 24),
pt(0.720, -0.076, 22),
pt(0.776, -0.060, 22),
pt(0.824, -0.044, 22),
pt(0.866, -0.028, 22),
pt(0.902, -0.014, 22),
pt(0.932, -0.006, 20),
pt(0.956, -0.002, 18),
pt(0.974, 0.000, 16),
pt(0.988, 0.000, 14),
pt(1.000, 0.000, 12),
];
pub const OVERSHOOT_CORRECT: &[TracePoint] = &[
pt(0.000, 0.000, 0),
pt(0.024, 0.002, 14),
pt(0.062, 0.006, 16),
pt(0.114, 0.012, 18),
pt(0.180, 0.018, 19),
pt(0.260, 0.022, 20),
pt(0.350, 0.024, 21),
pt(0.444, 0.022, 22),
pt(0.538, 0.018, 22),
pt(0.628, 0.014, 22),
pt(0.712, 0.010, 22),
pt(0.788, 0.006, 22),
pt(0.852, 0.003, 20),
pt(0.904, 0.001, 18),
pt(0.946, 0.000, 16),
pt(0.978, -0.001, 14),
pt(1.012, -0.002, 14),
pt(1.030, -0.002, 18),
pt(1.034, -0.002, 26),
pt(1.026, -0.001, 30),
pt(1.014, 0.000, 26),
pt(1.004, 0.000, 22),
pt(1.000, 0.000, 18),
];
pub const TWO_PHASE_PAUSE: &[TracePoint] = &[
pt(0.000, 0.000, 0),
pt(0.022, 0.008, 16),
pt(0.060, 0.018, 18),
pt(0.114, 0.030, 20),
pt(0.182, 0.040, 22),
pt(0.258, 0.048, 24),
pt(0.332, 0.052, 26),
pt(0.402, 0.052, 28),
pt(0.460, 0.048, 28),
pt(0.498, 0.042, 30),
pt(0.500, 0.040, 280),
pt(0.510, 0.038, 32),
pt(0.534, 0.034, 30),
pt(0.572, 0.028, 28),
pt(0.622, 0.022, 26),
pt(0.682, 0.016, 24),
pt(0.748, 0.010, 22),
pt(0.812, 0.006, 22),
pt(0.870, 0.002, 22),
pt(0.918, 0.000, 20),
pt(0.954, -0.001, 18),
pt(0.978, -0.001, 16),
pt(0.992, 0.000, 14),
pt(1.000, 0.000, 12),
];
fn all_traces() -> &'static [Trace] {
static ALL: OnceLock<Vec<Trace>> = OnceLock::new();
ALL.get_or_init(|| {
vec![
Trace {
name: "direct_fast",
points: DIRECT_FAST,
},
Trace {
name: "slow_careful",
points: SLOW_CAREFUL,
},
Trace {
name: "arc_above",
points: ARC_ABOVE,
},
Trace {
name: "arc_below",
points: ARC_BELOW,
},
Trace {
name: "overshoot_correct",
points: OVERSHOOT_CORRECT,
},
Trace {
name: "two_phase_pause",
points: TWO_PHASE_PAUSE,
},
]
})
}
pub const BUILTIN_TRACE_COUNT: usize = 6;
pub fn builtin_traces() -> &'static [Trace] {
all_traces()
}
pub fn trace_by_name(name: &str) -> Option<&'static Trace> {
all_traces().iter().find(|t| t.name == name)
}
pub fn pick_trace(rng: &mut StdRng) -> &'static Trace {
let traces = all_traces();
let idx = rng.gen_range(0..traces.len());
&traces[idx]
}
pub fn project_point(
x_norm: f32,
y_norm: f32,
jitter: f32,
x0: f64,
y0: f64,
x1: f64,
y1: f64,
) -> (f64, f64) {
let dx = x1 - x0;
let dy = y1 - y0;
let dist = (dx * dx + dy * dy).sqrt();
if dist < 1e-9 {
return (x0 + jitter as f64, y0 + jitter as f64);
}
let ax = dx / dist;
let ay = dy / dist;
let px = -ay;
let py = ax;
let world_x = x0 + (x_norm as f64) * dist * ax + (y_norm as f64) * dist * px + jitter as f64;
let world_y = y0 + (x_norm as f64) * dist * ay + (y_norm as f64) * dist * py + jitter as f64;
(world_x, world_y)
}
pub async fn replay_trace(
page: &Page,
x0: f64,
y0: f64,
x1: f64,
y1: f64,
trace: &Trace,
jitter_max_px: f32,
) -> anyhow::Result<()> {
let mut rng = StdRng::from_entropy();
for tp in trace.points {
if tp.dt_ms > 0 {
tokio::time::sleep(Duration::from_millis(tp.dt_ms as u64)).await;
}
let jitter = rng.gen_range(-jitter_max_px..=jitter_max_px);
let (wx, wy) = project_point(tp.x_norm, tp.y_norm, jitter, x0, y0, x1, y1);
let params = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseMoved)
.x(wx)
.y(wy)
.build()
.map_err(anyhow::Error::msg)?;
page.execute(params).await?;
}
Ok(())
}
pub fn trace_duration_ms(trace: &Trace) -> u32 {
trace.points.iter().map(|p| p.dt_ms as u32).sum()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_count_matches_constant() {
assert_eq!(builtin_traces().len(), BUILTIN_TRACE_COUNT);
}
#[test]
fn each_trace_starts_at_origin_and_ends_near_target() {
for t in builtin_traces() {
let first = t.points.first().expect("trace has points");
assert_eq!(first.x_norm, 0.0, "trace {} start x", t.name);
assert_eq!(first.y_norm, 0.0, "trace {} start y", t.name);
assert_eq!(first.dt_ms, 0, "trace {} first dt", t.name);
let last = t.points.last().expect("trace has points");
assert!(
(last.x_norm - 1.0).abs() < 0.05,
"trace {} end x_norm = {} (should be near 1.0)",
t.name,
last.x_norm
);
assert!(
last.y_norm.abs() < 0.05,
"trace {} end y_norm = {} (should be near 0.0)",
t.name,
last.y_norm
);
}
}
#[test]
fn each_trace_x_is_monotonic_after_overshoot_excluded() {
for t in builtin_traces() {
if t.name == "overshoot_correct" {
continue;
}
for w in t.points.windows(2) {
assert!(
w[1].x_norm >= w[0].x_norm - 1e-6,
"trace {} non-monotonic at {:?} -> {:?}",
t.name,
w[0],
w[1],
);
}
}
}
#[test]
fn overshoot_actually_overshoots() {
let t = trace_by_name("overshoot_correct").unwrap();
let max_x = t.points.iter().map(|p| p.x_norm).fold(0.0_f32, f32::max);
assert!(
max_x > 1.0,
"overshoot trace max x = {max_x}, should exceed 1.0"
);
}
#[test]
fn two_phase_pause_has_a_real_pause() {
let t = trace_by_name("two_phase_pause").unwrap();
let max_dt = t.points.iter().map(|p| p.dt_ms).max().unwrap();
assert!(
max_dt >= 200,
"two_phase_pause max gap = {max_dt}ms, should include a 200ms+ pause",
);
}
#[test]
fn arc_above_y_is_positive_in_middle() {
let t = trace_by_name("arc_above").unwrap();
let mid = t
.points
.iter()
.min_by(|a, b| {
(a.x_norm - 0.5)
.abs()
.partial_cmp(&(b.x_norm - 0.5).abs())
.unwrap()
})
.unwrap();
assert!(
mid.y_norm > 0.05,
"arc_above mid y = {} should be > 0.05",
mid.y_norm
);
}
#[test]
fn arc_below_y_is_negative_in_middle() {
let t = trace_by_name("arc_below").unwrap();
let mid = t
.points
.iter()
.min_by(|a, b| {
(a.x_norm - 0.5)
.abs()
.partial_cmp(&(b.x_norm - 0.5).abs())
.unwrap()
})
.unwrap();
assert!(
mid.y_norm < -0.05,
"arc_below mid y = {} should be < -0.05",
mid.y_norm,
);
}
#[test]
fn project_point_start_lands_at_start() {
let (x, y) = project_point(0.0, 0.0, 0.0, 50.0, 100.0, 250.0, 400.0);
assert!((x - 50.0).abs() < 1e-6);
assert!((y - 100.0).abs() < 1e-6);
}
#[test]
fn project_point_end_lands_at_end() {
let (x, y) = project_point(1.0, 0.0, 0.0, 50.0, 100.0, 250.0, 400.0);
assert!((x - 250.0).abs() < 1e-3);
assert!((y - 400.0).abs() < 1e-3);
}
#[test]
fn project_point_perpendicular_offset_rotates_correctly() {
let (x, y) = project_point(0.5, 0.1, 0.0, 0.0, 0.0, 100.0, 0.0);
assert!((x - 50.0).abs() < 1e-3);
assert!((y - 10.0).abs() < 1e-3);
}
#[test]
fn project_point_works_for_diagonal_moves() {
let (x, y) = project_point(0.5, 0.0, 0.0, 0.0, 0.0, 100.0, 100.0);
assert!((x - 50.0).abs() < 1e-3, "x = {x}");
assert!((y - 50.0).abs() < 1e-3, "y = {y}");
}
#[test]
fn project_point_degenerate_zero_distance_returns_start() {
let (x, y) = project_point(0.5, 0.5, 0.0, 100.0, 100.0, 100.0, 100.0);
assert_eq!(x, 100.0);
assert_eq!(y, 100.0);
}
#[test]
fn pick_trace_returns_a_known_trace() {
let mut rng = StdRng::from_entropy();
let t = pick_trace(&mut rng);
assert!(builtin_traces().iter().any(|b| b.name == t.name));
}
#[test]
fn pick_trace_distribution_covers_all_traces_eventually() {
let mut rng = StdRng::seed_from_u64(0xCAFE);
let mut seen = std::collections::HashSet::new();
for _ in 0..600 {
seen.insert(pick_trace(&mut rng).name);
}
for t in builtin_traces() {
assert!(seen.contains(t.name), "never picked trace {}", t.name);
}
}
#[test]
fn trace_duration_within_human_envelope() {
for t in builtin_traces() {
let d = trace_duration_ms(t);
assert!(
(200..=1500).contains(&d),
"trace {} duration {}ms outside human envelope",
t.name,
d,
);
}
}
#[test]
fn trace_by_name_returns_none_for_unknown() {
assert!(trace_by_name("does-not-exist").is_none());
}
#[test]
fn each_trace_has_at_least_15_points() {
for t in builtin_traces() {
assert!(
t.points.len() >= 15,
"trace {} has only {} points; <15 is below the bezier baseline",
t.name,
t.points.len(),
);
}
}
}