captchaforge 0.2.36

[DO NOT USE — UNDER ACTIVE DEVELOPMENT, NOT PRODUCTION-READY] Captcha solver scaffolding for chromiumoxide-driven browsers. The architecture is in place (vendor solvers, retry-loop iframe walking, VLM provider abstraction, real-WAF bench harness) but the live-vendor success rate is still 0% — Cloudflare Turnstile / hCaptcha / reCAPTCHA detect us at a TLS / CDP fingerprint layer that no flag-based stealth has cleared. Watch the repo; do not depend on this for any real workload.
Documentation
//! Property tests for `mouse_sampler::MouseSampler`. The sampler
//! generates anonymised-real-human mouse traces fed into chrome's
//! CDP `Input.dispatchMouseEvent` — a NaN dx/dy crashes the chrome
//! tab, and a wildly off-target end position breaks slider solvers
//! (the puzzle slider lands at the wrong x).

use captchaforge::mouse_sampler::MouseSampler;
use proptest::prelude::*;

proptest! {
    /// Sampled traces never contain NaN/Inf in dx/dy/dt — these
    /// would crash the chrome tab when dispatched via CDP.
    #[test]
    fn sampled_trace_steps_are_finite(
        x0 in -2000.0f32..2000.0,
        y0 in -2000.0f32..2000.0,
        x1 in -2000.0f32..2000.0,
        y1 in -2000.0f32..2000.0,
    ) {
        let sampler = MouseSampler::new();
        let trace = sampler.sample(x0, y0, x1, y1);
        for step in &trace.steps {
            prop_assert!(step.dx.is_finite(),
                "dx={} non-finite for ({},{})→({},{})", step.dx, x0, y0, x1, y1);
            prop_assert!(step.dy.is_finite(),
                "dy={} non-finite for ({},{})→({},{})", step.dy, x0, y0, x1, y1);
        }
    }

    /// Sampled traces hit the requested end point (within ±2 px
    /// jitter tolerance — the affine_transform_with_jitter docstring
    /// guarantees ±2 px per step but the cumulative final position
    /// should still be very close to the requested target).
    #[test]
    fn sampled_trace_lands_near_target(
        x0 in -500.0f32..500.0,
        y0 in -500.0f32..500.0,
        x1 in -500.0f32..500.0,
        y1 in -500.0f32..500.0,
    ) {
        let sampler = MouseSampler::new();
        let trace = sampler.sample(x0, y0, x1, y1);
        let final_x: f32 = trace.steps.iter().map(|s| s.dx).sum();
        let final_y: f32 = trace.steps.iter().map(|s| s.dy).sum();
        let actual_end_x = x0 + final_x;
        let actual_end_y = y0 + final_y;
        // Tolerance: ±2 px per step over up to ~50 steps = ±100 px
        // worst case. Relax bound accordingly.
        let tol = 100.0;
        prop_assert!((actual_end_x - x1).abs() < tol,
            "ended at x={}, expected {}, dx={}", actual_end_x, x1, final_x);
        prop_assert!((actual_end_y - y1).abs() < tol,
            "ended at y={}, expected {}, dy={}", actual_end_y, y1, final_y);
    }

    /// Sampled traces always have at least one step — empty traces
    /// would mean no mouse event dispatched.
    #[test]
    fn sampled_trace_is_non_empty(
        x0 in -100.0f32..100.0,
        y0 in -100.0f32..100.0,
        x1 in -100.0f32..100.0,
        y1 in -100.0f32..100.0,
    ) {
        let sampler = MouseSampler::new();
        let trace = sampler.sample(x0, y0, x1, y1);
        prop_assert!(!trace.steps.is_empty(),
            "sampled trace must have ≥1 step");
    }

    /// Total dt across a sampled trace stays in a humanlike range
    /// (50 ms - 5000 ms). Faster than 50 ms = bot-detection signal,
    /// slower than 5 s = solver timeout.
    #[test]
    fn sampled_trace_duration_is_humanlike(
        x0 in -200.0f32..200.0,
        y0 in -200.0f32..200.0,
        x1 in -200.0f32..200.0,
        y1 in -200.0f32..200.0,
    ) {
        let sampler = MouseSampler::new();
        let trace = sampler.sample(x0, y0, x1, y1);
        let total_ms: u32 = trace.steps.iter().map(|s| s.dt_ms).sum();
        prop_assert!(total_ms > 0, "total duration must be positive");
        prop_assert!(total_ms <= 8000,
            "total duration {} ms exceeds humanlike upper bound", total_ms);
    }
}

#[test]
fn bundled_corpus_is_non_empty() {
    let sampler = MouseSampler::new();
    assert!(!sampler.corpus().is_empty(),
        "MouseSampler::new() must bundle real traces — empty corpus would force the solver to use the synthetic single-step fallback");
}

#[test]
fn extra_traces_extend_corpus() {
    use captchaforge::mouse_sampler::{Step, Trace};
    let sampler = MouseSampler::new();
    let baseline = sampler.corpus().len();
    let extra = vec![Trace {
        steps: vec![Step {
            dx: 1.0,
            dy: 0.0,
            dt_ms: 100,
        }],
    }];
    let extended = MouseSampler::new().with_extra_traces(extra);
    assert_eq!(extended.corpus().len(), baseline + 1);
}