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
//! Behaviour contract for `PatternStore` — the crowd-sourced
//! per-domain memory of which solver method has worked. Pure-Rust
//! tests; no chromium needed.

use captchaforge::solver::{CaptchaPattern, CaptchaType, PatternStore, SolveMethod};
use std::sync::Arc;

#[test]
fn empty_store_returns_none_for_unknown_domain() {
    let s = PatternStore::default();
    assert!(s
        .best_method("never-seen.com", &CaptchaType::CloudflareTurnstile)
        .is_none());
}

#[test]
fn record_then_lookup_returns_the_method() {
    let s = PatternStore::default();
    s.record(
        "example.com",
        &CaptchaType::HCaptcha,
        true,
        500,
        SolveMethod::BehavioralBypass,
    );
    let got = s.best_method("example.com", &CaptchaType::HCaptcha);
    assert_eq!(got, Some(SolveMethod::BehavioralBypass));
}

#[test]
fn different_captcha_types_on_same_domain_are_keyed_independently() {
    let s = PatternStore::default();
    s.record(
        "x.com",
        &CaptchaType::CloudflareTurnstile,
        true,
        100,
        SolveMethod::BehavioralBypass,
    );
    s.record(
        "x.com",
        &CaptchaType::HCaptcha,
        true,
        200,
        SolveMethod::VisionLLM,
    );
    assert_eq!(
        s.best_method("x.com", &CaptchaType::CloudflareTurnstile),
        Some(SolveMethod::BehavioralBypass)
    );
    assert_eq!(
        s.best_method("x.com", &CaptchaType::HCaptcha),
        Some(SolveMethod::VisionLLM)
    );
}

#[test]
fn ties_break_to_faster_method() {
    // Per-method EMA gives both observations success_rate=1.0 after
    // one sample each. Ties resolve to the faster avg_solve_time —
    // BehavioralBypass at 100ms beats VisionLLM at 200ms.
    let s = PatternStore::default();
    s.record(
        "y.com",
        &CaptchaType::CloudflareTurnstile,
        true,
        100,
        SolveMethod::BehavioralBypass,
    );
    s.record(
        "y.com",
        &CaptchaType::CloudflareTurnstile,
        true,
        200,
        SolveMethod::VisionLLM,
    );
    assert_eq!(
        s.best_method("y.com", &CaptchaType::CloudflareTurnstile),
        Some(SolveMethod::BehavioralBypass),
        "tied success rates resolve to the faster method",
    );
}

#[test]
fn failures_do_not_overwrite_best_method() {
    let s = PatternStore::default();
    s.record(
        "z.com",
        &CaptchaType::CloudflareTurnstile,
        true,
        100,
        SolveMethod::BehavioralBypass,
    );
    s.record(
        "z.com",
        &CaptchaType::CloudflareTurnstile,
        false,
        9999,
        SolveMethod::AudioBypass,
    );
    assert_eq!(
        s.best_method("z.com", &CaptchaType::CloudflareTurnstile),
        Some(SolveMethod::BehavioralBypass),
        "AudioBypass failed; should NOT replace BehavioralBypass"
    );
}

#[test]
fn all_patterns_returns_one_entry_per_domain_type_pair() {
    let s = PatternStore::default();
    s.record(
        "a.com",
        &CaptchaType::HCaptcha,
        true,
        100,
        SolveMethod::VisionLLM,
    );
    s.record(
        "b.com",
        &CaptchaType::HCaptcha,
        true,
        100,
        SolveMethod::VisionLLM,
    );
    s.record(
        "a.com",
        &CaptchaType::CloudflareTurnstile,
        true,
        100,
        SolveMethod::BehavioralBypass,
    );
    let mut domains: Vec<String> = s.all_patterns().into_iter().map(|p| p.domain).collect();
    domains.sort();
    assert_eq!(domains, vec!["a.com", "a.com", "b.com"]);
}

#[test]
fn success_rate_recent_failures_dominate_under_ema() {
    // Bounded-α EMA puts recency weight on the latest samples — which
    // is the whole point: when a vendor flips and a previously-best
    // method starts failing, we want the routing to react within
    // ~10 samples instead of waiting for 100s of historical wins to
    // average out. Assert that 30 trailing failures push the EMA
    // toward 0, NOT toward the cumulative-mean 0.70.
    let mut p = CaptchaPattern::new(
        "ex.com",
        CaptchaType::CloudflareTurnstile,
        SolveMethod::BehavioralBypass,
    );
    for _ in 0..70 {
        p.record(true, 100, SolveMethod::BehavioralBypass);
    }
    let before_failures = p.success_rate;
    for _ in 0..30 {
        p.record(false, 100, SolveMethod::BehavioralBypass);
    }
    assert_eq!(p.sample_count, 100);
    assert!(
        before_failures > 0.9,
        "after 70 wins, EMA should be near 1.0; got {before_failures}",
    );
    assert!(
        p.success_rate < 0.05,
        "after 30 trailing failures, EMA should fall below 0.05; got {}",
        p.success_rate
    );
}

#[test]
fn avg_solve_time_converges_to_observed_mean() {
    let mut p = CaptchaPattern::new(
        "ex.com",
        CaptchaType::CloudflareTurnstile,
        SolveMethod::BehavioralBypass,
    );
    for ms in [100u64, 200, 300, 400, 500] {
        p.record(true, ms, SolveMethod::BehavioralBypass);
    }
    assert_eq!(p.sample_count, 5);
    assert_eq!(
        p.avg_solve_time, 300,
        "mean of 100..500 step 100 is 300, got {}",
        p.avg_solve_time
    );
}

#[test]
fn pattern_store_is_send_and_sync() {
    fn assert_send_sync<T: Send + Sync>() {}
    assert_send_sync::<PatternStore>();
}

#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn concurrent_records_do_not_drop_observations() {
    let s = Arc::new(PatternStore::default());
    let mut handles = Vec::new();
    for thread_id in 0..8u64 {
        let s = s.clone();
        handles.push(tokio::spawn(async move {
            for _ in 0..125 {
                s.record(
                    "concurrent.com",
                    &CaptchaType::CloudflareTurnstile,
                    true,
                    100 + thread_id * 10,
                    SolveMethod::BehavioralBypass,
                );
            }
        }));
    }
    for h in handles {
        h.await.unwrap();
    }
    let patterns = s.all_patterns();
    assert_eq!(patterns.len(), 1, "single domain+type → single pattern");
    assert_eq!(
        patterns[0].sample_count, 1000,
        "8 threads × 125 records = 1000 (no lost updates)"
    );
}

#[test]
fn pattern_clone_is_independent_of_original() {
    let mut p1 = CaptchaPattern::new(
        "ex.com",
        CaptchaType::HCaptcha,
        SolveMethod::BehavioralBypass,
    );
    p1.record(true, 100, SolveMethod::BehavioralBypass);
    let p2 = p1.clone();
    p1.record(true, 200, SolveMethod::BehavioralBypass);
    assert_eq!(
        p2.sample_count, 1,
        "clone must not share state with original"
    );
    assert_eq!(p1.sample_count, 2);
}