captchaforge 0.2.0

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.
//!
//! They are marked `#[ignore]` because they require a Chrome/Chromium
//! binary and are slower than the pure-Rust unit-test suite.
//!
//! Run with:
//!     cargo test --test integration -- --ignored

use captchaforge::detect::{detect, is_captcha, DetectedCaptcha};
use captchaforge::solver::{CaptchaSolverChain, SolveConfig};
use chromiumoxide::browser::{Browser, BrowserConfig};
use futures_util::stream::StreamExt;


/// Launch a headless browser for the tests.
/// Each call uses a unique temporary profile so multiple instances can run
/// in parallel without colliding on the singleton lock.
async fn launch_browser() -> Browser {
    let temp_dir = std::env::temp_dir().join(format!(
        "captchaforge-test-{}",
        std::process::id()
    ));
    let config = BrowserConfig::builder()
        .chrome_executable(
            std::env::var("CHROME_BIN")
                .ok()
                .or_else(|| which::which("google-chrome").ok().map(|p| p.to_string_lossy().to_string()))
                .or_else(|| which::which("chromium").ok().map(|p| p.to_string_lossy().to_string()))
                .expect("Chrome/Chromium not found. Set CHROME_BIN or install Chrome."),
        )
        .arg("--no-sandbox")
        .arg("--disable-setuid-sandbox")
        .arg("--disable-dev-shm-usage")
        .arg("--disable-gpu")
        .arg("--single-process")
        .arg("--no-zygote")
        .arg(format!("--user-data-dir={}", temp_dir.display()))
        .build()
        .unwrap();
    let (browser, mut handler) = Browser::launch(config).await.unwrap();
    // Spawn the handler task so CDP events are processed.
    tokio::spawn(async move {
        while let Some(_evt) = handler.next().await {}
    });
    browser
}

#[tokio::test]
#[ignore = "requires Chrome binary"]
async fn detect_turnstile_on_mock_page() {
    let mut browser = 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]
#[ignore = "requires Chrome binary"]
async fn detect_recaptcha_v2_on_mock_page() {
    let mut browser = 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]
#[ignore = "requires Chrome binary"]
async fn detect_none_on_plain_page() {
    let mut browser = 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]
#[ignore = "requires Chrome binary"]
async fn chain_falls_through_on_unknown_captcha() {
    let mut browser = 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]
#[ignore = "requires Chrome binary"]
async fn detect_challenge_page_title() {
    let mut browser = 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]
#[ignore = "requires Chrome binary"]
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);
}