captchaforge 0.2.39

Captcha detection and solving for Firefox and BiDi-driven browsers. Detection, vendor solver scaffolding, trusted cross-origin click delivery into nested OOPIFs, and stealth personas are implemented and tested; broad live-vendor solve rates are not yet benchmarked.
Documentation
//! Unit tests for [`super`] (captcha detection across vendors + container resolution).

use super::*;

// ========================================================================
// Legacy DETECT_JS smoke tests
// ========================================================================

#[test]
fn legacy_detect_js_contains_turnstile() {
    assert!(DETECT_JS.contains("cf-turnstile"));
    assert!(DETECT_JS.contains("challenges.cloudflare.com"));
}

#[test]
fn legacy_detect_js_contains_recaptcha() {
    assert!(DETECT_JS.contains("g-recaptcha"));
    assert!(DETECT_JS.contains("google.com/recaptcha"));
}

#[test]
fn legacy_detect_js_contains_hcaptcha() {
    assert!(DETECT_JS.contains("h-captcha"));
    assert!(DETECT_JS.contains("hcaptcha.com"));
}

#[test]
fn legacy_detect_js_contains_challenge_page() {
    assert!(DETECT_JS.contains("Just a moment"));
    assert!(DETECT_JS.contains("challenge-running"));
}

#[test]
fn legacy_detect_js_contains_image_captcha() {
    assert!(DETECT_JS.contains("img[src*=\"captcha\"]"));
    assert!(DETECT_JS.contains("'image'"));
}

#[test]
fn legacy_detect_js_contains_audio_captcha() {
    assert!(DETECT_JS.contains("audio[src*=\"captcha\"]"));
    assert!(DETECT_JS.contains("'audio'"));
}

#[test]
fn legacy_detect_js_recaptcha_prefers_g_recaptcha_class() {
    assert!(DETECT_JS.contains("document.querySelector('.g-recaptcha')"));
}

#[test]
fn legacy_detect_js_recaptcha_fallback_excludes_other_providers() {
    assert!(DETECT_JS.contains("!fallback.hasAttribute('data-hcaptcha-sitekey')"));
    assert!(DETECT_JS.contains("!fallback.hasAttribute('data-turnstile-sitekey')"));
}

#[test]
fn legacy_detect_js_hcaptcha_fallback_excludes_other_providers() {
    assert!(DETECT_JS.contains("!fallback.hasAttribute('data-turnstile-sitekey')"));
    assert!(DETECT_JS.contains("!fallback.matches('.g-recaptcha')"));
}

// ========================================================================
// CaptchaInfo & is_captcha tests
// ========================================================================

#[test]
fn captcha_info_serializes() {
    let info = CaptchaInfo {
        kind: DetectedCaptcha::Turnstile,
        site_key: Some("abc123".into()),
        page_url: "https://example.com".into(),
        container_selector: Some(".cf-turnstile".into()),
    };
    let json = serde_json::to_string(&info).unwrap();
    assert!(json.contains("\"kind\":\"turnstile\""));
    assert!(json.contains("\"site_key\":\"abc123\""));
}

#[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 is_captcha_true_for_every_non_none_variant() {
    for variant in [
        DetectedCaptcha::Turnstile,
        DetectedCaptcha::RecaptchaV2,
        DetectedCaptcha::RecaptchaV3,
        DetectedCaptcha::HCaptcha,
        DetectedCaptcha::ImageCaptcha,
        DetectedCaptcha::AudioCaptcha,
        DetectedCaptcha::PowCaptcha,
        DetectedCaptcha::SliderCaptcha,
        DetectedCaptcha::CanvasCaptcha,
        DetectedCaptcha::ShadowDomCaptcha,
        DetectedCaptcha::MultiStepCaptcha,
    ] {
        let info = CaptchaInfo {
            kind: variant.clone(),
            site_key: None,
            page_url: String::new(),
            container_selector: None,
        };
        assert!(is_captcha(&info), "variant {variant:?} should be a captcha");
    }
}

// ========================================================================
// solver_kind_str tests
// ========================================================================

#[test]
fn solver_kind_str_maps_all_variants() {
    assert_eq!(
        solver_kind_str(&DetectedCaptcha::Turnstile),
        Some("turnstile")
    );
    assert_eq!(
        solver_kind_str(&DetectedCaptcha::RecaptchaV2),
        Some("recaptcha_v2")
    );
    assert_eq!(
        solver_kind_str(&DetectedCaptcha::RecaptchaV3),
        Some("recaptcha_v3")
    );
    assert_eq!(
        solver_kind_str(&DetectedCaptcha::HCaptcha),
        Some("hcaptcha")
    );
    assert_eq!(
        solver_kind_str(&DetectedCaptcha::ImageCaptcha),
        Some("image_captcha")
    );
    assert_eq!(
        solver_kind_str(&DetectedCaptcha::AudioCaptcha),
        Some("audio_captcha")
    );
    assert_eq!(
        solver_kind_str(&DetectedCaptcha::PowCaptcha),
        Some("pow_captcha")
    );
    assert_eq!(
        solver_kind_str(&DetectedCaptcha::SliderCaptcha),
        Some("slider_captcha")
    );
    assert_eq!(
        solver_kind_str(&DetectedCaptcha::CanvasCaptcha),
        Some("canvas_captcha")
    );
    assert_eq!(
        solver_kind_str(&DetectedCaptcha::ShadowDomCaptcha),
        Some("shadow_dom_captcha")
    );
    assert_eq!(
        solver_kind_str(&DetectedCaptcha::MultiStepCaptcha),
        Some("multi_step_captcha")
    );
    assert_eq!(solver_kind_str(&DetectedCaptcha::None), None);
}

// ========================================================================
// Serde roundtrip tests
// ========================================================================

#[test]
fn detected_captcha_serde_roundtrip_all_variants() {
    for variant in [
        DetectedCaptcha::Turnstile,
        DetectedCaptcha::RecaptchaV2,
        DetectedCaptcha::RecaptchaV3,
        DetectedCaptcha::HCaptcha,
        DetectedCaptcha::ImageCaptcha,
        DetectedCaptcha::AudioCaptcha,
        DetectedCaptcha::PowCaptcha,
        DetectedCaptcha::SliderCaptcha,
        DetectedCaptcha::CanvasCaptcha,
        DetectedCaptcha::ShadowDomCaptcha,
        DetectedCaptcha::MultiStepCaptcha,
        DetectedCaptcha::None,
    ] {
        let json = serde_json::to_string(&variant).unwrap();
        let back: DetectedCaptcha = serde_json::from_str(&json).unwrap();
        assert_eq!(variant, back, "roundtrip failed for {:?}", variant);
    }
}

#[test]
fn captcha_info_serde_roundtrip() {
    let info = CaptchaInfo {
        kind: DetectedCaptcha::Turnstile,
        site_key: Some("key".into()),
        page_url: "https://example.com".into(),
        container_selector: Some(".cf".into()),
    };
    let json = serde_json::to_string(&info).unwrap();
    let back: CaptchaInfo = serde_json::from_str(&json).unwrap();
    assert_eq!(info.kind, back.kind);
    assert_eq!(info.site_key, back.site_key);
    assert_eq!(info.page_url, back.page_url);
    assert_eq!(info.container_selector, back.container_selector);
}

#[test]
fn captcha_info_serde_with_null_site_key() {
    let info = CaptchaInfo {
        kind: DetectedCaptcha::None,
        site_key: None,
        page_url: "https://example.com".into(),
        container_selector: None,
    };
    let json = serde_json::to_string(&info).unwrap();
    assert!(json.contains("null"));
    let back: CaptchaInfo = serde_json::from_str(&json).unwrap();
    assert!(back.site_key.is_none());
}

// ========================================================================
// Detector trait sanity (registry contract)
// ========================================================================

#[test]
fn challenge_page_detector_name_and_priority() {
    let d = ChallengePageDetector;
    assert_eq!(d.name(), "challenge_page");
    assert_eq!(d.priority(), 5);
}

#[test]
fn turnstile_detector_name_and_priority() {
    let d = TurnstileDetector;
    assert_eq!(d.name(), "turnstile");
    assert_eq!(d.priority(), 10);
}

#[test]
fn recaptcha_detector_name_and_priority() {
    let d = RecaptchaDetector;
    assert_eq!(d.name(), "recaptcha");
    assert_eq!(d.priority(), 20);
}

#[test]
fn hcaptcha_detector_name_and_priority() {
    let d = HCaptchaDetector;
    assert_eq!(d.name(), "hcaptcha");
    assert_eq!(d.priority(), 30);
}

#[test]
fn image_captcha_detector_name_and_priority() {
    let d = ImageCaptchaDetector;
    assert_eq!(d.name(), "image_captcha");
    assert_eq!(d.priority(), 40);
}

#[test]
fn audio_captcha_detector_name_and_priority() {
    let d = AudioCaptchaDetector;
    assert_eq!(d.name(), "audio_captcha");
    assert_eq!(d.priority(), 50);
}

#[test]
fn pow_captcha_detector_name_and_priority() {
    let d = PowCaptchaDetector;
    assert_eq!(d.name(), "pow_captcha");
    assert_eq!(d.priority(), 60);
}

#[test]
fn canvas_captcha_detector_name_and_priority() {
    let d = CanvasCaptchaDetector;
    assert_eq!(d.name(), "canvas_captcha");
    assert_eq!(d.priority(), 65);
}

#[test]
fn slider_captcha_detector_name_and_priority() {
    let d = SliderCaptchaDetector;
    assert_eq!(d.name(), "slider_captcha");
    // Slider must run BEFORE canvas (65) so a slider-puzzle widget
    // with a `canvas.captcha` puzzle image is routed to the slider
    // solver, not the VLM text-reader branch.
    assert_eq!(d.priority(), 60);
}

#[test]
fn multi_step_captcha_detector_name_and_priority() {
    let d = MultiStepCaptchaDetector;
    assert_eq!(d.name(), "multi_step_captcha");
    // Below math (80) so wizard-style multi-step captchas with a
    // math first step route to the orchestrator, not the
    // single-shot math solver.
    assert_eq!(d.priority(), 78);
}

#[test]
fn shadow_dom_detector_name_and_priority() {
    let d = ShadowDomDetector;
    assert_eq!(d.name(), "shadow_dom");
    assert_eq!(d.priority(), 90);
}

// ========================================================================
// Registry tests
// ========================================================================

#[test]
fn detector_registry_runs_in_priority_order() {
    let registry = DetectorRegistry::new();
    let priorities: Vec<i32> = registry.detectors.iter().map(|d| d.priority()).collect();
    let mut sorted = priorities.clone();
    sorted.sort_unstable();
    assert_eq!(priorities, sorted);
}

#[test]
fn detector_registry_contains_all_detectors() {
    let registry = DetectorRegistry::new();
    let names: Vec<&str> = registry.detectors.iter().map(|d| d.name()).collect();
    assert!(names.contains(&"challenge_page"));
    assert!(names.contains(&"turnstile"));
    assert!(names.contains(&"recaptcha"));
    assert!(names.contains(&"hcaptcha"));
    assert!(names.contains(&"image_captcha"));
    assert!(names.contains(&"audio_captcha"));
    assert!(names.contains(&"pow_captcha"));
    assert!(names.contains(&"slider_captcha"));
    assert!(names.contains(&"canvas_captcha"));
    assert!(names.contains(&"multi_step_captcha"));
    assert!(names.contains(&"shadow_dom"));
}

#[test]
fn parse_detection_result_returns_none_for_null() {
    assert!(parse_detection_result(serde_json::Value::Null).is_none());
}

#[test]
fn parse_detection_result_returns_none_for_unknown_kind() {
    let val = serde_json::json!({"kind": "unknown_xyz", "site_key": null, "container": null});
    assert!(parse_detection_result(val).is_none());
}

#[test]
fn parse_detection_result_parses_all_kinds() {
    let cases = [
        ("turnstile", DetectedCaptcha::Turnstile),
        ("recaptcha_v2", DetectedCaptcha::RecaptchaV2),
        ("recaptcha_v3", DetectedCaptcha::RecaptchaV3),
        ("hcaptcha", DetectedCaptcha::HCaptcha),
        ("image", DetectedCaptcha::ImageCaptcha),
        ("audio", DetectedCaptcha::AudioCaptcha),
        ("pow", DetectedCaptcha::PowCaptcha),
        ("slider", DetectedCaptcha::SliderCaptcha),
        ("canvas", DetectedCaptcha::CanvasCaptcha),
        ("shadow_dom", DetectedCaptcha::ShadowDomCaptcha),
        ("multi_step", DetectedCaptcha::MultiStepCaptcha),
    ];
    for (kind_str, expected) in cases {
        let val = serde_json::json!({
            "kind": kind_str,
            "site_key": "test_key",
            "container": ".test"
        });
        let info = parse_detection_result(val).unwrap();
        assert_eq!(info.kind, expected, "kind mismatch for {}", kind_str);
        assert_eq!(info.site_key, Some("test_key".to_string()));
        assert_eq!(info.container_selector, Some(".test".to_string()));
    }
}