stygian_browser/
behavior.rs

1//! Human behavior simulation for anti-detection
2//!
3//! This module provides realistic input simulation that mimics genuine human
4//! browsing patterns, making automated sessions harder to distinguish from
5//! real users.
6//!
7//! - [`MouseSimulator`] — Distance-aware Bézier curve mouse trajectories
8//! - [`TypingSimulator`] — Variable-speed typing with natural pauses *(T11)*
9//! - [`InteractionSimulator`] — Random scrolls and micro-movements *(T12)*
10
11// All f64→int and int→f64 casts in this module are bounded by construction
12// (RNG outputs, clamped durations, step counts ≤ 120) so truncation and sign
13// loss cannot occur in practice.  Precision loss from int→f64 is intentional
14// for the splitmix64 RNG and Bézier parameter arithmetic.
15#![allow(
16    clippy::cast_possible_truncation,
17    clippy::cast_sign_loss,
18    clippy::cast_precision_loss
19)]
20
21use chromiumoxide::Page;
22use chromiumoxide::cdp::browser_protocol::input::{DispatchKeyEventParams, DispatchKeyEventType};
23use std::time::{Duration, SystemTime, UNIX_EPOCH};
24use tokio::time::sleep;
25
26use crate::error::{BrowserError, Result};
27
28// ─── RNG helpers (splitmix64, no external dep) ────────────────────────────────
29
30/// One splitmix64 step — deterministic, high-quality 64-bit output.
31const fn splitmix64(state: &mut u64) -> u64 {
32    *state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
33    let mut z = *state;
34    z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
35    z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
36    z ^ (z >> 31)
37}
38
39/// Uniform float in `[0, 1)`.
40fn rand_f64(state: &mut u64) -> f64 {
41    (splitmix64(state) >> 11) as f64 / (1u64 << 53) as f64
42}
43
44/// Uniform float in `[min, max)`.
45fn rand_range(state: &mut u64, min: f64, max: f64) -> f64 {
46    rand_f64(state).mul_add(max - min, min)
47}
48
49/// Approximate Gaussian sample via Box–Muller transform.
50fn rand_normal(state: &mut u64, mean: f64, std_dev: f64) -> f64 {
51    let u1 = rand_f64(state).max(1e-10);
52    let u2 = rand_f64(state);
53    let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
54    std_dev.mul_add(z, mean)
55}
56
57// ─── Bézier helpers ───────────────────────────────────────────────────────────
58
59fn lerp(p0: (f64, f64), p1: (f64, f64), t: f64) -> (f64, f64) {
60    (t.mul_add(p1.0 - p0.0, p0.0), t.mul_add(p1.1 - p0.1, p0.1))
61}
62
63/// Evaluate a cubic Bézier curve at parameter `t ∈ [0, 1]`.
64fn cubic_bezier(
65    p0: (f64, f64),
66    p1: (f64, f64),
67    p2: (f64, f64),
68    p3: (f64, f64),
69    t: f64,
70) -> (f64, f64) {
71    let a = lerp(p0, p1, t);
72    let b = lerp(p1, p2, t);
73    let c = lerp(p2, p3, t);
74    lerp(lerp(a, b, t), lerp(b, c, t), t)
75}
76
77// ─── MouseSimulator ───────────────────────────────────────────────────────────
78
79/// Simulates human-like mouse movement via distance-aware Bézier curve trajectories.
80///
81/// Each call to [`move_to`][MouseSimulator::move_to] computes a cubic Bézier path
82/// between the current cursor position and the target, then replays it as a sequence
83/// of `Input.dispatchMouseEvent` CDP commands with randomised inter-event delays
84/// (10–50 ms per segment).  Movement speed naturally slows for long distances and
85/// accelerates for short ones — matching human motor-control patterns.
86///
87/// # Example
88///
89/// ```no_run
90/// use stygian_browser::behavior::MouseSimulator;
91///
92/// # async fn run(page: &chromiumoxide::Page) -> stygian_browser::Result<()> {
93/// let mut mouse = MouseSimulator::new();
94/// mouse.move_to(page, 640.0, 400.0).await?;
95/// mouse.click(page, 640.0, 400.0).await?;
96/// # Ok(())
97/// # }
98/// ```
99pub struct MouseSimulator {
100    /// Current cursor X in CSS pixels.
101    current_x: f64,
102    /// Current cursor Y in CSS pixels.
103    current_y: f64,
104    /// Splitmix64 RNG state.
105    rng: u64,
106}
107
108impl Default for MouseSimulator {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114impl MouseSimulator {
115    /// Create a simulator seeded from wall-clock time, positioned at (0, 0).
116    ///
117    /// # Example
118    ///
119    /// ```
120    /// use stygian_browser::behavior::MouseSimulator;
121    /// let mouse = MouseSimulator::new();
122    /// assert_eq!(mouse.position(), (0.0, 0.0));
123    /// ```
124    pub fn new() -> Self {
125        let seed = SystemTime::now()
126            .duration_since(UNIX_EPOCH)
127            .map(|d| d.as_secs() ^ u64::from(d.subsec_nanos()))
128            .unwrap_or(0x1234_5678_9abc_def0);
129        Self {
130            current_x: 0.0,
131            current_y: 0.0,
132            rng: seed,
133        }
134    }
135
136    /// Create a simulator with a known initial position and deterministic seed.
137    ///
138    /// Useful for unit-testing path generation without CDP.
139    ///
140    /// # Example
141    ///
142    /// ```
143    /// use stygian_browser::behavior::MouseSimulator;
144    /// let mouse = MouseSimulator::with_seed_and_position(42, 100.0, 200.0);
145    /// assert_eq!(mouse.position(), (100.0, 200.0));
146    /// ```
147    pub const fn with_seed_and_position(seed: u64, x: f64, y: f64) -> Self {
148        Self {
149            current_x: x,
150            current_y: y,
151            rng: seed,
152        }
153    }
154
155    /// Returns the current cursor position as `(x, y)`.
156    ///
157    /// # Example
158    ///
159    /// ```
160    /// use stygian_browser::behavior::MouseSimulator;
161    /// let mouse = MouseSimulator::new();
162    /// let (x, y) = mouse.position();
163    /// assert_eq!((x, y), (0.0, 0.0));
164    /// ```
165    pub const fn position(&self) -> (f64, f64) {
166        (self.current_x, self.current_y)
167    }
168
169    /// Compute Bézier waypoints for a move from `(from_x, from_y)` to
170    /// `(to_x, to_y)`.
171    ///
172    /// The number of waypoints scales with Euclidean distance — roughly one
173    /// point every 8 pixels — with a minimum of 12 and maximum of 120 steps.
174    /// Random perpendicular offsets are applied to the two interior control
175    /// points to produce natural curved paths.  Each waypoint receives
176    /// sub-pixel jitter (±0.8 px) for micro-tremor realism.
177    ///
178    /// This method is pure (no I/O) and is exposed for testing.
179    ///
180    /// # Example
181    ///
182    /// ```
183    /// use stygian_browser::behavior::MouseSimulator;
184    /// let mut mouse = MouseSimulator::with_seed_and_position(1, 0.0, 0.0);
185    /// let path = mouse.compute_path(0.0, 0.0, 200.0, 0.0);
186    /// // always at least 12 steps
187    /// assert!(path.len() >= 13);
188    /// // starts near origin
189    /// assert!((path[0].0).abs() < 5.0);
190    /// // ends near target
191    /// let last = path[path.len() - 1];
192    /// assert!((last.0 - 200.0).abs() < 5.0);
193    /// ```
194    pub fn compute_path(
195        &mut self,
196        from_x: f64,
197        from_y: f64,
198        to_x: f64,
199        to_y: f64,
200    ) -> Vec<(f64, f64)> {
201        let dx = to_x - from_x;
202        let dy = to_y - from_y;
203        let distance = dx.hypot(dy);
204
205        // Scale step count with distance; clamp to [12, 120].
206        let steps = ((distance / 8.0).round() as usize).clamp(12, 120);
207
208        // Perpendicular unit vector for offsetting control points.
209        let (px, py) = if distance > 1.0 {
210            (-dy / distance, dx / distance)
211        } else {
212            (1.0, 0.0)
213        };
214
215        // Larger offsets for longer movements (capped at 200 px).
216        let offset_scale = (distance * 0.35).min(200.0);
217        let cp1_off = rand_normal(&mut self.rng, 0.0, offset_scale * 0.5);
218        let cp2_off = rand_normal(&mut self.rng, 0.0, offset_scale * 0.4);
219
220        // Control points at 1/3 and 2/3 of the straight line, offset perp.
221        let cp1 = (
222            px.mul_add(cp1_off, from_x + dx / 3.0),
223            py.mul_add(cp1_off, from_y + dy / 3.0),
224        );
225        let cp2 = (
226            px.mul_add(cp2_off, from_x + 2.0 * dx / 3.0),
227            py.mul_add(cp2_off, from_y + 2.0 * dy / 3.0),
228        );
229        let p0 = (from_x, from_y);
230        let p3 = (to_x, to_y);
231
232        (0..=steps)
233            .map(|i| {
234                let t = i as f64 / steps as f64;
235                let (bx, by) = cubic_bezier(p0, cp1, cp2, p3, t);
236                // Micro-tremor jitter (± ~0.8 px, normally distributed).
237                let jx = rand_normal(&mut self.rng, 0.0, 0.4);
238                let jy = rand_normal(&mut self.rng, 0.0, 0.4);
239                (bx + jx, by + jy)
240            })
241            .collect()
242    }
243
244    /// Move the cursor to `(to_x, to_y)` using a human-like Bézier trajectory.
245    ///
246    /// Dispatches `Input.dispatchMouseEvent`(`mouseMoved`) for each waypoint
247    /// with randomised 10–50 ms delays.  Updates [`position`][Self::position]
248    /// on success.
249    ///
250    /// # Errors
251    ///
252    /// Returns [`BrowserError::CdpError`] if any CDP event dispatch fails.
253    pub async fn move_to(&mut self, page: &Page, to_x: f64, to_y: f64) -> Result<()> {
254        use chromiumoxide::cdp::browser_protocol::input::{
255            DispatchMouseEventParams, DispatchMouseEventType,
256        };
257
258        let path = self.compute_path(self.current_x, self.current_y, to_x, to_y);
259
260        for &(x, y) in &path {
261            let params = DispatchMouseEventParams::builder()
262                .r#type(DispatchMouseEventType::MouseMoved)
263                .x(x)
264                .y(y)
265                .build()
266                .map_err(BrowserError::ConfigError)?;
267
268            page.execute(params)
269                .await
270                .map_err(|e| BrowserError::CdpError {
271                    operation: "Input.dispatchMouseEvent(mouseMoved)".to_string(),
272                    message: e.to_string(),
273                })?;
274
275            let delay_ms = rand_range(&mut self.rng, 10.0, 50.0) as u64;
276            sleep(Duration::from_millis(delay_ms)).await;
277        }
278
279        self.current_x = to_x;
280        self.current_y = to_y;
281        Ok(())
282    }
283
284    /// Move to `(x, y)` then perform a human-like left-click.
285    ///
286    /// After arriving at the target the simulator pauses (20–80 ms), sends
287    /// `mousePressed`, holds (50–150 ms), then sends `mouseReleased`.
288    ///
289    /// # Errors
290    ///
291    /// Returns [`BrowserError::CdpError`] if any CDP event dispatch fails.
292    pub async fn click(&mut self, page: &Page, x: f64, y: f64) -> Result<()> {
293        use chromiumoxide::cdp::browser_protocol::input::{
294            DispatchMouseEventParams, DispatchMouseEventType, MouseButton,
295        };
296
297        self.move_to(page, x, y).await?;
298
299        // Pre-click pause.
300        let pre_ms = rand_range(&mut self.rng, 20.0, 80.0) as u64;
301        sleep(Duration::from_millis(pre_ms)).await;
302
303        let press = DispatchMouseEventParams::builder()
304            .r#type(DispatchMouseEventType::MousePressed)
305            .x(x)
306            .y(y)
307            .button(MouseButton::Left)
308            .click_count(1i64)
309            .build()
310            .map_err(BrowserError::ConfigError)?;
311
312        page.execute(press)
313            .await
314            .map_err(|e| BrowserError::CdpError {
315                operation: "Input.dispatchMouseEvent(mousePressed)".to_string(),
316                message: e.to_string(),
317            })?;
318
319        // Hold duration (humans don't click at zero duration).
320        let hold_ms = rand_range(&mut self.rng, 50.0, 150.0) as u64;
321        sleep(Duration::from_millis(hold_ms)).await;
322
323        let release = DispatchMouseEventParams::builder()
324            .r#type(DispatchMouseEventType::MouseReleased)
325            .x(x)
326            .y(y)
327            .button(MouseButton::Left)
328            .click_count(1i64)
329            .build()
330            .map_err(BrowserError::ConfigError)?;
331
332        page.execute(release)
333            .await
334            .map_err(|e| BrowserError::CdpError {
335                operation: "Input.dispatchMouseEvent(mouseReleased)".to_string(),
336                message: e.to_string(),
337            })?;
338
339        Ok(())
340    }
341}
342
343// ─── Keyboard helper ─────────────────────────────────────────────────────────
344
345/// Return a plausible adjacent key for typo simulation.
346///
347/// Looks up `ch` in a basic QWERTY row map and returns a neighbouring key.
348/// Non-alphabetic characters fall back to `'x'`.
349fn adjacent_key(ch: char, rng: &mut u64) -> char {
350    const ROWS: [&str; 3] = ["qwertyuiop", "asdfghjkl", "zxcvbnm"];
351    let lc = ch.to_lowercase().next().unwrap_or(ch);
352    for row in ROWS {
353        let chars: Vec<char> = row.chars().collect();
354        if let Some(idx) = chars.iter().position(|&c| c == lc) {
355            let adj = if idx == 0 {
356                chars.get(1).copied().unwrap_or(lc)
357            } else if idx == chars.len() - 1 || rand_f64(rng) < 0.5 {
358                chars.get(idx - 1).copied().unwrap_or(lc)
359            } else {
360                chars.get(idx + 1).copied().unwrap_or(lc)
361            };
362            return if ch.is_uppercase() {
363                adj.to_uppercase().next().unwrap_or(adj)
364            } else {
365                adj
366            };
367        }
368    }
369    'x'
370}
371
372// ─── TypingSimulator ──────────────────────────────────────────────────────────
373
374/// Simulates human-like typing using `Input.dispatchKeyEvent` CDP commands.
375///
376/// Each character is dispatched as a `keyDown` → `char` → `keyUp` sequence.
377/// Capital letters include the Shift modifier mask (`modifiers = 8`).  A
378/// configurable error rate causes occasional typos that are corrected via
379/// Backspace before the intended character is retyped.  Inter-key delays
380/// follow a Gaussian distribution (~80 ms mean, 25 ms σ) clamped to
381/// 30–200 ms.
382///
383/// # Example
384///
385/// ```no_run
386/// # async fn run(page: &chromiumoxide::Page) -> stygian_browser::Result<()> {
387/// use stygian_browser::behavior::TypingSimulator;
388/// let mut typer = TypingSimulator::new();
389/// typer.type_text(page, "Hello, world!").await?;
390/// # Ok(())
391/// # }
392/// ```
393pub struct TypingSimulator {
394    /// Splitmix64 RNG state.
395    rng: u64,
396    /// Per-character typo probability (default: 1.5 %).
397    error_rate: f64,
398}
399
400impl Default for TypingSimulator {
401    fn default() -> Self {
402        Self::new()
403    }
404}
405
406impl TypingSimulator {
407    /// Create a typing simulator seeded from wall-clock time.
408    ///
409    /// # Example
410    ///
411    /// ```
412    /// use stygian_browser::behavior::TypingSimulator;
413    /// let typer = TypingSimulator::new();
414    /// ```
415    pub fn new() -> Self {
416        let seed = SystemTime::now()
417            .duration_since(UNIX_EPOCH)
418            .map(|d| d.as_secs() ^ u64::from(d.subsec_nanos()))
419            .unwrap_or(0xdead_beef_cafe_babe);
420        Self {
421            rng: seed,
422            error_rate: 0.015,
423        }
424    }
425
426    /// Create a typing simulator with a fixed seed (useful for testing).
427    ///
428    /// # Example
429    ///
430    /// ```
431    /// use stygian_browser::behavior::TypingSimulator;
432    /// let typer = TypingSimulator::with_seed(42);
433    /// ```
434    pub const fn with_seed(seed: u64) -> Self {
435        Self {
436            rng: seed,
437            error_rate: 0.015,
438        }
439    }
440
441    /// Set the per-character typo probability (clamped to `0.0–1.0`).
442    ///
443    /// Default is `0.015` (1.5 %).
444    ///
445    /// # Example
446    ///
447    /// ```
448    /// use stygian_browser::behavior::TypingSimulator;
449    /// let typer = TypingSimulator::new().with_error_rate(0.0);
450    /// ```
451    #[must_use]
452    pub const fn with_error_rate(mut self, rate: f64) -> Self {
453        self.error_rate = rate.clamp(0.0, 1.0);
454        self
455    }
456
457    /// Sample a realistic inter-keystroke delay (Gaussian, ~80 ms mean).
458    ///
459    /// The returned value is clamped to the range 30–200 ms.
460    ///
461    /// # Example
462    ///
463    /// ```
464    /// use stygian_browser::behavior::TypingSimulator;
465    /// let mut typer = TypingSimulator::with_seed(1);
466    /// let delay = typer.keystroke_delay();
467    /// assert!(delay.as_millis() >= 30 && delay.as_millis() <= 200);
468    /// ```
469    pub fn keystroke_delay(&mut self) -> Duration {
470        let ms = rand_normal(&mut self.rng, 80.0, 25.0).clamp(30.0, 200.0) as u64;
471        Duration::from_millis(ms)
472    }
473
474    /// Dispatch one `Input.dispatchKeyEvent` CDP command.
475    async fn dispatch_key(
476        page: &Page,
477        kind: DispatchKeyEventType,
478        key: &str,
479        text: Option<&str>,
480        modifiers: i64,
481    ) -> Result<()> {
482        let mut b = DispatchKeyEventParams::builder().r#type(kind).key(key);
483        if let Some(t) = text {
484            b = b.text(t);
485        }
486        if modifiers != 0 {
487            b = b.modifiers(modifiers);
488        }
489        let params = b.build().map_err(BrowserError::ConfigError)?;
490        page.execute(params)
491            .await
492            .map_err(|e| BrowserError::CdpError {
493                operation: "Input.dispatchKeyEvent".to_string(),
494                message: e.to_string(),
495            })?;
496        Ok(())
497    }
498
499    /// Press and release a `Backspace` key (for correcting a typo).
500    async fn type_backspace(page: &Page) -> Result<()> {
501        Self::dispatch_key(page, DispatchKeyEventType::RawKeyDown, "Backspace", None, 0).await?;
502        Self::dispatch_key(page, DispatchKeyEventType::KeyUp, "Backspace", None, 0).await?;
503        Ok(())
504    }
505
506    /// Send the full `keyDown` → `char` → `keyUp` sequence for one character.
507    ///
508    /// Capital letters (Unicode uppercase alphabetic) include `modifiers = 8`
509    /// (Shift).
510    async fn type_char(page: &Page, ch: char) -> Result<()> {
511        let text = ch.to_string();
512        let modifiers: i64 = if ch.is_uppercase() && ch.is_alphabetic() {
513            8
514        } else {
515            0
516        };
517        let key = text.as_str();
518        Self::dispatch_key(
519            page,
520            DispatchKeyEventType::KeyDown,
521            key,
522            Some(&text),
523            modifiers,
524        )
525        .await?;
526        Self::dispatch_key(
527            page,
528            DispatchKeyEventType::Char,
529            key,
530            Some(&text),
531            modifiers,
532        )
533        .await?;
534        Self::dispatch_key(page, DispatchKeyEventType::KeyUp, key, None, modifiers).await?;
535        Ok(())
536    }
537
538    /// Type `text` into the focused element with human-like keystrokes.
539    ///
540    /// Each character produces `keyDown` → `char` → `keyUp` events.  With
541    /// probability `error_rate` a wrong adjacent key is typed first, then
542    /// corrected with Backspace.  Word boundaries (space or newline) receive an
543    /// additional 100–400 ms pause to simulate natural word-completion rhythm.
544    ///
545    /// # Errors
546    ///
547    /// Returns [`BrowserError::CdpError`] if any CDP call fails.
548    pub async fn type_text(&mut self, page: &Page, text: &str) -> Result<()> {
549        for ch in text.chars() {
550            // Occasionally make a typo: adjacent key → backspace → correct key.
551            if rand_f64(&mut self.rng) < self.error_rate {
552                let wrong = adjacent_key(ch, &mut self.rng);
553                Self::type_char(page, wrong).await?;
554                let typo_delay = rand_normal(&mut self.rng, 120.0, 30.0).clamp(60.0, 250.0) as u64;
555                sleep(Duration::from_millis(typo_delay)).await;
556                Self::type_backspace(page).await?;
557                let fix_delay = rand_range(&mut self.rng, 40.0, 120.0) as u64;
558                sleep(Duration::from_millis(fix_delay)).await;
559            }
560
561            Self::type_char(page, ch).await?;
562            sleep(self.keystroke_delay()).await;
563
564            // Extra pause after word boundaries.
565            if ch == ' ' || ch == '\n' {
566                let word_pause = rand_range(&mut self.rng, 100.0, 400.0) as u64;
567                sleep(Duration::from_millis(word_pause)).await;
568            }
569        }
570        Ok(())
571    }
572}
573
574// ─── InteractionLevel ─────────────────────────────────────────────────────────
575
576/// Intensity level for [`InteractionSimulator`] random interactions.
577///
578/// # Example
579///
580/// ```
581/// use stygian_browser::behavior::InteractionLevel;
582/// assert_eq!(InteractionLevel::default(), InteractionLevel::None);
583/// ```
584#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
585pub enum InteractionLevel {
586    /// No random interactions are performed.
587    #[default]
588    None,
589    /// Occasional scroll + brief pause (500–1 500 ms).
590    Low,
591    /// Scroll sequence + mouse wiggle + reading pause (1–3 s).
592    Medium,
593    /// Full simulation: scrolling, mouse wiggles, hover, and scroll-back.
594    High,
595}
596
597// ─── InteractionSimulator ─────────────────────────────────────────────────────
598
599/// Simulates random human-like page interactions.
600///
601/// Combines scroll patterns, mouse micro-movements, and reading pauses to
602/// produce convincing human browsing behaviour.  The intensity is controlled
603/// by [`InteractionLevel`].
604///
605/// # Example
606///
607/// ```no_run
608/// # async fn run(page: &chromiumoxide::Page) -> stygian_browser::Result<()> {
609/// use stygian_browser::behavior::{InteractionSimulator, InteractionLevel};
610/// let mut sim = InteractionSimulator::new(InteractionLevel::Medium);
611/// sim.random_interaction(page, 1280.0, 800.0).await?;
612/// # Ok(())
613/// # }
614/// ```
615pub struct InteractionSimulator {
616    rng: u64,
617    mouse: MouseSimulator,
618    level: InteractionLevel,
619}
620
621impl Default for InteractionSimulator {
622    fn default() -> Self {
623        Self::new(InteractionLevel::None)
624    }
625}
626
627impl InteractionSimulator {
628    /// Create a new interaction simulator with the given interaction level.
629    ///
630    /// # Example
631    ///
632    /// ```
633    /// use stygian_browser::behavior::{InteractionSimulator, InteractionLevel};
634    /// let sim = InteractionSimulator::new(InteractionLevel::Low);
635    /// ```
636    pub fn new(level: InteractionLevel) -> Self {
637        let seed = SystemTime::now()
638            .duration_since(UNIX_EPOCH)
639            .map(|d| d.as_secs() ^ u64::from(d.subsec_nanos()))
640            .unwrap_or(0x0123_4567_89ab_cdef);
641        Self {
642            rng: seed,
643            mouse: MouseSimulator::with_seed_and_position(seed ^ 0xca11_ab1e, 400.0, 300.0),
644            level,
645        }
646    }
647
648    /// Create a simulator with a fixed seed (useful for unit-testing).
649    ///
650    /// # Example
651    ///
652    /// ```
653    /// use stygian_browser::behavior::{InteractionSimulator, InteractionLevel};
654    /// let sim = InteractionSimulator::with_seed(42, InteractionLevel::High);
655    /// ```
656    pub const fn with_seed(seed: u64, level: InteractionLevel) -> Self {
657        Self {
658            rng: seed,
659            mouse: MouseSimulator::with_seed_and_position(seed ^ 0xca11_ab1e, 400.0, 300.0),
660            level,
661        }
662    }
663
664    /// Evaluate a JavaScript expression on `page`.
665    async fn js(page: &Page, expr: String) -> Result<()> {
666        page.evaluate(expr)
667            .await
668            .map_err(|e| BrowserError::CdpError {
669                operation: "Runtime.evaluate".to_string(),
670                message: e.to_string(),
671            })?;
672        Ok(())
673    }
674
675    /// Scroll `delta_y` CSS pixels (positive = down, negative = up).
676    async fn scroll(page: &Page, delta_y: i64) -> Result<()> {
677        Self::js(
678            page,
679            format!("window.scrollBy({{top:{delta_y},behavior:'smooth'}})"),
680        )
681        .await
682    }
683
684    /// Scroll down a random amount, then partially scroll back up.
685    async fn do_scroll(&mut self, page: &Page) -> Result<()> {
686        let down = rand_range(&mut self.rng, 200.0, 600.0) as i64;
687        Self::scroll(page, down).await?;
688        let pause = rand_range(&mut self.rng, 300.0, 1_000.0) as u64;
689        sleep(Duration::from_millis(pause)).await;
690        let up = -(rand_range(&mut self.rng, 50.0, (down as f64) * 0.4) as i64);
691        Self::scroll(page, up).await?;
692        Ok(())
693    }
694
695    /// Move the mouse to a random point within the viewport.
696    async fn do_mouse_wiggle(&mut self, page: &Page, vw: f64, vh: f64) -> Result<()> {
697        let tx = rand_range(&mut self.rng, vw * 0.1, vw * 0.9);
698        let ty = rand_range(&mut self.rng, vh * 0.1, vh * 0.9);
699        self.mouse.move_to(page, tx, ty).await
700    }
701
702    /// Perform a random human-like interaction matching the configured level.
703    ///
704    /// | Level    | Actions                                                   |
705    /// | ---------- | ----------------------------------------------------------- |
706    /// | `None`   | No-op                                                     |
707    /// | `Low`    | One scroll + short pause (500–1 500 ms)                   |
708    /// | `Medium` | Scroll + mouse wiggle + reading pause (1–3 s)             |
709    /// | `High`   | Medium + second wiggle + optional scroll-back             |
710    ///
711    /// # Parameters
712    ///
713    /// - `page` — The active browser page.
714    /// - `viewport_w` / `viewport_h` — Approximate viewport size in CSS pixels.
715    ///
716    /// # Errors
717    ///
718    /// Returns [`BrowserError::CdpError`] if any CDP call fails.
719    pub async fn random_interaction(
720        &mut self,
721        page: &Page,
722        viewport_w: f64,
723        viewport_h: f64,
724    ) -> Result<()> {
725        match self.level {
726            InteractionLevel::None => {}
727            InteractionLevel::Low => {
728                self.do_scroll(page).await?;
729                let pause = rand_range(&mut self.rng, 500.0, 1_500.0) as u64;
730                sleep(Duration::from_millis(pause)).await;
731            }
732            InteractionLevel::Medium => {
733                self.do_scroll(page).await?;
734                let p1 = rand_range(&mut self.rng, 1_000.0, 3_000.0) as u64;
735                sleep(Duration::from_millis(p1)).await;
736                self.do_mouse_wiggle(page, viewport_w, viewport_h).await?;
737                let p2 = rand_range(&mut self.rng, 500.0, 2_000.0) as u64;
738                sleep(Duration::from_millis(p2)).await;
739            }
740            InteractionLevel::High => {
741                self.do_scroll(page).await?;
742                let p1 = rand_range(&mut self.rng, 1_000.0, 5_000.0) as u64;
743                sleep(Duration::from_millis(p1)).await;
744                self.do_mouse_wiggle(page, viewport_w, viewport_h).await?;
745                let p2 = rand_range(&mut self.rng, 800.0, 3_000.0) as u64;
746                sleep(Duration::from_millis(p2)).await;
747                self.do_mouse_wiggle(page, viewport_w, viewport_h).await?;
748                let p3 = rand_range(&mut self.rng, 500.0, 2_000.0) as u64;
749                sleep(Duration::from_millis(p3)).await;
750                // Occasional scroll-back (40 % chance).
751                if rand_f64(&mut self.rng) < 0.4 {
752                    let up = -(rand_range(&mut self.rng, 50.0, 200.0) as i64);
753                    Self::scroll(page, up).await?;
754                    sleep(Duration::from_millis(500)).await;
755                }
756            }
757        }
758        Ok(())
759    }
760}
761
762// ─── Tests ────────────────────────────────────────────────────────────────────
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767
768    #[test]
769    fn mouse_simulator_starts_at_origin() {
770        let mouse = MouseSimulator::new();
771        assert_eq!(mouse.position(), (0.0, 0.0));
772    }
773
774    #[test]
775    fn mouse_simulator_with_seed_and_position() {
776        let mouse = MouseSimulator::with_seed_and_position(42, 150.0, 300.0);
777        assert_eq!(mouse.position(), (150.0, 300.0));
778    }
779
780    #[test]
781    fn compute_path_minimum_steps_for_zero_distance() {
782        let mut mouse = MouseSimulator::with_seed_and_position(1, 100.0, 100.0);
783        let path = mouse.compute_path(100.0, 100.0, 100.0, 100.0);
784        // 12 steps minimum => 13 points (0..=12)
785        assert!(path.len() >= 13);
786    }
787
788    #[test]
789    fn compute_path_scales_with_distance() {
790        let mut mouse_near = MouseSimulator::with_seed_and_position(1, 0.0, 0.0);
791        let mut mouse_far = MouseSimulator::with_seed_and_position(1, 0.0, 0.0);
792
793        let short_path = mouse_near.compute_path(0.0, 0.0, 30.0, 0.0);
794        let long_path = mouse_far.compute_path(0.0, 0.0, 800.0, 0.0);
795
796        // Long distance should produce more waypoints.
797        assert!(long_path.len() > short_path.len());
798    }
799
800    #[test]
801    fn compute_path_step_cap_at_120() {
802        let mut mouse = MouseSimulator::with_seed_and_position(99, 0.0, 0.0);
803        // distance = 10_000 px → would be 1250 steps without cap.
804        let path = mouse.compute_path(0.0, 0.0, 10_000.0, 0.0);
805        // 120 steps => 121 points
806        assert!(path.len() <= 121);
807    }
808
809    #[test]
810    fn compute_path_endpoint_near_target() {
811        let mut mouse = MouseSimulator::with_seed_and_position(7, 0.0, 0.0);
812        let target_x = 500.0_f64;
813        let target_y = 300.0_f64;
814        let path = mouse.compute_path(0.0, 0.0, target_x, target_y);
815        let last = path.last().copied().unwrap_or_default();
816        // Jitter is tiny; endpoint should be within 5 px.
817        assert!(
818            (last.0 - target_x).abs() < 5.0,
819            "x off by {}",
820            (last.0 - target_x).abs()
821        );
822        assert!(
823            (last.1 - target_y).abs() < 5.0,
824            "y off by {}",
825            (last.1 - target_y).abs()
826        );
827    }
828
829    #[test]
830    fn compute_path_startpoint_near_origin() {
831        let mut mouse = MouseSimulator::with_seed_and_position(3, 50.0, 80.0);
832        let path = mouse.compute_path(50.0, 80.0, 400.0, 200.0);
833        // First point should be close to start.
834        if let Some(first) = path.first() {
835            assert!((first.0 - 50.0).abs() < 5.0);
836            assert!((first.1 - 80.0).abs() < 5.0);
837        }
838    }
839
840    #[test]
841    fn compute_path_diagonal_movement() {
842        let mut mouse = MouseSimulator::with_seed_and_position(17, 0.0, 0.0);
843        let path = mouse.compute_path(0.0, 0.0, 300.0, 400.0);
844        // 500 px distance → ~62 raw steps, clamped to max(12,62).min(120) = 62 → 63 pts
845        assert!(path.len() >= 13);
846        let last = path.last().copied().unwrap_or_default();
847        assert!((last.0 - 300.0).abs() < 5.0);
848        assert!((last.1 - 400.0).abs() < 5.0);
849    }
850
851    #[test]
852    fn compute_path_deterministic_with_same_seed() {
853        let mut m1 = MouseSimulator::with_seed_and_position(42, 0.0, 0.0);
854        let mut m2 = MouseSimulator::with_seed_and_position(42, 0.0, 0.0);
855        let path1 = m1.compute_path(0.0, 0.0, 200.0, 150.0);
856        let path2 = m2.compute_path(0.0, 0.0, 200.0, 150.0);
857        assert_eq!(path1.len(), path2.len());
858        for (a, b) in path1.iter().zip(path2.iter()) {
859            assert!((a.0 - b.0).abs() < 1e-9);
860            assert!((a.1 - b.1).abs() < 1e-9);
861        }
862    }
863
864    #[test]
865    fn cubic_bezier_at_t0_is_p0() {
866        let p0 = (10.0, 20.0);
867        let p1 = (50.0, 100.0);
868        let p2 = (150.0, 80.0);
869        let p3 = (200.0, 30.0);
870        let result = cubic_bezier(p0, p1, p2, p3, 0.0);
871        assert!((result.0 - p0.0).abs() < 1e-9);
872        assert!((result.1 - p0.1).abs() < 1e-9);
873    }
874
875    #[test]
876    fn cubic_bezier_at_t1_is_p3() {
877        let p0 = (10.0, 20.0);
878        let p1 = (50.0, 100.0);
879        let p2 = (150.0, 80.0);
880        let p3 = (200.0, 30.0);
881        let result = cubic_bezier(p0, p1, p2, p3, 1.0);
882        assert!((result.0 - p3.0).abs() < 1e-9);
883        assert!((result.1 - p3.1).abs() < 1e-9);
884    }
885
886    #[test]
887    fn rand_f64_is_in_unit_interval() {
888        let mut state = 12345u64;
889        for _ in 0..1000 {
890            let v = rand_f64(&mut state);
891            assert!((0.0..1.0).contains(&v), "out of range: {v}");
892        }
893    }
894
895    #[test]
896    fn rand_range_stays_in_bounds() {
897        let mut state = 99999u64;
898        for _ in 0..1000 {
899            let v = rand_range(&mut state, 10.0, 50.0);
900            assert!((10.0..50.0).contains(&v), "out of range: {v}");
901        }
902    }
903
904    #[test]
905    fn typing_simulator_keystroke_delay_is_positive() {
906        let mut ts = TypingSimulator::new();
907        assert!(ts.keystroke_delay().as_millis() > 0);
908    }
909
910    #[test]
911    fn typing_simulator_keystroke_delay_in_range() {
912        let mut ts = TypingSimulator::with_seed(123);
913        for _ in 0..50 {
914            let d = ts.keystroke_delay();
915            assert!(
916                d.as_millis() >= 30 && d.as_millis() <= 200,
917                "delay out of range: {}ms",
918                d.as_millis()
919            );
920        }
921    }
922
923    #[test]
924    fn typing_simulator_error_rate_clamps_to_one() {
925        let ts = TypingSimulator::new().with_error_rate(2.0);
926        assert!(
927            (ts.error_rate - 1.0).abs() < 1e-9,
928            "rate should clamp to 1.0"
929        );
930    }
931
932    #[test]
933    fn typing_simulator_error_rate_clamps_to_zero() {
934        let ts = TypingSimulator::new().with_error_rate(-0.5);
935        assert!(ts.error_rate.abs() < 1e-9, "rate should clamp to 0.0");
936    }
937
938    #[test]
939    fn typing_simulator_deterministic_with_same_seed() {
940        let mut t1 = TypingSimulator::with_seed(999);
941        let mut t2 = TypingSimulator::with_seed(999);
942        assert_eq!(t1.keystroke_delay(), t2.keystroke_delay());
943    }
944
945    #[test]
946    fn adjacent_key_returns_different_char() {
947        let mut rng = 42u64;
948        for &ch in &['a', 'b', 's', 'k', 'z', 'm'] {
949            let adj = adjacent_key(ch, &mut rng);
950            assert_ne!(adj, ch, "adjacent_key({ch}) should not return itself");
951        }
952    }
953
954    #[test]
955    fn adjacent_key_preserves_case() {
956        let mut rng = 7u64;
957        let adj = adjacent_key('A', &mut rng);
958        assert!(
959            adj.is_uppercase(),
960            "adjacent_key('A') should return uppercase"
961        );
962    }
963
964    #[test]
965    fn adjacent_key_non_alpha_returns_fallback() {
966        let mut rng = 1u64;
967        assert_eq!(adjacent_key('!', &mut rng), 'x');
968        assert_eq!(adjacent_key('5', &mut rng), 'x');
969    }
970
971    #[test]
972    fn interaction_level_default_is_none() {
973        assert_eq!(InteractionLevel::default(), InteractionLevel::None);
974    }
975
976    #[test]
977    fn interaction_simulator_with_seed_is_deterministic() {
978        let s1 = InteractionSimulator::with_seed(77, InteractionLevel::Low);
979        let s2 = InteractionSimulator::with_seed(77, InteractionLevel::Low);
980        assert_eq!(s1.rng, s2.rng);
981    }
982
983    #[test]
984    fn interaction_simulator_default_is_none_level() {
985        let sim = InteractionSimulator::default();
986        assert_eq!(sim.level, InteractionLevel::None);
987    }
988}