captchaforge 0.2.26

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! Shared utilities for integration tests.

use chromiumoxide::browser::{Browser, BrowserConfig};
use futures_util::stream::StreamExt;
use std::net::SocketAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;

/// Cross-process lock to serialize browser launches — Chrome's process singleton
/// prevents multiple instances from sharing the same default profile area.
/// Integration tests run in separate binaries, so a std::sync::Mutex is not
/// sufficient; we use a filesystem lock instead.
fn acquire_browser_launch_lock() {
    let lock_path = std::path::Path::new("/tmp/captchaforge-browser.lock");
    let max_attempts = 600; // 60 seconds
    for _ in 0..max_attempts {
        match std::fs::OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(lock_path)
        {
            Ok(file) => {
                // Hold the lock file open until the guard is dropped.
                let _ = file;
                return;
            }
            Err(_) => {
                std::thread::sleep(std::time::Duration::from_millis(100));
            }
        }
    }
    panic!("Timed out waiting for browser launch lock");
}

fn release_browser_launch_lock() {
    let _ = std::fs::remove_file("/tmp/captchaforge-browser.lock");
}

/// Launch a headless browser for tests.
pub async fn launch_browser() -> Browser {
    acquire_browser_launch_lock();
    // Clean up any stale chromiumoxide-runner singleton lock.
    let _ = std::fs::remove_dir_all("/tmp/chromiumoxide-runner");
    let temp_dir = std::env::temp_dir().join(format!(
        "captchaforge-test-{}-{}",
        std::process::id(),
        rand::random::<u32>()
    ));
    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 result = Browser::launch(config).await;
    release_browser_launch_lock();
    let (browser, mut handler) = result.unwrap();
    tokio::spawn(async move { while let Some(_evt) = handler.next().await {} });
    browser
}

/// Serve HTML responses then shut down.
/// Keeps running until at least one valid page request is served,
/// with a long overall timeout to accommodate serialized browser launches.
#[allow(dead_code)]
pub async fn serve_once(html: String) -> SocketAddr {
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    let html_clone = html.clone();
    tokio::spawn(async move {
        let mut served = 0;
        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30);
        while served < 2 && tokio::time::Instant::now() < deadline {
            let timeout =
                tokio::time::timeout(std::time::Duration::from_millis(500), listener.accept());
            match timeout.await {
                Ok(Ok((mut stream, _))) => {
                    let mut buf = [0u8; 1024];
                    let _ = stream.read(&mut buf).await;
                    let req = String::from_utf8_lossy(&buf);
                    let response = if req.starts_with("GET / ") || req.starts_with("GET / HTTP") {
                        served += 1;
                        format!(
                            "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
                            html_clone.len(),
                            html_clone
                        )
                    } else {
                        "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
                            .to_string()
                    };
                    let _ = stream.write_all(response.as_bytes()).await;
                }
                _ => continue,
            }
        }
    });
    addr
}