Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
Standalone product rename across the whole repo (issue: project identity): - Binary/package/repo/skill/docs: agent-browser[-stealth] → chrome-use (single binary name `chrome-use`; old aliases agent-browser/abs dropped). - Version: 0.27.0-fork.51 → 1.0.0 (drop the upstream-fork counter). - Native-messaging host: com.agent_browser.connect → com.leeguoo.chrome_use (CLI + ab-connect extension in lockstep — this is a breaking handshake change, extension bumped 0.4.2 → 0.5.0, needs a Web Store republish). - Config dir: ~/.agent-browser → ~/.chrome-use. - README/zh: reframed from "stealth fork of agent-browser" to a standalone product with a small `originally based on vercel-labs/agent-browser` credit. - Kept AGENT_BROWSER_* env vars working (63 vars across the codebase; renaming them would break every existing script/skill for no user-facing gain). Build green, 802 unit tests pass, fmt + clippy clean. Upstream attribution to vercel-labs/agent-browser preserved.
518 lines
19 KiB
Rust
518 lines
19 KiB
Rust
//! Human-like input behaviour for stealth.
|
|
//!
|
|
//! When chrome-use drives a real Chrome over CDP, the input events it
|
|
//! dispatches are already `isTrusted` — but a click that teleports the cursor
|
|
//! straight to an element's exact centre, with no approach path and zero delay
|
|
//! between move/press/release, is a behavioural tell that advanced anti-bot
|
|
//! vendors (Akamai, PerimeterX, DataDome) look for.
|
|
//!
|
|
//! This module produces **human-like motion plans** — curved, eased cursor
|
|
//! trajectories and variable keystroke timing — as *pure data*. It performs no
|
|
//! I/O and knows nothing about CDP: callers turn the returned steps into
|
|
//! `Input.dispatchMouseEvent` / `dispatchKeyEvent` calls. Keeping the maths pure
|
|
//! makes the easing/jitter/detection logic unit-testable and deterministic
|
|
//! (every randomised value comes from a caller-supplied seed).
|
|
//!
|
|
//! Design (see brainstorm 2026-06-11):
|
|
//! - Three levels: [`HumanizeLevel::Off`] (instant, today's behaviour),
|
|
//! `Fast` (a few cheap eased steps), `Human` (full curved trajectory + jitter).
|
|
//! - Baseline is `Off`; the daemon escalates a session to `Human` when
|
|
//! [`detect_level`] spots a known anti-bot vendor on the page. `--humanize` /
|
|
//! `AGENT_BROWSER_HUMANIZE` force a fixed level.
|
|
//! - Humanization only changes *how* the cursor reaches a target, never *which*
|
|
//! element is hit: the landing jitter stays inside the caller-provided bounds.
|
|
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::{Mutex, OnceLock};
|
|
use std::time::Duration;
|
|
|
|
// ---- daemon-wide runtime state -------------------------------------------
|
|
//
|
|
// The pure motion maths above are stateless. The daemon drives one active page
|
|
// at a time, so we keep the *current* humanize level and last cursor position
|
|
// in process-global slots rather than threading them through every call site.
|
|
// (The adaptive detector flips the level per navigation; `dispatch_click` reads
|
|
// the level + cursor here, so no signature in the click/type call graph has to
|
|
// change.)
|
|
|
|
/// `AGENT_BROWSER_HUMANIZE` forces a fixed level, overriding the adaptive
|
|
/// detector. Parsed once.
|
|
fn env_override() -> Option<HumanizeLevel> {
|
|
static OVERRIDE: OnceLock<Option<HumanizeLevel>> = OnceLock::new();
|
|
*OVERRIDE.get_or_init(|| {
|
|
std::env::var("AGENT_BROWSER_HUMANIZE")
|
|
.ok()
|
|
.and_then(|s| HumanizeLevel::parse(&s))
|
|
})
|
|
}
|
|
|
|
fn session_level() -> &'static Mutex<HumanizeLevel> {
|
|
static LEVEL: OnceLock<Mutex<HumanizeLevel>> = OnceLock::new();
|
|
LEVEL.get_or_init(|| Mutex::new(HumanizeLevel::Off))
|
|
}
|
|
|
|
fn last_cursor_slot() -> &'static Mutex<(f64, f64)> {
|
|
static CURSOR: OnceLock<Mutex<(f64, f64)>> = OnceLock::new();
|
|
CURSOR.get_or_init(|| Mutex::new((0.0, 0.0)))
|
|
}
|
|
|
|
/// The level that should apply right now: the env override if set, else the
|
|
/// level the detector last chose for the active page.
|
|
pub fn active_level() -> HumanizeLevel {
|
|
env_override().unwrap_or_else(|| *session_level().lock().unwrap())
|
|
}
|
|
|
|
/// Set by the adaptive detector after navigation. Ignored while an env override
|
|
/// is in force (so `--humanize` always wins).
|
|
pub fn set_detected_level(level: HumanizeLevel) {
|
|
*session_level().lock().unwrap() = level;
|
|
}
|
|
|
|
/// Where the virtual cursor currently sits, so the next move starts from there
|
|
/// instead of teleporting.
|
|
pub fn last_cursor() -> (f64, f64) {
|
|
*last_cursor_slot().lock().unwrap()
|
|
}
|
|
|
|
/// Record the cursor landing point after a move/click.
|
|
pub fn set_last_cursor(p: (f64, f64)) {
|
|
*last_cursor_slot().lock().unwrap() = p;
|
|
}
|
|
|
|
/// A fresh seed per action so repeated clicks on the same point still vary,
|
|
/// without touching the wall clock or a global RNG (both would break replay).
|
|
pub fn next_seed() -> u64 {
|
|
static COUNTER: AtomicU64 = AtomicU64::new(0x1234_5678);
|
|
COUNTER
|
|
.fetch_add(0x9E37_79B9_7F4A_7C15, Ordering::Relaxed)
|
|
.rotate_left(17)
|
|
}
|
|
|
|
/// How human-like input motion should be.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
|
pub enum HumanizeLevel {
|
|
/// Instant: a single move to the exact point, no delays. Original behaviour.
|
|
#[default]
|
|
Off,
|
|
/// A few eased steps with small delays — cheap cover for ordinary sites.
|
|
Fast,
|
|
/// Full curved, decelerating trajectory with landing jitter and press
|
|
/// dwell — for pages guarded by behavioural anti-bot systems.
|
|
Human,
|
|
}
|
|
|
|
impl HumanizeLevel {
|
|
/// Parse a user-supplied level (`--humanize` / `AGENT_BROWSER_HUMANIZE`).
|
|
pub fn parse(s: &str) -> Option<Self> {
|
|
match s.trim().to_ascii_lowercase().as_str() {
|
|
"off" | "none" | "instant" | "0" => Some(Self::Off),
|
|
"fast" | "light" | "low" => Some(Self::Fast),
|
|
"human" | "full" | "high" | "max" => Some(Self::Human),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn is_off(self) -> bool {
|
|
matches!(self, Self::Off)
|
|
}
|
|
}
|
|
|
|
/// One step of a humanized cursor move: dispatch `mouseMoved` to (`x`, `y`),
|
|
/// then sleep for `delay` before the next step. The final step's point is where
|
|
/// the press/release should land.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct MoveStep {
|
|
pub x: f64,
|
|
pub y: f64,
|
|
pub delay: Duration,
|
|
}
|
|
|
|
/// Tiny deterministic PRNG (xorshift64*). Seeded by the caller so trajectories
|
|
/// are reproducible in tests; we avoid pulling in the `rand` crate and never
|
|
/// call a wall-clock/global RNG (which would also break workflow replay).
|
|
struct Rng(u64);
|
|
|
|
impl Rng {
|
|
fn new(seed: u64) -> Self {
|
|
// Avoid the zero state, which xorshift cannot escape.
|
|
Rng(seed ^ 0x9E37_79B9_7F4A_7C15)
|
|
}
|
|
|
|
fn next_u64(&mut self) -> u64 {
|
|
let mut x = self.0;
|
|
x ^= x >> 12;
|
|
x ^= x << 25;
|
|
x ^= x >> 27;
|
|
self.0 = x;
|
|
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
|
|
}
|
|
|
|
/// Uniform in [0, 1).
|
|
fn unit(&mut self) -> f64 {
|
|
// Top 53 bits → f64 mantissa.
|
|
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
|
|
}
|
|
|
|
/// Uniform in [-1, 1).
|
|
fn signed(&mut self) -> f64 {
|
|
self.unit() * 2.0 - 1.0
|
|
}
|
|
}
|
|
|
|
/// Smootherstep ease (zero velocity at both ends) — used to bias the per-step
|
|
/// timing so the cursor accelerates away from the start and decelerates into
|
|
/// the target, the way a hand does.
|
|
fn ease(t: f64) -> f64 {
|
|
let t = t.clamp(0.0, 1.0);
|
|
t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
|
|
}
|
|
|
|
/// Cubic Bézier point at parameter `t`.
|
|
fn bezier(p0: (f64, f64), p1: (f64, f64), p2: (f64, f64), p3: (f64, f64), t: f64) -> (f64, f64) {
|
|
let u = 1.0 - t;
|
|
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
|
|
(
|
|
a * p0.0 + b * p1.0 + c * p2.0 + d * p3.0,
|
|
a * p0.1 + b * p1.1 + c * p2.1 + d * p3.1,
|
|
)
|
|
}
|
|
|
|
/// Pick a landing point inside `bbox` (`x`, `y`, `width`, `height`). `Off`
|
|
/// returns the exact centre; `Fast`/`Human` jitter around the centre but stay
|
|
/// well inside the element so the click still lands on it.
|
|
pub fn landing_point(bbox: (f64, f64, f64, f64), level: HumanizeLevel, seed: u64) -> (f64, f64) {
|
|
let (bx, by, bw, bh) = bbox;
|
|
let cx = bx + bw / 2.0;
|
|
let cy = by + bh / 2.0;
|
|
if level.is_off() || bw <= 1.0 || bh <= 1.0 {
|
|
return (cx, cy);
|
|
}
|
|
// Keep within the inner 60% so jitter never lands on a neighbouring element
|
|
// or the element's padding/edge.
|
|
let spread = match level {
|
|
HumanizeLevel::Human => 0.30,
|
|
_ => 0.15,
|
|
};
|
|
let mut rng = Rng::new(seed);
|
|
(
|
|
cx + rng.signed() * bw * spread,
|
|
cy + rng.signed() * bh * spread,
|
|
)
|
|
}
|
|
|
|
/// Build the cursor path from `from` to `to`. The last [`MoveStep`] is the
|
|
/// landing point. `Off` yields a single zero-delay step at `to` (today's
|
|
/// teleport), so callers can use one code path for every level.
|
|
pub fn move_path(
|
|
from: (f64, f64),
|
|
to: (f64, f64),
|
|
level: HumanizeLevel,
|
|
seed: u64,
|
|
) -> Vec<MoveStep> {
|
|
if level.is_off() {
|
|
return vec![MoveStep {
|
|
x: to.0,
|
|
y: to.1,
|
|
delay: Duration::ZERO,
|
|
}];
|
|
}
|
|
|
|
let dist = (to.0 - from.0).hypot(to.1 - from.1);
|
|
if dist < 1.0 {
|
|
return vec![MoveStep {
|
|
x: to.0,
|
|
y: to.1,
|
|
delay: Duration::ZERO,
|
|
}];
|
|
}
|
|
|
|
let (steps, total_ms, arc) = match level {
|
|
HumanizeLevel::Fast => {
|
|
let s = ((dist / 120.0).round() as usize).clamp(3, 6);
|
|
(s, (dist * 0.35).clamp(40.0, 130.0), 0.06)
|
|
}
|
|
// Off handled above.
|
|
_ => {
|
|
let s = ((dist / 45.0).round() as usize).clamp(8, 24);
|
|
(s, (dist * 0.9).clamp(140.0, 650.0), 0.16)
|
|
}
|
|
};
|
|
|
|
let mut rng = Rng::new(seed);
|
|
|
|
// Two control points along the line, pushed perpendicular to it to bow the
|
|
// path into a gentle, slightly asymmetric arc.
|
|
let (dx, dy) = (to.0 - from.0, to.1 - from.1);
|
|
let (nx, ny) = (-dy / dist, dx / dist); // unit normal
|
|
let bow = dist * arc * rng.signed();
|
|
let ctrl = |frac: f64, jitter: f64, rng: &mut Rng| {
|
|
let base = (from.0 + dx * frac, from.1 + dy * frac);
|
|
let off = bow * (1.0 + jitter * rng.signed());
|
|
(base.0 + nx * off, base.1 + ny * off)
|
|
};
|
|
let p1 = ctrl(0.33, 0.4, &mut rng);
|
|
let p2 = ctrl(0.66, 0.4, &mut rng);
|
|
|
|
let mut out = Vec::with_capacity(steps);
|
|
let mut prev_ease = 0.0;
|
|
for i in 1..=steps {
|
|
let t = i as f64 / steps as f64;
|
|
// Ease maps wall-time progress so most points cluster near the ends
|
|
// (slow start, slow finish, fast middle).
|
|
let te = ease(t);
|
|
let (x, y) = bezier(from, p1, p2, to, te);
|
|
let frac = te - prev_ease;
|
|
prev_ease = te;
|
|
out.push(MoveStep {
|
|
x,
|
|
y,
|
|
delay: Duration::from_micros((total_ms * frac * 1000.0).max(0.0) as u64),
|
|
});
|
|
}
|
|
// Guarantee the final point is exactly the target.
|
|
if let Some(last) = out.last_mut() {
|
|
last.x = to.0;
|
|
last.y = to.1;
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Split a wheel scroll of (`total_dx`, `total_dy`) into eased segments. `Off`
|
|
/// returns a single instant segment (today's one-shot scroll); `Fast`/`Human`
|
|
/// break it into several accelerate-then-decelerate chunks with small,
|
|
/// jittered inter-segment delays, the way a trackpad/wheel flick actually
|
|
/// lands. The segment deltas always sum to the requested total.
|
|
pub fn scroll_segments(
|
|
total_dx: f64,
|
|
total_dy: f64,
|
|
level: HumanizeLevel,
|
|
seed: u64,
|
|
) -> Vec<(f64, f64, Duration)> {
|
|
if level.is_off() {
|
|
return vec![(total_dx, total_dy, Duration::ZERO)];
|
|
}
|
|
let (segs, base_ms) = match level {
|
|
HumanizeLevel::Fast => (4usize, 18.0),
|
|
_ => (9usize, 28.0),
|
|
};
|
|
let mut rng = Rng::new(seed);
|
|
let mut out = Vec::with_capacity(segs);
|
|
let mut prev = 0.0;
|
|
for i in 1..=segs {
|
|
let f = ease(i as f64 / segs as f64);
|
|
let frac = f - prev;
|
|
prev = f;
|
|
let jitter = 1.0 + 0.3 * rng.signed();
|
|
out.push((
|
|
total_dx * frac,
|
|
total_dy * frac,
|
|
Duration::from_millis((base_ms * jitter).max(4.0) as u64),
|
|
));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Dwell between `mousePressed` and `mouseReleased` (a real click isn't
|
|
/// instantaneous). Zero for `Off`.
|
|
pub fn press_dwell(level: HumanizeLevel, seed: u64) -> Duration {
|
|
match level {
|
|
HumanizeLevel::Off => Duration::ZERO,
|
|
HumanizeLevel::Fast => Duration::from_millis(20 + (seed % 30)),
|
|
HumanizeLevel::Human => Duration::from_millis(50 + (seed % 90)),
|
|
}
|
|
}
|
|
|
|
/// Per-character delays for typing `len` characters. `Off` is all-zero (use a
|
|
/// single `Input.insertText`); `Fast`/`Human` produce variable inter-keystroke
|
|
/// gaps with the occasional longer "think" pause, like a real typist.
|
|
pub fn keystroke_delays(len: usize, level: HumanizeLevel, seed: u64) -> Vec<Duration> {
|
|
if level.is_off() || len == 0 {
|
|
return vec![Duration::ZERO; len];
|
|
}
|
|
let (mean, jitter, pause_chance, pause_extra) = match level {
|
|
HumanizeLevel::Fast => (25.0, 15.0, 0.0, 0.0),
|
|
_ => (95.0, 55.0, 0.06, 220.0),
|
|
};
|
|
let mut rng = Rng::new(seed);
|
|
(0..len)
|
|
.map(|_| {
|
|
let mut ms = (mean + rng.signed() * jitter).max(8.0);
|
|
if pause_chance > 0.0 && rng.unit() < pause_chance {
|
|
ms += rng.unit() * pause_extra;
|
|
}
|
|
Duration::from_millis(ms as u64)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Page signals sampled after navigation, used to decide whether to escalate a
|
|
/// session to [`HumanizeLevel::Human`]. All strings are matched case-insensitively.
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct DetectSignals {
|
|
/// Cookie names present on the document (e.g. `_abck`, `datadome`).
|
|
pub cookie_names: Vec<String>,
|
|
/// `src` of loaded scripts.
|
|
pub script_urls: Vec<String>,
|
|
/// Names of suspicious globals on `window` (e.g. `_px`, `bmak`).
|
|
pub window_globals: Vec<String>,
|
|
}
|
|
|
|
/// Known behavioural anti-bot fingerprints: (substring, vendor). Matched against
|
|
/// cookie names, script URLs, and window globals.
|
|
const VENDOR_MARKERS: &[(&str, &str)] = &[
|
|
("_abck", "akamai"),
|
|
("bm_sz", "akamai"),
|
|
("ak_bmsc", "akamai"),
|
|
("bmak", "akamai"),
|
|
("_px", "perimeterx"),
|
|
("perimeterx", "perimeterx"),
|
|
("px-cloud", "perimeterx"),
|
|
("datadome", "datadome"),
|
|
("kpsdk", "kasada"),
|
|
("incap_ses", "imperva"),
|
|
("visid_incap", "imperva"),
|
|
("reese84", "imperva"),
|
|
("__cf_bm", "cloudflare-bot-mgmt"),
|
|
];
|
|
|
|
/// Decide the level for a page. Returns `Human` if any known anti-bot vendor is
|
|
/// present, otherwise `baseline`. Misses just stay at baseline and false hits
|
|
/// only cost a little latency, so matching is deliberately liberal.
|
|
pub fn detect_level(signals: &DetectSignals, baseline: HumanizeLevel) -> HumanizeLevel {
|
|
let hay: Vec<String> = signals
|
|
.cookie_names
|
|
.iter()
|
|
.chain(signals.script_urls.iter())
|
|
.chain(signals.window_globals.iter())
|
|
.map(|s| s.to_ascii_lowercase())
|
|
.collect();
|
|
let matched = VENDOR_MARKERS
|
|
.iter()
|
|
.any(|(marker, _)| hay.iter().any(|h| h.contains(marker)));
|
|
if matched {
|
|
HumanizeLevel::Human
|
|
} else {
|
|
baseline
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parse_accepts_known_levels_and_rejects_junk() {
|
|
assert_eq!(HumanizeLevel::parse("off"), Some(HumanizeLevel::Off));
|
|
assert_eq!(HumanizeLevel::parse(" FAST "), Some(HumanizeLevel::Fast));
|
|
assert_eq!(HumanizeLevel::parse("Human"), Some(HumanizeLevel::Human));
|
|
assert_eq!(HumanizeLevel::parse("max"), Some(HumanizeLevel::Human));
|
|
assert_eq!(HumanizeLevel::parse("wat"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn off_level_teleports_in_one_step() {
|
|
let path = move_path((0.0, 0.0), (100.0, 50.0), HumanizeLevel::Off, 1);
|
|
assert_eq!(path.len(), 1);
|
|
assert_eq!((path[0].x, path[0].y), (100.0, 50.0));
|
|
assert_eq!(path[0].delay, Duration::ZERO);
|
|
}
|
|
|
|
#[test]
|
|
fn humanized_path_is_multi_step_and_lands_exactly_on_target() {
|
|
let to = (640.0, 480.0);
|
|
let path = move_path((10.0, 10.0), to, HumanizeLevel::Human, 42);
|
|
assert!(path.len() >= 8, "human path should have many steps");
|
|
let last = path.last().unwrap();
|
|
assert_eq!((last.x, last.y), to, "final point must equal the target");
|
|
// Path must actually leave the straight line at some point (it's a curve).
|
|
let straight = path.iter().all(|s| {
|
|
let t = (s.x - 10.0) / (to.0 - 10.0);
|
|
(s.y - (10.0 + t * (to.1 - 10.0))).abs() < 0.5
|
|
});
|
|
assert!(!straight, "human path should bow off the straight line");
|
|
}
|
|
|
|
#[test]
|
|
fn fast_path_is_shorter_than_human() {
|
|
let fast = move_path((0.0, 0.0), (500.0, 500.0), HumanizeLevel::Fast, 7);
|
|
let human = move_path((0.0, 0.0), (500.0, 500.0), HumanizeLevel::Human, 7);
|
|
assert!(fast.len() < human.len());
|
|
assert!((3..=6).contains(&fast.len()));
|
|
}
|
|
|
|
#[test]
|
|
fn move_path_is_deterministic_for_a_seed() {
|
|
let a = move_path((1.0, 2.0), (300.0, 400.0), HumanizeLevel::Human, 99);
|
|
let b = move_path((1.0, 2.0), (300.0, 400.0), HumanizeLevel::Human, 99);
|
|
assert_eq!(a, b);
|
|
let c = move_path((1.0, 2.0), (300.0, 400.0), HumanizeLevel::Human, 100);
|
|
assert_ne!(a, c, "different seeds should differ");
|
|
}
|
|
|
|
#[test]
|
|
fn landing_point_stays_inside_bounds_and_centres_when_off() {
|
|
let bbox = (100.0, 100.0, 40.0, 20.0);
|
|
assert_eq!(landing_point(bbox, HumanizeLevel::Off, 1), (120.0, 110.0));
|
|
for seed in 0..200 {
|
|
let (x, y) = landing_point(bbox, HumanizeLevel::Human, seed);
|
|
assert!(x > 100.0 && x < 140.0, "x {x} escaped bbox");
|
|
assert!(y > 100.0 && y < 120.0, "y {y} escaped bbox");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn keystroke_delays_zero_when_off_and_positive_otherwise() {
|
|
assert!(keystroke_delays(5, HumanizeLevel::Off, 1)
|
|
.iter()
|
|
.all(|d| *d == Duration::ZERO));
|
|
let human = keystroke_delays(20, HumanizeLevel::Human, 3);
|
|
assert_eq!(human.len(), 20);
|
|
assert!(human.iter().all(|d| *d >= Duration::from_millis(8)));
|
|
}
|
|
|
|
#[test]
|
|
fn scroll_segments_sum_to_total_and_single_when_off() {
|
|
let off = scroll_segments(0.0, 600.0, HumanizeLevel::Off, 1);
|
|
assert_eq!(off.len(), 1);
|
|
assert_eq!((off[0].0, off[0].1), (0.0, 600.0));
|
|
assert_eq!(off[0].2, Duration::ZERO);
|
|
|
|
let human = scroll_segments(0.0, 600.0, HumanizeLevel::Human, 5);
|
|
assert!(human.len() >= 5);
|
|
let total_dy: f64 = human.iter().map(|s| s.1).sum();
|
|
assert!(
|
|
(total_dy - 600.0).abs() < 1e-6,
|
|
"segments must sum to total"
|
|
);
|
|
assert!(human.iter().all(|s| s.2 >= Duration::from_millis(4)));
|
|
}
|
|
|
|
#[test]
|
|
fn detect_escalates_on_known_vendor_else_baseline() {
|
|
let mut s = DetectSignals::default();
|
|
assert_eq!(detect_level(&s, HumanizeLevel::Off), HumanizeLevel::Off);
|
|
|
|
s.cookie_names = vec!["sessionid".into(), "_abck".into()];
|
|
assert_eq!(detect_level(&s, HumanizeLevel::Off), HumanizeLevel::Human);
|
|
|
|
let s2 = DetectSignals {
|
|
script_urls: vec!["https://cdn.example.com/DataDome-tags.js".into()],
|
|
..Default::default()
|
|
};
|
|
assert_eq!(detect_level(&s2, HumanizeLevel::Off), HumanizeLevel::Human);
|
|
|
|
let s3 = DetectSignals {
|
|
window_globals: vec!["_pxAppId".into()],
|
|
..Default::default()
|
|
};
|
|
assert_eq!(detect_level(&s3, HumanizeLevel::Fast), HumanizeLevel::Human);
|
|
|
|
// Unknown signals keep the baseline.
|
|
let s4 = DetectSignals {
|
|
cookie_names: vec!["cart".into(), "theme".into()],
|
|
..Default::default()
|
|
};
|
|
assert_eq!(detect_level(&s4, HumanizeLevel::Fast), HumanizeLevel::Fast);
|
|
}
|
|
}
|