captchaforge 0.2.1

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 --test-threads=1

use captchaforge::detect::{detect, is_captcha, DetectedCaptcha};
use captchaforge::solver::{BehavioralCaptchaSolver, CaptchaSolver, CaptchaSolverChain, SolveConfig};
use chromiumoxide::browser::{Browser, BrowserConfig};
use futures_util::stream::StreamExt;
use std::net::SocketAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;

/// Serves a single HTML response then shuts down.
async fn serve_once(html: String) -> SocketAddr {
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move {
        if let Ok((mut stream, _)) = listener.accept().await {
            let mut buf = [0u8; 1024];
            let _ = stream.read(&mut buf).await;
            let response = format!(
                "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
                html.len(),
                html
            );
            let _ = stream.write_all(response.as_bytes()).await;
        }
    });
    addr
}

/// 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);
}

// ─── 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]
#[ignore = "requires Chrome binary + network"]
async fn e2e_turnstile_behavioral_solve() {
    let mut browser = 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 = 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 = 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 = 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();
}