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-based tests for the detection engine.
//!
//! These tests verify that `detect` helpers behave correctly for arbitrary
//! inputs without requiring a live browser.

use captchaforge::detect::{is_captcha, solver_kind_str, CaptchaInfo, DetectedCaptcha};
use proptest::prelude::*;
use proptest::test_runner::TestRunner;

#[test]
fn is_captcha_true_for_all_non_none() {
    let mut runner = TestRunner::default();
    runner
        .run(&detected_captcha_non_none(), |variant| {
            let info = CaptchaInfo {
                kind: variant,
                site_key: None,
                page_url: String::new(),
                container_selector: None,
            };
            prop_assert!(is_captcha(&info));
            Ok(())
        })
        .unwrap();
}

#[test]
fn is_captcha_false_for_none() {
    let info = CaptchaInfo {
        kind: DetectedCaptcha::None,
        site_key: None,
        page_url: String::new(),
        container_selector: None,
    };
    assert!(!is_captcha(&info));
}

#[test]
fn solver_kind_str_matches_variant() {
    let mut runner = TestRunner::default();
    runner
        .run(&any_detected_captcha(), |variant| {
            let result = solver_kind_str(&variant);
            match variant {
                DetectedCaptcha::Turnstile => prop_assert_eq!(result, Some("turnstile")),
                DetectedCaptcha::RecaptchaV2 => prop_assert_eq!(result, Some("recaptcha_v2")),
                DetectedCaptcha::RecaptchaV3 => prop_assert_eq!(result, Some("recaptcha_v3")),
                DetectedCaptcha::HCaptcha => prop_assert_eq!(result, Some("hcaptcha")),
                DetectedCaptcha::ImageCaptcha => prop_assert_eq!(result, Some("image_captcha")),
                DetectedCaptcha::AudioCaptcha => prop_assert_eq!(result, Some("audio_captcha")),
                DetectedCaptcha::PowCaptcha => prop_assert_eq!(result, Some("pow_captcha")),
                DetectedCaptcha::SliderCaptcha => prop_assert_eq!(result, Some("slider_captcha")),
                DetectedCaptcha::CanvasCaptcha => prop_assert_eq!(result, Some("canvas_captcha")),
                DetectedCaptcha::ShadowDomCaptcha => {
                    prop_assert_eq!(result, Some("shadow_dom_captcha"))
                }
                DetectedCaptcha::MultiStepCaptcha => {
                    prop_assert_eq!(result, Some("multi_step_captcha"))
                }
                DetectedCaptcha::Custom(_) => prop_assert_eq!(result, None),
                DetectedCaptcha::None => prop_assert_eq!(result, None),
                // DetectedCaptcha is #[non_exhaustive]; future variants
                // surface as "unmapped" — the test's only contract is
                // that the function returns None for variants it doesn't
                // know yet rather than panicking.
                _ => prop_assert_eq!(result, None),
            }
            Ok(())
        })
        .unwrap();
}

#[test]
fn detected_captcha_serde_roundtrip() {
    let mut runner = TestRunner::default();
    runner
        .run(&any_detected_captcha(), |variant| {
            let json = serde_json::to_string(&variant).unwrap();
            let back: DetectedCaptcha = serde_json::from_str(&json).unwrap();
            prop_assert_eq!(variant, back);
            Ok(())
        })
        .unwrap();
}

#[test]
fn captcha_info_serde_roundtrip() {
    let mut runner = TestRunner::default();
    let strategy = (
        any_detected_captcha(),
        proptest::option::of("[a-zA-Z0-9_-]{0,64}"),
        "https?://[a-z0-9.-]{1,64}/[a-zA-Z0-9/_-]{0,128}",
        proptest::option::of("[.#][a-zA-Z_-][a-zA-Z0-9_-]{0,32}"),
    );
    runner
        .run(&strategy, |(kind, site_key, page_url, container)| {
            let info = CaptchaInfo {
                kind,
                site_key,
                page_url,
                container_selector: container,
            };
            let json = serde_json::to_string(&info).unwrap();
            let back: CaptchaInfo = serde_json::from_str(&json).unwrap();
            prop_assert_eq!(info.kind, back.kind);
            prop_assert_eq!(info.site_key, back.site_key);
            prop_assert_eq!(info.page_url, back.page_url);
            prop_assert_eq!(info.container_selector, back.container_selector);
            Ok(())
        })
        .unwrap();
}

#[test]
fn captcha_info_serde_never_panics() {
    let mut runner = TestRunner::default();
    let strategy = (
        any_detected_captcha(),
        proptest::option::of("(?s).*"),
        "(?s).*",
        proptest::option::of("(?s).*"),
    );
    runner
        .run(&strategy, |(kind, site_key, page_url, container)| {
            let info = CaptchaInfo {
                kind,
                site_key,
                page_url,
                container_selector: container,
            };
            let json = serde_json::to_string(&info);
            prop_assert!(json.is_ok());
            let back: Result<CaptchaInfo, _> = serde_json::from_str(&json.unwrap());
            prop_assert!(back.is_ok());
            Ok(())
        })
        .unwrap();
}

#[test]
fn new_detected_captcha_variants_serialize_to_snake_case() {
    static CASES: &[(DetectedCaptcha, &str)] = &[
        (DetectedCaptcha::PowCaptcha, "pow_captcha"),
        (DetectedCaptcha::SliderCaptcha, "slider_captcha"),
        (DetectedCaptcha::CanvasCaptcha, "canvas_captcha"),
        (DetectedCaptcha::ShadowDomCaptcha, "shadow_dom_captcha"),
        (DetectedCaptcha::MultiStepCaptcha, "multi_step_captcha"),
    ];
    let mut runner = TestRunner::default();
    runner
        .run(&proptest::sample::select(CASES), |case| {
            let variant = &case.0;
            let expected = case.1;
            let json = serde_json::to_string(variant).unwrap();
            prop_assert_eq!(
                json,
                format!("\"{}\"", expected),
                "serialization mismatch for {:?}",
                variant
            );
            Ok(())
        })
        .unwrap();
}

#[test]
fn solver_kind_str_returns_correct_values_for_new_variants() {
    let cases = [
        (DetectedCaptcha::PowCaptcha, Some("pow_captcha")),
        (DetectedCaptcha::SliderCaptcha, Some("slider_captcha")),
        (DetectedCaptcha::CanvasCaptcha, Some("canvas_captcha")),
        (
            DetectedCaptcha::ShadowDomCaptcha,
            Some("shadow_dom_captcha"),
        ),
        (
            DetectedCaptcha::MultiStepCaptcha,
            Some("multi_step_captcha"),
        ),
    ];
    for (variant, expected) in cases {
        assert_eq!(
            solver_kind_str(&variant),
            expected,
            "solver_kind_str mismatch for {:?}",
            variant
        );
    }
}

#[test]
fn detect_js_contains_expected_fragments() {
    let js = captchaforge::detect::DETECT_JS;
    let known = [
        "turnstile",
        "recaptcha",
        "hcaptcha",
        "image",
        "audio",
        "pow",
        "slider",
        "canvas",
        "shadow_dom",
        "multi_step",
        "challenge",
    ];
    assert!(known.iter().any(|&k| js.contains(k)));
}

fn any_detected_captcha() -> impl Strategy<Value = DetectedCaptcha> {
    prop_oneof![
        Just(DetectedCaptcha::Turnstile),
        Just(DetectedCaptcha::RecaptchaV2),
        Just(DetectedCaptcha::RecaptchaV3),
        Just(DetectedCaptcha::HCaptcha),
        Just(DetectedCaptcha::ImageCaptcha),
        Just(DetectedCaptcha::AudioCaptcha),
        Just(DetectedCaptcha::PowCaptcha),
        Just(DetectedCaptcha::SliderCaptcha),
        Just(DetectedCaptcha::CanvasCaptcha),
        Just(DetectedCaptcha::ShadowDomCaptcha),
        Just(DetectedCaptcha::MultiStepCaptcha),
        Just(DetectedCaptcha::None),
    ]
}

fn detected_captcha_non_none() -> impl Strategy<Value = DetectedCaptcha> {
    prop_oneof![
        Just(DetectedCaptcha::Turnstile),
        Just(DetectedCaptcha::RecaptchaV2),
        Just(DetectedCaptcha::RecaptchaV3),
        Just(DetectedCaptcha::HCaptcha),
        Just(DetectedCaptcha::ImageCaptcha),
        Just(DetectedCaptcha::AudioCaptcha),
        Just(DetectedCaptcha::PowCaptcha),
        Just(DetectedCaptcha::SliderCaptcha),
        Just(DetectedCaptcha::CanvasCaptcha),
        Just(DetectedCaptcha::ShadowDomCaptcha),
        Just(DetectedCaptcha::MultiStepCaptcha),
    ]
}