Skip to main content

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, Instant, SystemTime, UNIX_EPOCH};
24use tokio::time::sleep;
25use tracing::warn;
26
27use crate::error::{BrowserError, Result};
28
29// ─── RNG helpers (splitmix64, no external dep) ────────────────────────────────
30
31/// One splitmix64 step — deterministic, high-quality 64-bit output.
32const fn splitmix64(state: &mut u64) -> u64 {
33    *state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
34    let mut z = *state;
35    z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
36    z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
37    z ^ (z >> 31)
38}
39
40/// Uniform float in `[0, 1)`.
41fn rand_f64(state: &mut u64) -> f64 {
42    (splitmix64(state) >> 11) as f64 / (1u64 << 53) as f64
43}
44
45/// Uniform float in `[min, max)`.
46fn rand_range(state: &mut u64, min: f64, max: f64) -> f64 {
47    rand_f64(state).mul_add(max - min, min)
48}
49
50/// Approximate Gaussian sample via Box–Muller transform.
51fn rand_normal(state: &mut u64, mean: f64, std_dev: f64) -> f64 {
52    let u1 = rand_f64(state).max(1e-10);
53    let u2 = rand_f64(state);
54    let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
55    std_dev.mul_add(z, mean)
56}
57
58// ─── Bézier helpers ───────────────────────────────────────────────────────────
59
60fn lerp(p0: (f64, f64), p1: (f64, f64), t: f64) -> (f64, f64) {
61    (t.mul_add(p1.0 - p0.0, p0.0), t.mul_add(p1.1 - p0.1, p0.1))
62}
63
64/// Evaluate a cubic Bézier curve at parameter `t ∈ [0, 1]`.
65fn cubic_bezier(
66    p0: (f64, f64),
67    p1: (f64, f64),
68    p2: (f64, f64),
69    p3: (f64, f64),
70    t: f64,
71) -> (f64, f64) {
72    let a = lerp(p0, p1, t);
73    let b = lerp(p1, p2, t);
74    let c = lerp(p2, p3, t);
75    lerp(lerp(a, b, t), lerp(b, c, t), t)
76}
77
78// ─── MouseSimulator ───────────────────────────────────────────────────────────
79
80/// Simulates human-like mouse movement via distance-aware Bézier curve trajectories.
81///
82/// Each call to [`move_to`][MouseSimulator::move_to] computes a cubic Bézier path
83/// between the current cursor position and the target, then replays it as a sequence
84/// of `Input.dispatchMouseEvent` CDP commands with randomised inter-event delays
85/// (10–50 ms per segment).  Movement speed naturally slows for long distances and
86/// accelerates for short ones — matching human motor-control patterns.
87///
88/// # Example
89///
90/// ```no_run
91/// use stygian_browser::behavior::MouseSimulator;
92///
93/// # async fn run(page: &chromiumoxide::Page) -> stygian_browser::Result<()> {
94/// let mut mouse = MouseSimulator::new();
95/// mouse.move_to(page, 640.0, 400.0).await?;
96/// mouse.click(page, 640.0, 400.0).await?;
97/// # Ok(())
98/// # }
99/// ```
100pub struct MouseSimulator {
101    /// Current cursor X in CSS pixels.
102    current_x: f64,
103    /// Current cursor Y in CSS pixels.
104    current_y: f64,
105    /// Splitmix64 RNG state.
106    rng: u64,
107}
108
109impl Default for MouseSimulator {
110    fn default() -> Self {
111        Self::new()
112    }
113}
114
115impl MouseSimulator {
116    /// Create a simulator seeded from wall-clock time, positioned at (0, 0).
117    ///
118    /// # Example
119    ///
120    /// ```
121    /// use stygian_browser::behavior::MouseSimulator;
122    /// let mouse = MouseSimulator::new();
123    /// assert_eq!(mouse.position(), (0.0, 0.0));
124    /// ```
125    #[must_use]
126    pub fn new() -> Self {
127        let seed = SystemTime::now()
128            .duration_since(UNIX_EPOCH)
129            .map_or(0x1234_5678_9abc_def0, |d| {
130                d.as_secs() ^ u64::from(d.subsec_nanos())
131            });
132        Self {
133            current_x: 0.0,
134            current_y: 0.0,
135            rng: seed,
136        }
137    }
138
139    /// Create a simulator with a known initial position and deterministic seed.
140    ///
141    /// Useful for unit-testing path generation without CDP.
142    ///
143    /// # Example
144    ///
145    /// ```
146    /// use stygian_browser::behavior::MouseSimulator;
147    /// let mouse = MouseSimulator::with_seed_and_position(42, 100.0, 200.0);
148    /// assert_eq!(mouse.position(), (100.0, 200.0));
149    /// ```
150    #[must_use]
151    pub const fn with_seed_and_position(seed: u64, x: f64, y: f64) -> Self {
152        Self {
153            current_x: x,
154            current_y: y,
155            rng: seed,
156        }
157    }
158
159    /// Returns the current cursor position as `(x, y)`.
160    ///
161    /// # Example
162    ///
163    /// ```
164    /// use stygian_browser::behavior::MouseSimulator;
165    /// let mouse = MouseSimulator::new();
166    /// let (x, y) = mouse.position();
167    /// assert_eq!((x, y), (0.0, 0.0));
168    /// ```
169    #[must_use]
170    pub const fn position(&self) -> (f64, f64) {
171        (self.current_x, self.current_y)
172    }
173
174    /// Compute Bézier waypoints for a move from `(from_x, from_y)` to
175    /// `(to_x, to_y)`.
176    ///
177    /// The number of waypoints scales with Euclidean distance — roughly one
178    /// point every 8 pixels — with a minimum of 12 and maximum of 120 steps.
179    /// Random perpendicular offsets are applied to the two interior control
180    /// points to produce natural curved paths.  Each waypoint receives
181    /// sub-pixel jitter (±0.8 px) for micro-tremor realism.
182    ///
183    /// This method is pure, performs no I/O, and is exposed for testing.
184    ///
185    /// # Example
186    ///
187    /// ```
188    /// use stygian_browser::behavior::MouseSimulator;
189    /// let mut mouse = MouseSimulator::with_seed_and_position(1, 0.0, 0.0);
190    /// let path = mouse.compute_path(0.0, 0.0, 200.0, 0.0);
191    /// // always at least 12 steps
192    /// assert!(path.len() >= 13);
193    /// // starts near origin
194    /// assert!((path[0].0).abs() < 5.0);
195    /// // ends near target
196    /// let last = path[path.len() - 1];
197    /// assert!((last.0 - 200.0).abs() < 5.0);
198    /// ```
199    pub fn compute_path(
200        &mut self,
201        from_x: f64,
202        from_y: f64,
203        to_x: f64,
204        to_y: f64,
205    ) -> Vec<(f64, f64)> {
206        let dx = to_x - from_x;
207        let dy = to_y - from_y;
208        let distance = dx.hypot(dy);
209
210        // Scale step count with distance; clamp to [12, 120].
211        let steps = ((distance / 8.0).round() as usize).clamp(12, 120);
212
213        // Perpendicular unit vector for offsetting control points.
214        let (px, py) = if distance > 1.0 {
215            (-dy / distance, dx / distance)
216        } else {
217            (1.0, 0.0)
218        };
219
220        // Larger offsets for longer movements (capped at 200 px).
221        let offset_scale = (distance * 0.35).min(200.0);
222        let cp1_off = rand_normal(&mut self.rng, 0.0, offset_scale * 0.5);
223        let cp2_off = rand_normal(&mut self.rng, 0.0, offset_scale * 0.4);
224
225        // Control points at 1/3 and 2/3 of the straight line, offset perp.
226        let cp1 = (
227            px.mul_add(cp1_off, from_x + dx / 3.0),
228            py.mul_add(cp1_off, from_y + dy / 3.0),
229        );
230        let cp2 = (
231            px.mul_add(cp2_off, from_x + 2.0 * dx / 3.0),
232            py.mul_add(cp2_off, from_y + 2.0 * dy / 3.0),
233        );
234        let p0 = (from_x, from_y);
235        let p3 = (to_x, to_y);
236
237        (0..=steps)
238            .map(|i| {
239                let t = i as f64 / steps as f64;
240                let (bx, by) = cubic_bezier(p0, cp1, cp2, p3, t);
241                // Micro-tremor jitter (± ~0.8 px, normally distributed).
242                let jx = rand_normal(&mut self.rng, 0.0, 0.4);
243                let jy = rand_normal(&mut self.rng, 0.0, 0.4);
244                (bx + jx, by + jy)
245            })
246            .collect()
247    }
248
249    /// Move the cursor to `(to_x, to_y)` using a human-like Bézier trajectory.
250    ///
251    /// Dispatches `Input.dispatchMouseEvent`(`mouseMoved`) for each waypoint
252    /// with randomised 10–50 ms delays.  Updates [`position`][Self::position]
253    /// on success.
254    ///
255    /// # Errors
256    ///
257    /// Returns [`BrowserError::CdpError`] if any CDP event dispatch fails.
258    pub async fn move_to(&mut self, page: &Page, to_x: f64, to_y: f64) -> Result<()> {
259        use chromiumoxide::cdp::browser_protocol::input::{
260            DispatchMouseEventParams, DispatchMouseEventType,
261        };
262
263        let path = self.compute_path(self.current_x, self.current_y, to_x, to_y);
264
265        for &(x, y) in &path {
266            let params = DispatchMouseEventParams::builder()
267                .r#type(DispatchMouseEventType::MouseMoved)
268                .x(x)
269                .y(y)
270                .build()
271                .map_err(BrowserError::ConfigError)?;
272
273            page.execute(params)
274                .await
275                .map_err(|e| BrowserError::CdpError {
276                    operation: "Input.dispatchMouseEvent(mouseMoved)".to_string(),
277                    message: e.to_string(),
278                })?;
279
280            let delay_ms = rand_range(&mut self.rng, 10.0, 50.0) as u64;
281            sleep(Duration::from_millis(delay_ms)).await;
282        }
283
284        self.current_x = to_x;
285        self.current_y = to_y;
286        Ok(())
287    }
288
289    /// Move to `(x, y)` then perform a human-like left-click.
290    ///
291    /// After arriving at the target the simulator pauses (20–80 ms), sends
292    /// `mousePressed`, holds (50–150 ms), then sends `mouseReleased`.
293    ///
294    /// # Errors
295    ///
296    /// Returns [`BrowserError::CdpError`] if any CDP event dispatch fails.
297    pub async fn click(&mut self, page: &Page, x: f64, y: f64) -> Result<()> {
298        use chromiumoxide::cdp::browser_protocol::input::{
299            DispatchMouseEventParams, DispatchMouseEventType, MouseButton,
300        };
301
302        self.move_to(page, x, y).await?;
303
304        // Pre-click pause.
305        let pre_ms = rand_range(&mut self.rng, 20.0, 80.0) as u64;
306        sleep(Duration::from_millis(pre_ms)).await;
307
308        let press = DispatchMouseEventParams::builder()
309            .r#type(DispatchMouseEventType::MousePressed)
310            .x(x)
311            .y(y)
312            .button(MouseButton::Left)
313            .click_count(1i64)
314            .build()
315            .map_err(BrowserError::ConfigError)?;
316
317        page.execute(press)
318            .await
319            .map_err(|e| BrowserError::CdpError {
320                operation: "Input.dispatchMouseEvent(mousePressed)".to_string(),
321                message: e.to_string(),
322            })?;
323
324        // Hold duration (humans don't click at zero duration).
325        let hold_ms = rand_range(&mut self.rng, 50.0, 150.0) as u64;
326        sleep(Duration::from_millis(hold_ms)).await;
327
328        let release = DispatchMouseEventParams::builder()
329            .r#type(DispatchMouseEventType::MouseReleased)
330            .x(x)
331            .y(y)
332            .button(MouseButton::Left)
333            .click_count(1i64)
334            .build()
335            .map_err(BrowserError::ConfigError)?;
336
337        page.execute(release)
338            .await
339            .map_err(|e| BrowserError::CdpError {
340                operation: "Input.dispatchMouseEvent(mouseReleased)".to_string(),
341                message: e.to_string(),
342            })?;
343
344        Ok(())
345    }
346}
347
348// ─── Keyboard helper ─────────────────────────────────────────────────────────
349
350/// Return a plausible adjacent key for typo simulation.
351///
352/// Looks up `ch` in a basic QWERTY row map and returns a neighbouring key.
353/// Non-alphabetic characters fall back to `'x'`.
354fn adjacent_key(ch: char, rng: &mut u64) -> char {
355    const ROWS: [&str; 3] = ["qwertyuiop", "asdfghjkl", "zxcvbnm"];
356    let lc = ch.to_lowercase().next().unwrap_or(ch);
357    for row in ROWS {
358        let chars: Vec<char> = row.chars().collect();
359        if let Some(idx) = chars.iter().position(|&c| c == lc) {
360            let adj = if idx == 0 {
361                chars.get(1).copied().unwrap_or(lc)
362            } else if idx == chars.len() - 1 || rand_f64(rng) < 0.5 {
363                chars.get(idx - 1).copied().unwrap_or(lc)
364            } else {
365                chars.get(idx + 1).copied().unwrap_or(lc)
366            };
367            return if ch.is_uppercase() {
368                adj.to_uppercase().next().unwrap_or(adj)
369            } else {
370                adj
371            };
372        }
373    }
374    'x'
375}
376
377// ─── TypingSimulator ──────────────────────────────────────────────────────────
378
379/// Simulates human-like typing using `Input.dispatchKeyEvent` CDP commands.
380///
381/// Each character is dispatched as a `keyDown` → `char` → `keyUp` sequence.
382/// Capital letters include the Shift modifier mask (`modifiers = 8`).  A
383/// configurable error rate causes occasional typos that are corrected via
384/// Backspace before the intended character is retyped.  Inter-key delays
385/// follow a Gaussian distribution (~80 ms mean, 25 ms σ) clamped to
386/// 30–200 ms.
387///
388/// # Example
389///
390/// ```no_run
391/// # async fn run(page: &chromiumoxide::Page) -> stygian_browser::Result<()> {
392/// use stygian_browser::behavior::TypingSimulator;
393/// let mut typer = TypingSimulator::new();
394/// typer.type_text(page, "Hello, world!").await?;
395/// # Ok(())
396/// # }
397/// ```
398pub struct TypingSimulator {
399    /// Splitmix64 RNG state.
400    rng: u64,
401    /// Per-character typo probability (default: 1.5 %).
402    error_rate: f64,
403}
404
405impl Default for TypingSimulator {
406    fn default() -> Self {
407        Self::new()
408    }
409}
410
411impl TypingSimulator {
412    /// Create a typing simulator seeded from wall-clock time.
413    ///
414    /// # Example
415    ///
416    /// ```
417    /// use stygian_browser::behavior::TypingSimulator;
418    /// let typer = TypingSimulator::new();
419    /// ```
420    #[must_use]
421    pub fn new() -> Self {
422        let seed = SystemTime::now()
423            .duration_since(UNIX_EPOCH)
424            .map_or(0xdead_beef_cafe_babe, |d| {
425                d.as_secs() ^ u64::from(d.subsec_nanos())
426            });
427        Self {
428            rng: seed,
429            error_rate: 0.015,
430        }
431    }
432
433    /// Create a typing simulator with a fixed seed (useful for testing).
434    ///
435    /// # Example
436    ///
437    /// ```
438    /// use stygian_browser::behavior::TypingSimulator;
439    /// let typer = TypingSimulator::with_seed(42);
440    /// ```
441    #[must_use]
442    pub const fn with_seed(seed: u64) -> Self {
443        Self {
444            rng: seed,
445            error_rate: 0.015,
446        }
447    }
448
449    /// Set the per-character typo probability (clamped to `0.0–1.0`).
450    ///
451    /// Default is `0.015` (1.5 %).
452    ///
453    /// # Example
454    ///
455    /// ```
456    /// use stygian_browser::behavior::TypingSimulator;
457    /// let typer = TypingSimulator::new().with_error_rate(0.0);
458    /// ```
459    #[must_use]
460    pub const fn with_error_rate(mut self, rate: f64) -> Self {
461        self.error_rate = rate.clamp(0.0, 1.0);
462        self
463    }
464
465    /// Sample a realistic inter-keystroke delay (Gaussian, ~80 ms mean).
466    ///
467    /// The returned value is clamped to the range 30–200 ms.
468    ///
469    /// # Example
470    ///
471    /// ```
472    /// use stygian_browser::behavior::TypingSimulator;
473    /// let mut typer = TypingSimulator::with_seed(1);
474    /// let delay = typer.keystroke_delay();
475    /// assert!(delay.as_millis() >= 30 && delay.as_millis() <= 200);
476    /// ```
477    pub fn keystroke_delay(&mut self) -> Duration {
478        let ms = rand_normal(&mut self.rng, 80.0, 25.0).clamp(30.0, 200.0) as u64;
479        Duration::from_millis(ms)
480    }
481
482    /// Dispatch one `Input.dispatchKeyEvent` CDP command.
483    async fn dispatch_key(
484        page: &Page,
485        kind: DispatchKeyEventType,
486        key: &str,
487        text: Option<&str>,
488        modifiers: i64,
489    ) -> Result<()> {
490        let mut b = DispatchKeyEventParams::builder().r#type(kind).key(key);
491        if let Some(t) = text {
492            b = b.text(t);
493        }
494        if modifiers != 0 {
495            b = b.modifiers(modifiers);
496        }
497        let params = b.build().map_err(BrowserError::ConfigError)?;
498        page.execute(params)
499            .await
500            .map_err(|e| BrowserError::CdpError {
501                operation: "Input.dispatchKeyEvent".to_string(),
502                message: e.to_string(),
503            })?;
504        Ok(())
505    }
506
507    /// Press and release a `Backspace` key (for correcting a typo).
508    async fn type_backspace(page: &Page) -> Result<()> {
509        Self::dispatch_key(page, DispatchKeyEventType::RawKeyDown, "Backspace", None, 0).await?;
510        Self::dispatch_key(page, DispatchKeyEventType::KeyUp, "Backspace", None, 0).await?;
511        Ok(())
512    }
513
514    /// Send the full `keyDown` → `char` → `keyUp` sequence for one character.
515    ///
516    /// Capital letters (Unicode uppercase alphabetic) include `modifiers = 8`
517    /// (Shift).
518    async fn type_char(page: &Page, ch: char) -> Result<()> {
519        let text = ch.to_string();
520        let modifiers: i64 = if ch.is_uppercase() && ch.is_alphabetic() {
521            8
522        } else {
523            0
524        };
525        let key = text.as_str();
526        Self::dispatch_key(
527            page,
528            DispatchKeyEventType::KeyDown,
529            key,
530            Some(&text),
531            modifiers,
532        )
533        .await?;
534        Self::dispatch_key(
535            page,
536            DispatchKeyEventType::Char,
537            key,
538            Some(&text),
539            modifiers,
540        )
541        .await?;
542        Self::dispatch_key(page, DispatchKeyEventType::KeyUp, key, None, modifiers).await?;
543        Ok(())
544    }
545
546    /// Type `text` into the focused element with human-like keystrokes.
547    ///
548    /// Each character produces `keyDown` → `char` → `keyUp` events.  With
549    /// probability `error_rate` a wrong adjacent key is typed first, then
550    /// corrected with Backspace.  Word boundaries (space or newline) receive an
551    /// additional 100–400 ms pause to simulate natural word-completion rhythm.
552    ///
553    /// # Errors
554    ///
555    /// Returns [`BrowserError::CdpError`] if any CDP call fails.
556    pub async fn type_text(&mut self, page: &Page, text: &str) -> Result<()> {
557        for ch in text.chars() {
558            // Occasionally make a typo: adjacent key → backspace → correct key.
559            if rand_f64(&mut self.rng) < self.error_rate {
560                let wrong = adjacent_key(ch, &mut self.rng);
561                Self::type_char(page, wrong).await?;
562                let typo_delay = rand_normal(&mut self.rng, 120.0, 30.0).clamp(60.0, 250.0) as u64;
563                sleep(Duration::from_millis(typo_delay)).await;
564                Self::type_backspace(page).await?;
565                let fix_delay = rand_range(&mut self.rng, 40.0, 120.0) as u64;
566                sleep(Duration::from_millis(fix_delay)).await;
567            }
568
569            Self::type_char(page, ch).await?;
570            sleep(self.keystroke_delay()).await;
571
572            // Extra pause after word boundaries.
573            if ch == ' ' || ch == '\n' {
574                let word_pause = rand_range(&mut self.rng, 100.0, 400.0) as u64;
575                sleep(Duration::from_millis(word_pause)).await;
576            }
577        }
578        Ok(())
579    }
580}
581
582// ─── InteractionLevel ─────────────────────────────────────────────────────────
583
584/// Intensity level for [`InteractionSimulator`] random interactions.
585///
586/// # Example
587///
588/// ```
589/// use stygian_browser::behavior::InteractionLevel;
590/// assert_eq!(InteractionLevel::default(), InteractionLevel::None);
591/// ```
592#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
593pub enum InteractionLevel {
594    /// No random interactions are performed.
595    #[default]
596    None,
597    /// Occasional scroll + brief pause (500–1 500 ms).
598    Low,
599    /// Scroll sequence + mouse wiggle + reading pause (1–3 s).
600    Medium,
601    /// Full simulation: scrolling, mouse wiggles, hover, and scroll-back.
602    High,
603}
604
605// ─── InteractionSimulator ─────────────────────────────────────────────────────
606
607/// Simulates random human-like page interactions.
608///
609/// Combines scroll patterns, mouse micro-movements, and reading pauses to
610/// produce convincing human browsing behaviour.  The intensity is controlled
611/// by [`InteractionLevel`].
612///
613/// # Example
614///
615/// ```no_run
616/// # async fn run(page: &chromiumoxide::Page) -> stygian_browser::Result<()> {
617/// use stygian_browser::behavior::{InteractionSimulator, InteractionLevel};
618/// let mut sim = InteractionSimulator::new(InteractionLevel::Medium);
619/// sim.random_interaction(page, 1280.0, 800.0).await?;
620/// # Ok(())
621/// # }
622/// ```
623pub struct InteractionSimulator {
624    rng: u64,
625    mouse: MouseSimulator,
626    level: InteractionLevel,
627}
628
629impl Default for InteractionSimulator {
630    fn default() -> Self {
631        Self::new(InteractionLevel::None)
632    }
633}
634
635impl InteractionSimulator {
636    /// Create a new interaction simulator with the given interaction level.
637    ///
638    /// # Example
639    ///
640    /// ```
641    /// use stygian_browser::behavior::{InteractionSimulator, InteractionLevel};
642    /// let sim = InteractionSimulator::new(InteractionLevel::Low);
643    /// ```
644    #[must_use]
645    pub fn new(level: InteractionLevel) -> Self {
646        let seed = SystemTime::now()
647            .duration_since(UNIX_EPOCH)
648            .map_or(0x0123_4567_89ab_cdef, |d| {
649                d.as_secs() ^ u64::from(d.subsec_nanos())
650            });
651        Self {
652            rng: seed,
653            mouse: MouseSimulator::with_seed_and_position(seed ^ 0xca11_ab1e, 400.0, 300.0),
654            level,
655        }
656    }
657
658    /// Create a simulator with a fixed seed (useful for unit-testing).
659    ///
660    /// # Example
661    ///
662    /// ```
663    /// use stygian_browser::behavior::{InteractionSimulator, InteractionLevel};
664    /// let sim = InteractionSimulator::with_seed(42, InteractionLevel::High);
665    /// ```
666    #[must_use]
667    pub const fn with_seed(seed: u64, level: InteractionLevel) -> Self {
668        Self {
669            rng: seed,
670            mouse: MouseSimulator::with_seed_and_position(seed ^ 0xca11_ab1e, 400.0, 300.0),
671            level,
672        }
673    }
674
675    /// Evaluate a JavaScript expression on `page`.
676    async fn js(page: &Page, expr: String) -> Result<()> {
677        page.evaluate(expr)
678            .await
679            .map_err(|e| BrowserError::CdpError {
680                operation: "Runtime.evaluate".to_string(),
681                message: e.to_string(),
682            })?;
683        Ok(())
684    }
685
686    /// Scroll `delta_y` CSS pixels (positive = down, negative = up).
687    async fn scroll(page: &Page, delta_y: i64) -> Result<()> {
688        Self::js(
689            page,
690            format!("window.scrollBy({{top:{delta_y},behavior:'smooth'}})"),
691        )
692        .await
693    }
694
695    /// Dispatch synthetic key events to `window` so behavioural-biometric SDKs
696    /// (Cloudflare Turnstile Signal Orchestrator, `OpenAI` Sentinel SO) accumulate
697    /// non-zero keystroke telemetry before a protected action fires.
698    ///
699    /// Events are dispatched at window-level (bubbling) since SO listeners are
700    /// installed there.  Arrow/Tab keys are used — they do not activate UI
701    /// elements but are universally listened for by signal trackers.
702    async fn do_keyactivity(&mut self, page: &Page) -> Result<()> {
703        const KEYS: &[&str] = &["ArrowDown", "Tab", "ArrowRight", "ArrowUp"];
704        let count = 3 + rand_range(&mut self.rng, 0.0, 4.0) as u32;
705        let mut successful_pairs = 0u32;
706        for i in 0..count {
707            let key = KEYS
708                .get((i as usize) % KEYS.len())
709                .copied()
710                .unwrap_or("Tab");
711            let down_delay = rand_range(&mut self.rng, 50.0, 120.0) as u64;
712            sleep(Duration::from_millis(down_delay)).await;
713            let keydown_ok = if let Err(e) = Self::js(
714                page,
715                format!(
716                    "window.dispatchEvent(new KeyboardEvent('keydown',\
717                     {{bubbles:true,cancelable:true,key:{key:?},code:{key:?}}}));"
718                ),
719            )
720            .await
721            {
722                warn!(key, "Failed to dispatch keydown event: {e}");
723                false
724            } else {
725                true
726            };
727            let hold_ms = rand_range(&mut self.rng, 20.0, 60.0) as u64;
728            sleep(Duration::from_millis(hold_ms)).await;
729            let keyup_ok = if let Err(e) = Self::js(
730                page,
731                format!(
732                    "window.dispatchEvent(new KeyboardEvent('keyup',\
733                     {{bubbles:true,cancelable:true,key:{key:?},code:{key:?}}}));"
734                ),
735            )
736            .await
737            {
738                warn!(key, "Failed to dispatch keyup event: {e}");
739                false
740            } else {
741                true
742            };
743
744            if keydown_ok && keyup_ok {
745                successful_pairs += 1;
746            }
747        }
748
749        if successful_pairs == 0 {
750            return Err(BrowserError::CdpError {
751                operation: "InteractionSimulator::do_keyactivity".to_string(),
752                message: "all synthetic key event dispatches failed".to_string(),
753            });
754        }
755
756        Ok(())
757    }
758
759    /// Scroll down a random amount, then partially scroll back up.
760    async fn do_scroll(&mut self, page: &Page) -> Result<()> {
761        let down = rand_range(&mut self.rng, 200.0, 600.0) as i64;
762        Self::scroll(page, down).await?;
763        let pause = rand_range(&mut self.rng, 300.0, 1_000.0) as u64;
764        sleep(Duration::from_millis(pause)).await;
765        let up = -(rand_range(&mut self.rng, 50.0, (down as f64) * 0.4) as i64);
766        Self::scroll(page, up).await?;
767        Ok(())
768    }
769
770    /// Move the mouse to a random point within the viewport.
771    async fn do_mouse_wiggle(&mut self, page: &Page, vw: f64, vh: f64) -> Result<()> {
772        let tx = rand_range(&mut self.rng, vw * 0.1, vw * 0.9);
773        let ty = rand_range(&mut self.rng, vh * 0.1, vh * 0.9);
774        self.mouse.move_to(page, tx, ty).await
775    }
776
777    /// Perform a random human-like interaction matching the configured level.
778    ///
779    /// | Level    | Actions                                                   |
780    /// | ---------- | ----------------------------------------------------------- |
781    /// | `None`   | No-op                                                     |
782    /// | `Low`    | One scroll + short pause (500–1 500 ms)                   |
783    /// | `Medium` | Scroll + key activity + mouse wiggle + reading pauses     |
784    /// | `High`   | Medium + extra key activity + extra wiggle + optional up-scroll |
785    ///
786    /// # Parameters
787    ///
788    /// - `page` — The active browser page.
789    /// - `viewport_w` / `viewport_h` — Approximate viewport size in CSS pixels.
790    ///
791    /// # Errors
792    ///
793    /// Returns [`BrowserError::CdpError`] if any CDP call fails.
794    pub async fn random_interaction(
795        &mut self,
796        page: &Page,
797        viewport_w: f64,
798        viewport_h: f64,
799    ) -> Result<()> {
800        match self.level {
801            InteractionLevel::None => {}
802            InteractionLevel::Low => {
803                self.do_scroll(page).await?;
804                let pause = rand_range(&mut self.rng, 500.0, 1_500.0) as u64;
805                sleep(Duration::from_millis(pause)).await;
806            }
807            InteractionLevel::Medium => {
808                self.do_scroll(page).await?;
809                let p1 = rand_range(&mut self.rng, 800.0, 2_000.0) as u64;
810                sleep(Duration::from_millis(p1)).await;
811                // Key events populate behavioural-biometric trackers.
812                self.do_keyactivity(page).await?;
813                let p2 = rand_range(&mut self.rng, 500.0, 1_500.0) as u64;
814                sleep(Duration::from_millis(p2)).await;
815                self.do_mouse_wiggle(page, viewport_w, viewport_h).await?;
816                let p3 = rand_range(&mut self.rng, 400.0, 1_500.0) as u64;
817                sleep(Duration::from_millis(p3)).await;
818            }
819            InteractionLevel::High => {
820                self.do_scroll(page).await?;
821                let p1 = rand_range(&mut self.rng, 1_000.0, 5_000.0) as u64;
822                sleep(Duration::from_millis(p1)).await;
823                self.do_keyactivity(page).await?;
824                let p2 = rand_range(&mut self.rng, 400.0, 1_200.0) as u64;
825                sleep(Duration::from_millis(p2)).await;
826                self.do_mouse_wiggle(page, viewport_w, viewport_h).await?;
827                let p3 = rand_range(&mut self.rng, 800.0, 3_000.0) as u64;
828                sleep(Duration::from_millis(p3)).await;
829                self.do_keyactivity(page).await?;
830                let p4 = rand_range(&mut self.rng, 300.0, 800.0) as u64;
831                sleep(Duration::from_millis(p4)).await;
832                self.do_mouse_wiggle(page, viewport_w, viewport_h).await?;
833                let p5 = rand_range(&mut self.rng, 500.0, 2_000.0) as u64;
834                sleep(Duration::from_millis(p5)).await;
835                // Occasional scroll-back (40 % chance).
836                if rand_f64(&mut self.rng) < 0.4 {
837                    let up = -(rand_range(&mut self.rng, 50.0, 200.0) as i64);
838                    Self::scroll(page, up).await?;
839                    sleep(Duration::from_millis(500)).await;
840                }
841            }
842        }
843        Ok(())
844    }
845}
846
847// ─── RequestPacer ─────────────────────────────────────────────────────────────
848
849/// Paces programmatic HTTP/CDP requests with human-realistic inter-request delays.
850///
851/// Prevents tight-loop request patterns that are trivially detectable by server-side
852/// rate analysers (Cloudflare, Akamai, `DataDome`, AWS WAF).  Delays follow a truncated
853/// Gaussian distribution centred on `mean_ms` with `std_ms` variance, giving natural
854/// bursty-but-not-mechanical timing.
855///
856/// The first call to [`throttle`][RequestPacer::throttle] always returns immediately
857/// (no prior request to pace against).
858///
859/// # Example
860///
861/// ```no_run
862/// use stygian_browser::behavior::RequestPacer;
863///
864/// # async fn run() {
865/// let mut pacer = RequestPacer::new();
866/// for url in &["https://a.example.com", "https://b.example.com"] {
867///     pacer.throttle().await;
868///     // … make request to url …
869/// }
870/// # }
871/// ```
872pub struct RequestPacer {
873    rng: u64,
874    mean_ms: u64,
875    std_ms: u64,
876    min_ms: u64,
877    max_ms: u64,
878    last_request: Option<Instant>,
879}
880
881impl Default for RequestPacer {
882    fn default() -> Self {
883        Self::new()
884    }
885}
886
887impl RequestPacer {
888    /// Default pacer: mean 1 200 ms, σ = 400 ms, clamped 400–4 000 ms.
889    ///
890    /// # Example
891    ///
892    /// ```
893    /// use stygian_browser::behavior::RequestPacer;
894    /// let _pacer = RequestPacer::new();
895    /// ```
896    #[must_use]
897    pub fn new() -> Self {
898        let seed = SystemTime::now()
899            .duration_since(UNIX_EPOCH)
900            .map_or(0xdead_beef_cafe_1337, |d| {
901                d.as_secs() ^ u64::from(d.subsec_nanos())
902            });
903        Self {
904            rng: seed,
905            mean_ms: 1_200,
906            std_ms: 400,
907            min_ms: 400,
908            max_ms: 4_000,
909            last_request: None,
910        }
911    }
912
913    /// Create with explicit timing parameters (all values in milliseconds).
914    ///
915    /// If `min_ms > max_ms`, bounds are normalized by swapping them.
916    ///
917    /// # Example
918    ///
919    /// ```
920    /// use stygian_browser::behavior::RequestPacer;
921    /// // Aggressive: ~500 ms mean, σ = 150 ms, clamped 200–1 500 ms.
922    /// let _pacer = RequestPacer::with_timing(500, 150, 200, 1_500);
923    /// ```
924    #[must_use]
925    pub fn with_timing(mean_ms: u64, std_ms: u64, min_ms: u64, max_ms: u64) -> Self {
926        let (min_ms, max_ms) = if min_ms <= max_ms {
927            (min_ms, max_ms)
928        } else {
929            (max_ms, min_ms)
930        };
931        let seed = SystemTime::now()
932            .duration_since(UNIX_EPOCH)
933            .map_or(0xdead_beef_cafe_1337, |d| {
934                d.as_secs() ^ u64::from(d.subsec_nanos())
935            });
936        Self {
937            rng: seed,
938            mean_ms,
939            std_ms,
940            min_ms,
941            max_ms,
942            last_request: None,
943        }
944    }
945
946    /// Construct from a target requests-per-second rate.
947    ///
948    /// Mean = `1000 / rps` ms, σ = 25 % of mean, clamped to ±50 % of mean.
949    ///
950    /// `rps` is clamped to a minimum of `0.01` to avoid division by zero and
951    /// extreme near-zero denominators.
952    ///
953    /// # Example
954    ///
955    /// ```
956    /// use stygian_browser::behavior::RequestPacer;
957    /// let _pacer = RequestPacer::with_rate(0.5); // ~1 request every 2 s
958    /// ```
959    #[must_use]
960    pub fn with_rate(requests_per_second: f64) -> Self {
961        let mean_ms = (1_000.0 / requests_per_second.max(0.01)).max(1.0) as u64;
962        let std_ms = mean_ms / 4;
963        let min_ms = mean_ms / 2;
964        let max_ms = mean_ms.saturating_mul(2);
965        let seed = SystemTime::now()
966            .duration_since(UNIX_EPOCH)
967            .map_or(0xdead_beef_cafe_1337, |d| {
968                d.as_secs() ^ u64::from(d.subsec_nanos())
969            });
970        Self {
971            rng: seed,
972            mean_ms,
973            std_ms,
974            min_ms,
975            max_ms,
976            last_request: None,
977        }
978    }
979
980    /// Wait until the appropriate inter-request delay has elapsed, then return.
981    ///
982    /// The first call returns immediately.  Subsequent calls sleep remaining time
983    /// to match the sampled target delay.
984    ///
985    /// # Example
986    ///
987    /// ```no_run
988    /// # async fn run() {
989    /// use stygian_browser::behavior::RequestPacer;
990    /// let mut pacer = RequestPacer::new();
991    /// pacer.throttle().await; // first call: immediate
992    /// pacer.throttle().await; // waits ~1.2 s
993    /// # }
994    /// ```
995    pub async fn throttle(&mut self) {
996        let target_ms = rand_normal(&mut self.rng, self.mean_ms as f64, self.std_ms as f64)
997            .max(self.min_ms as f64)
998            .min(self.max_ms as f64) as u64;
999
1000        if let Some(last) = self.last_request {
1001            let elapsed_ms = last.elapsed().as_millis() as u64;
1002            if elapsed_ms < target_ms {
1003                sleep(Duration::from_millis(target_ms - elapsed_ms)).await;
1004            }
1005        }
1006        self.last_request = Some(Instant::now());
1007    }
1008}
1009
1010// ─── Tests ────────────────────────────────────────────────────────────────────
1011
1012#[cfg(test)]
1013mod tests {
1014    use super::*;
1015
1016    #[test]
1017    fn mouse_simulator_starts_at_origin() {
1018        let mouse = MouseSimulator::new();
1019        assert_eq!(mouse.position(), (0.0, 0.0));
1020    }
1021
1022    #[test]
1023    fn mouse_simulator_with_seed_and_position() {
1024        let mouse = MouseSimulator::with_seed_and_position(42, 150.0, 300.0);
1025        assert_eq!(mouse.position(), (150.0, 300.0));
1026    }
1027
1028    #[test]
1029    fn compute_path_minimum_steps_for_zero_distance() {
1030        let mut mouse = MouseSimulator::with_seed_and_position(1, 100.0, 100.0);
1031        let path = mouse.compute_path(100.0, 100.0, 100.0, 100.0);
1032        // 12 steps minimum => 13 points (0..=12)
1033        assert!(path.len() >= 13);
1034    }
1035
1036    #[test]
1037    fn compute_path_scales_with_distance() {
1038        let mut mouse_near = MouseSimulator::with_seed_and_position(1, 0.0, 0.0);
1039        let mut mouse_far = MouseSimulator::with_seed_and_position(1, 0.0, 0.0);
1040
1041        let short_path = mouse_near.compute_path(0.0, 0.0, 30.0, 0.0);
1042        let long_path = mouse_far.compute_path(0.0, 0.0, 800.0, 0.0);
1043
1044        // Long distance should produce more waypoints.
1045        assert!(long_path.len() > short_path.len());
1046    }
1047
1048    #[test]
1049    fn compute_path_step_cap_at_120() {
1050        let mut mouse = MouseSimulator::with_seed_and_position(99, 0.0, 0.0);
1051        // distance = 10_000 px → would be 1250 steps without cap.
1052        let path = mouse.compute_path(0.0, 0.0, 10_000.0, 0.0);
1053        // 120 steps => 121 points
1054        assert!(path.len() <= 121);
1055    }
1056
1057    #[test]
1058    fn compute_path_endpoint_near_target() {
1059        let mut mouse = MouseSimulator::with_seed_and_position(7, 0.0, 0.0);
1060        let target_x = 500.0_f64;
1061        let target_y = 300.0_f64;
1062        let path = mouse.compute_path(0.0, 0.0, target_x, target_y);
1063        let last = path.last().copied().unwrap_or_default();
1064        // Jitter is tiny; endpoint should be within 5 px.
1065        assert!(
1066            (last.0 - target_x).abs() < 5.0,
1067            "x off by {}",
1068            (last.0 - target_x).abs()
1069        );
1070        assert!(
1071            (last.1 - target_y).abs() < 5.0,
1072            "y off by {}",
1073            (last.1 - target_y).abs()
1074        );
1075    }
1076
1077    #[test]
1078    fn compute_path_startpoint_near_origin() {
1079        let mut mouse = MouseSimulator::with_seed_and_position(3, 50.0, 80.0);
1080        let path = mouse.compute_path(50.0, 80.0, 400.0, 200.0);
1081        // First point should be close to start.
1082        if let Some(first) = path.first() {
1083            assert!((first.0 - 50.0).abs() < 5.0);
1084            assert!((first.1 - 80.0).abs() < 5.0);
1085        }
1086    }
1087
1088    #[test]
1089    fn compute_path_diagonal_movement() {
1090        let mut mouse = MouseSimulator::with_seed_and_position(17, 0.0, 0.0);
1091        let path = mouse.compute_path(0.0, 0.0, 300.0, 400.0);
1092        // 500 px distance → ~62 raw steps, clamped to max(12,62).min(120) = 62 → 63 pts
1093        assert!(path.len() >= 13);
1094        let last = path.last().copied().unwrap_or_default();
1095        assert!((last.0 - 300.0).abs() < 5.0);
1096        assert!((last.1 - 400.0).abs() < 5.0);
1097    }
1098
1099    #[test]
1100    fn compute_path_deterministic_with_same_seed() {
1101        let mut m1 = MouseSimulator::with_seed_and_position(42, 0.0, 0.0);
1102        let mut m2 = MouseSimulator::with_seed_and_position(42, 0.0, 0.0);
1103        let path1 = m1.compute_path(0.0, 0.0, 200.0, 150.0);
1104        let path2 = m2.compute_path(0.0, 0.0, 200.0, 150.0);
1105        assert_eq!(path1.len(), path2.len());
1106        for (a, b) in path1.iter().zip(path2.iter()) {
1107            assert!((a.0 - b.0).abs() < 1e-9);
1108            assert!((a.1 - b.1).abs() < 1e-9);
1109        }
1110    }
1111
1112    #[test]
1113    fn cubic_bezier_at_t0_is_p0() {
1114        let p0 = (10.0, 20.0);
1115        let p1 = (50.0, 100.0);
1116        let p2 = (150.0, 80.0);
1117        let p3 = (200.0, 30.0);
1118        let result = cubic_bezier(p0, p1, p2, p3, 0.0);
1119        assert!((result.0 - p0.0).abs() < 1e-9);
1120        assert!((result.1 - p0.1).abs() < 1e-9);
1121    }
1122
1123    #[test]
1124    fn cubic_bezier_at_t1_is_p3() {
1125        let p0 = (10.0, 20.0);
1126        let p1 = (50.0, 100.0);
1127        let p2 = (150.0, 80.0);
1128        let p3 = (200.0, 30.0);
1129        let result = cubic_bezier(p0, p1, p2, p3, 1.0);
1130        assert!((result.0 - p3.0).abs() < 1e-9);
1131        assert!((result.1 - p3.1).abs() < 1e-9);
1132    }
1133
1134    #[test]
1135    fn rand_f64_is_in_unit_interval() {
1136        let mut state = 12345u64;
1137        for _ in 0..1000 {
1138            let v = rand_f64(&mut state);
1139            assert!((0.0..1.0).contains(&v), "out of range: {v}");
1140        }
1141    }
1142
1143    #[test]
1144    fn rand_range_stays_in_bounds() {
1145        let mut state = 99999u64;
1146        for _ in 0..1000 {
1147            let v = rand_range(&mut state, 10.0, 50.0);
1148            assert!((10.0..50.0).contains(&v), "out of range: {v}");
1149        }
1150    }
1151
1152    #[test]
1153    fn typing_simulator_keystroke_delay_is_positive() {
1154        let mut ts = TypingSimulator::new();
1155        assert!(ts.keystroke_delay().as_millis() > 0);
1156    }
1157
1158    #[test]
1159    fn typing_simulator_keystroke_delay_in_range() {
1160        let mut ts = TypingSimulator::with_seed(123);
1161        for _ in 0..50 {
1162            let d = ts.keystroke_delay();
1163            assert!(
1164                d.as_millis() >= 30 && d.as_millis() <= 200,
1165                "delay out of range: {}ms",
1166                d.as_millis()
1167            );
1168        }
1169    }
1170
1171    #[test]
1172    fn typing_simulator_error_rate_clamps_to_one() {
1173        let ts = TypingSimulator::new().with_error_rate(2.0);
1174        assert!(
1175            (ts.error_rate - 1.0).abs() < 1e-9,
1176            "rate should clamp to 1.0"
1177        );
1178    }
1179
1180    #[test]
1181    fn typing_simulator_error_rate_clamps_to_zero() {
1182        let ts = TypingSimulator::new().with_error_rate(-0.5);
1183        assert!(ts.error_rate.abs() < 1e-9, "rate should clamp to 0.0");
1184    }
1185
1186    #[test]
1187    fn typing_simulator_deterministic_with_same_seed() {
1188        let mut t1 = TypingSimulator::with_seed(999);
1189        let mut t2 = TypingSimulator::with_seed(999);
1190        assert_eq!(t1.keystroke_delay(), t2.keystroke_delay());
1191    }
1192
1193    #[test]
1194    fn adjacent_key_returns_different_char() {
1195        let mut rng = 42u64;
1196        for &ch in &['a', 'b', 's', 'k', 'z', 'm'] {
1197            let adj = adjacent_key(ch, &mut rng);
1198            assert_ne!(adj, ch, "adjacent_key({ch}) should not return itself");
1199        }
1200    }
1201
1202    #[test]
1203    fn adjacent_key_preserves_case() {
1204        let mut rng = 7u64;
1205        let adj = adjacent_key('A', &mut rng);
1206        assert!(
1207            adj.is_uppercase(),
1208            "adjacent_key('A') should return uppercase"
1209        );
1210    }
1211
1212    #[test]
1213    fn adjacent_key_non_alpha_returns_fallback() {
1214        let mut rng = 1u64;
1215        assert_eq!(adjacent_key('!', &mut rng), 'x');
1216        assert_eq!(adjacent_key('5', &mut rng), 'x');
1217    }
1218
1219    #[test]
1220    fn interaction_level_default_is_none() {
1221        assert_eq!(InteractionLevel::default(), InteractionLevel::None);
1222    }
1223
1224    #[test]
1225    fn interaction_simulator_with_seed_is_deterministic() {
1226        let s1 = InteractionSimulator::with_seed(77, InteractionLevel::Low);
1227        let s2 = InteractionSimulator::with_seed(77, InteractionLevel::Low);
1228        assert_eq!(s1.rng, s2.rng);
1229    }
1230
1231    #[test]
1232    fn interaction_simulator_default_is_none_level() {
1233        let sim = InteractionSimulator::default();
1234        assert_eq!(sim.level, InteractionLevel::None);
1235    }
1236
1237    #[test]
1238    fn request_pacer_new_has_expected_defaults() {
1239        let p = RequestPacer::new();
1240        assert_eq!(p.mean_ms, 1_200);
1241        assert_eq!(p.min_ms, 400);
1242        assert_eq!(p.max_ms, 4_000);
1243        assert!(p.last_request.is_none());
1244    }
1245
1246    #[test]
1247    fn request_pacer_with_timing_stores_params() {
1248        let p = RequestPacer::with_timing(500, 100, 200, 2_000);
1249        assert_eq!(p.mean_ms, 500);
1250        assert_eq!(p.std_ms, 100);
1251        assert_eq!(p.min_ms, 200);
1252        assert_eq!(p.max_ms, 2_000);
1253    }
1254
1255    #[test]
1256    fn request_pacer_with_rate_computes_mean() {
1257        // 0.5 rps → mean = 2 000 ms
1258        let p = RequestPacer::with_rate(0.5);
1259        assert_eq!(p.mean_ms, 2_000);
1260        assert_eq!(p.min_ms, 1_000);
1261        assert_eq!(p.max_ms, 4_000);
1262    }
1263
1264    #[test]
1265    fn request_pacer_with_rate_clamps_extreme() {
1266        // Very high rps yields a very small mean delay; mean_ms should still be at least 1 ms
1267        let p = RequestPacer::with_rate(10_000.0);
1268        assert!(p.mean_ms >= 1);
1269    }
1270
1271    #[test]
1272    fn request_pacer_with_timing_swaps_inverted_bounds() {
1273        let p = RequestPacer::with_timing(500, 100, 2_000, 200);
1274        assert_eq!(p.min_ms, 200);
1275        assert_eq!(p.max_ms, 2_000);
1276    }
1277
1278    #[tokio::test]
1279    async fn request_pacer_throttle_first_immediate_then_waits() {
1280        let mut p = RequestPacer::with_timing(25, 0, 25, 25);
1281
1282        // First call should complete immediately.
1283        p.throttle().await;
1284
1285        // Second call should enforce a measurable delay.
1286        let started = Instant::now();
1287        p.throttle().await;
1288        assert!(
1289            started.elapsed() >= Duration::from_millis(15),
1290            "second throttle should wait before returning"
1291        );
1292    }
1293}