captchaforge 0.2.5

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! Integration tests against a real headless browser.
//!
//! These tests spin up a local HTTP server with mock CAPTCHA HTML pages,
//! launch chromiumoxide, and verify that `detect` and `solve` behave
//! correctly against real DOM.

use captchaforge::detect::{detect, is_captcha, DetectedCaptcha};
use captchaforge::solver::{
    BehavioralCaptchaSolver, CaptchaSolver, CaptchaSolverChain, SolveConfig,
};

mod common;

#[tokio::test]
async fn detect_turnstile_on_mock_page() {
    let mut browser = common::launch_browser().await;
    let page = browser.new_page("about:blank").await.unwrap();

    // Inject a mock Turnstile widget into the page.
    let js = r#"
        document.body.innerHTML = '<div class="cf-turnstile" data-sitekey="0xTEST"></div>' +
            '<script src="https://challenges.cloudflare.com/turnstile/v0/api.js"></script>';
        "true"
    "#;
    let _: String = page.evaluate(js).await.unwrap().into_value().unwrap();

    let info = detect(&page).await.unwrap();
    assert!(is_captcha(&info));
    assert_eq!(info.kind, DetectedCaptcha::Turnstile);
    assert_eq!(info.site_key, Some("0xTEST".to_string()));

    browser.close().await.unwrap();
}

#[tokio::test]
async fn detect_recaptcha_v2_on_mock_page() {
    let mut browser = common::launch_browser().await;
    let page = browser.new_page("about:blank").await.unwrap();

    let js = r#"
        document.body.innerHTML = '<div class="g-recaptcha" data-sitekey="test-key-123"></div>' +
            '<script src="https://www.google.com/recaptcha/api.js"></script>';
        "true"
    "#;
    let _: String = page.evaluate(js).await.unwrap().into_value().unwrap();

    let info = detect(&page).await.unwrap();
    assert!(is_captcha(&info));
    assert_eq!(info.kind, DetectedCaptcha::RecaptchaV2);
    assert_eq!(info.site_key, Some("test-key-123".to_string()));

    browser.close().await.unwrap();
}

#[tokio::test]
async fn detect_none_on_plain_page() {
    let mut browser = common::launch_browser().await;
    let page = browser.new_page("about:blank").await.unwrap();

    let info = detect(&page).await.unwrap();
    assert!(!is_captcha(&info));
    assert_eq!(info.kind, DetectedCaptcha::None);

    browser.close().await.unwrap();
}

#[tokio::test]
async fn chain_falls_through_on_unknown_captcha() {
    let mut browser = common::launch_browser().await;
    let page = browser.new_page("about:blank").await.unwrap();

    // Inject a plain page with no captcha.
    let _: String = page
        .evaluate("document.body.innerHTML = '<h1>Hello</h1>'; 'done'")
        .await
        .unwrap()
        .into_value()
        .unwrap();

    let info = detect(&page).await.unwrap();
    // Even though there's no captcha, run the chain to verify it doesn't panic.
    let chain = CaptchaSolverChain::default_chain();
    let result = chain.solve(&page, &info).await;
    assert!(!result.success);

    browser.close().await.unwrap();
}

#[tokio::test]
async fn detect_challenge_page_title() {
    let mut browser = common::launch_browser().await;
    let page = browser.new_page("about:blank").await.unwrap();

    let js = r#"
        document.title = 'Just a moment...';
        document.body.innerHTML = '<form id="challenge-form"></form>';
        "true"
    "#;
    let _: String = page.evaluate(js).await.unwrap().into_value().unwrap();

    let info = detect(&page).await.unwrap();
    assert!(is_captcha(&info));
    assert_eq!(info.kind, DetectedCaptcha::Turnstile);

    browser.close().await.unwrap();
}

#[tokio::test]
async fn solve_config_custom_timeouts_work() {
    let config = SolveConfig {
        checkbox_poll_interval_ms: 100,
        checkbox_max_attempts: 3,
        token_poll_interval_ms: 100,
        token_max_attempts: 3,
        audio_button_delay_ms: 100,
        audio_submit_delay_ms: 100,
        vlm_http_timeout_ms: 1000,
        client_http_timeout_ms: 1000,
    };

    // Just verify the config struct builds and fields are accessible.
    assert_eq!(config.checkbox_max_attempts, 3);
    assert_eq!(config.vlm_http_timeout_ms, 1000);
}

// ─── Real end-to-end Turnstile solve test ───────────────────────────────────

/// Cloudflare Turnstile public "always pass" test site key.
/// https://developers.cloudflare.com/turnstile/troubleshooting/testing/
const TURNSTILE_TEST_KEY: &str = "1x00000000000000000000AA";

#[tokio::test]
async fn e2e_turnstile_behavioral_solve() {
    let mut browser = common::launch_browser().await;

    // Serve a real Turnstile widget page on localhost.
    let html = format!(
        r#"<!DOCTYPE html>
<html>
<head><title>Turnstile Test</title></head>
<body>
  <h1>Test Page</h1>
  <div class="cf-turnstile" data-sitekey="{}"></div>
  <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</body>
</html>"#,
        TURNSTILE_TEST_KEY
    );
    let addr = common::serve_once(html).await;
    let url = format!("http://{}", addr);

    let page = browser.new_page(&url).await.unwrap();

    // Give the Turnstile script time to load and inject the iframe.
    tokio::time::sleep(std::time::Duration::from_secs(2)).await;

    // Detection should find Turnstile.
    let info = detect(&page).await.unwrap();
    assert!(is_captcha(&info), "Turnstile should be detected");
    assert_eq!(info.kind, DetectedCaptcha::Turnstile);

    // Attempt behavioural solve.
    let solver = BehavioralCaptchaSolver::new();
    let result = solver.solve(&page, &info).await.unwrap();

    // The test key always passes, so we expect success.
    assert!(
        result.success,
        "Turnstile behavioural solve should succeed with test key: got {:?}",
        result
    );
    assert!(
        result.solution.contains("turnstile"),
        "solution should reference turnstile"
    );

    browser.close().await.unwrap();
}

/// If the user sets `TURNSTILE_SITE_KEY` in the environment, run a solve
/// against their real key.  This is gated behind both `#[ignore]` and a
/// runtime check so it never runs in CI.
#[tokio::test]
#[ignore = "requires Chrome binary + network + real Turnstile key"]
async fn e2e_turnstile_real_key_solve() {
    let site_key = match std::env::var("TURNSTILE_SITE_KEY") {
        Ok(k) if !k.is_empty() => k,
        _ => {
            eprintln!("Skipping: TURNSTILE_SITE_KEY not set");
            return;
        }
    };

    let mut browser = common::launch_browser().await;

    let html = format!(
        r#"<!DOCTYPE html>
<html>
<head><title>Turnstile Real Key</title></head>
<body>
  <h1>Real Key Test</h1>
  <div class="cf-turnstile" data-sitekey="{}"></div>
  <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</body>
</html>"#,
        site_key
    );
    let addr = common::serve_once(html).await;
    let url = format!("http://{}", addr);

    let page = browser.new_page(&url).await.unwrap();
    tokio::time::sleep(std::time::Duration::from_secs(2)).await;

    let info = detect(&page).await.unwrap();
    assert!(is_captcha(&info));

    let solver = BehavioralCaptchaSolver::new();
    let result = solver.solve(&page, &info).await.unwrap();

    // With a real key we don't assert success — Turnstile may still block
    // headless browsers — but we do assert the solver returned gracefully.
    tracing::info!(success = result.success, solution = %result.solution, "real key solve finished");

    browser.close().await.unwrap();
}