captchaforge 0.2.40

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
//! Shared utilities for integration tests (Firefox / rustenium).

use std::net::SocketAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;

use captchaforge::browser::{launch_firefox, FoxBrowserConfig, Page};
use std::sync::LazyLock;
use tokio::sync::Semaphore;

/// Limit concurrent Firefox launches to avoid geckodriver port exhaustion.
static BROWSER_SEM: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(4));

pub struct TestBrowser {
    page: Page,
}

impl std::ops::Deref for TestBrowser {
    type Target = Page;

    fn deref(&self) -> &Self::Target {
        &self.page
    }
}

impl TestBrowser {
    /// Navigate the underlying page to `url` and return a reference to it.
    pub async fn new_page(&self, url: &str) -> anyhow::Result<&Page> {
        self.page.goto(url).await?;
        Ok(&self.page)
    }

    pub async fn close(&self) -> anyhow::Result<()> {
        self.page.close().await
    }
}

/// Launch a headless Firefox instance for tests.
pub async fn launch_browser() -> TestBrowser {
    use std::sync::atomic::{AtomicUsize, Ordering};
    static COUNTER: AtomicUsize = AtomicUsize::new(0);

    let _permit = BROWSER_SEM.acquire().await.expect("semaphore never closed");

    let firefox_path = which::which("firefox")
        .or_else(|_| which::which("/snap/bin/firefox"))
        .unwrap_or_else(|_| std::path::PathBuf::from("firefox"));

    let user_data_dir = std::env::temp_dir().join(format!(
        "captchaforge-test-{}-{}-{}",
        std::process::id(),
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos(),
        COUNTER.fetch_add(1, Ordering::SeqCst)
    ));

    let config = FoxBrowserConfig {
        executable_path: Some(firefox_path.to_string_lossy().to_string()),
        profile_dir: Some(user_data_dir.to_string_lossy().to_string()),
        headless: true,
        viewport_width: 1280,
        viewport_height: 720,
        user_agent: None,
        ..Default::default()
    };

    let page = launch_firefox(config)
        .await
        .expect("failed to launch Firefox");
    TestBrowser { page }
}

/// Serve HTML responses then shut down.
#[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
}