captchaforge 0.2.38

[DO NOT USE — UNDER ACTIVE DEVELOPMENT, NOT PRODUCTION-READY] Captcha solver scaffolding for Firefox + BiDi-driven browsers. The architecture is in place (vendor solvers, retry-loop iframe walking, VLM provider abstraction, real-WAF bench harness) but the live-vendor success rate is still 0% — Cloudflare Turnstile / hCaptcha / reCAPTCHA detect us at a TLS / BiDi fingerprint layer that no flag-based stealth has cleared. Watch the repo; do not depend on this for any real workload.
Documentation
//! End-to-end vision backend tests with real browser instances.
//!
//! These tests verify the complete pipeline:
//!   Browser → Screenshot → ONNX inference → BiDi interaction → Token harvest
//!
//! Run with: cargo test --features vision --test vision_e2e

use captchaforge::solver::{CaptchaSolver, CrnnTextSolver, YoloGridSolver};
#[cfg(feature = "vision")]
use captchaforge::solver::CaptchaSolverChain;
#[cfg(feature = "vision")]
use captchaforge::{detect, vision};

#[cfg(feature = "vision")]
mod common;

// ---------------------------------------------------------------------------
// YOLO Grid Solver — Mock Image Grid Challenge
// ---------------------------------------------------------------------------

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

    // Inject a mock image grid challenge that looks like reCAPTCHA.
    let html = r#"
<!DOCTYPE html>
<html>
<head><title>Challenge</title></head>
<body>
    <div class="rc-imageselect-desc">Select all images with buses</div>
    <table class="rc-imageselect-table">
        <tr>
            <td class="rc-imageselect-tile" data-index="0">Tile 0</td>
            <td class="rc-imageselect-tile" data-index="1">Tile 1</td>
            <td class="rc-imageselect-tile" data-index="2">Tile 2</td>
        </tr>
        <tr>
            <td class="rc-imageselect-tile" data-index="3">Tile 3</td>
            <td class="rc-imageselect-tile" data-index="4">Tile 4</td>
            <td class="rc-imageselect-tile" data-index="5">Tile 5</td>
        </tr>
        <tr>
            <td class="rc-imageselect-tile" data-index="6">Tile 6</td>
            <td class="rc-imageselect-tile" data-index="7">Tile 7</td>
            <td class="rc-imageselect-tile" data-index="8">Tile 8</td>
        </tr>
    </table>
    <button id="recaptcha-verify-button">Verify</button>
    <input type="hidden" id="g-recaptcha-response" value="">
</body>
</html>
"#;

    let _: String = page
        .evaluate(format!("document.documentElement.innerHTML = `{}`; 'done'", html))
        .await
        .unwrap()
        .into_value()
        .unwrap();

    // Populate the token after a short delay to simulate challenge success.
    let _ = page
        .evaluate(r#"
            setTimeout(() => {
                document.getElementById('g-recaptcha-response').value = 'mock-token-yolo';
            }, 800);
        "#)
        .await;

    let info = detect::detect(&page).await.unwrap();
    let solver = YoloGridSolver::new();

    // The solver should run without panicking. Since the screenshot contains
    // no real COCO objects (just text tiles), YOLO returns no detections
    // and the solver reports failure — this is the expected honest outcome.
    let result = solver.solve(&page, &info).await.unwrap();

    // We verify the solver attempted the pipeline and returned a structured result.
    assert_eq!(result.method, captchaforge::solver::SolveMethod::VisionLLM);
    assert!(result.time_ms < 30_000, "solve should complete within 30s");

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

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

    // Inject a grid with a click counter so we can verify clicks happened.
    let html = r#"
<!DOCTYPE html>
<html>
<head><title>Challenge</title></head>
<body>
    <div class="rc-imageselect-desc">Select all images with traffic lights</div>
    <div id="click-log"></div>
    <table class="rc-imageselect-table">
        <tr>
            <td class="rc-imageselect-tile" id="tile-0">Tile 0</td>
            <td class="rc-imageselect-tile" id="tile-1">Tile 1</td>
            <td class="rc-imageselect-tile" id="tile-2">Tile 2</td>
        </tr>
    </table>
    <button id="recaptcha-verify-button">Verify</button>
    <script>
        window.tileClicks = {};
        document.querySelectorAll('.rc-imageselect-tile').forEach(t => {
            t.addEventListener('click', (e) => {
                window.tileClicks[t.id] = (window.tileClicks[t.id] || 0) + 1;
            });
        });
    </script>
</body>
</html>
"#;

    let _: String = page
        .evaluate(format!("document.open(); document.write(`{}`); document.close(); 'done'", html))
        .await
        .unwrap()
        .into_value()
        .unwrap();

    // Manually dispatch a click on tile-0 via JS to verify the event listener works.
    let clicked: bool = page
        .evaluate(r#"document.getElementById('tile-0').click(); true"#)
        .await
        .unwrap()
        .into_value()
        .unwrap();
    assert!(clicked);

    let clicks: serde_json::Value = page
        .evaluate(r#"window.tileClicks"#)
        .await
        .unwrap()
        .into_value()
        .unwrap();
    assert!(clicks.get("tile-0").is_some(), "click counter should record the click");

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

// ---------------------------------------------------------------------------
// CRNN Text Solver — Mock Text CAPTCHA Challenge
// ---------------------------------------------------------------------------

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

    let html = r#"
<!DOCTYPE html>
<html>
<head><title>Text Challenge</title></head>
<body>
    <img id="captcha-img" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==">
    <input type="text" id="captcha-answer" placeholder="Enter text">
    <button type="submit">Submit</button>
    <input type="hidden" id="g-recaptcha-response" value="">
</body>
</html>
"#;

    let _: String = page
        .evaluate(format!("document.open(); document.write(`{}`); document.close(); 'done'", html))
        .await
        .unwrap()
        .into_value()
        .unwrap();

    let info = detect::detect(&page).await.unwrap();
    let solver = CrnnTextSolver::new();

    // CRNN model may not be trained yet — if unavailable, skip gracefully.
    match solver.solve(&page, &info).await {
        Ok(result) => {
            assert_eq!(result.method, captchaforge::solver::SolveMethod::VisionLLM);
        }
        Err(e) => {
            let msg = format!("{e}");
            if msg.contains("CRNN model unavailable") {
                println!("CRNN model not available — skipping test (run scripts/train_crnn.py)");
            } else {
                panic!("unexpected error: {e}");
            }
        }
    }

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

// ---------------------------------------------------------------------------
// Solver Chain — Vision Solvers in Default Chain
// ---------------------------------------------------------------------------

#[tokio::test]
#[cfg(feature = "vision")]
async fn default_chain_contains_vision_solvers() {
    let chain = CaptchaSolverChain::default_chain();
    let names = chain.solver_names();
    assert!(names.contains(&"YoloGridSolver"), "chain must contain YoloGridSolver");
    assert!(names.contains(&"CrnnTextSolver"), "chain must contain CrnnTextSolver");
}

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

    let html = r#"
<!DOCTYPE html>
<html>
<head><title>reCAPTCHA</title></head>
<body>
    <div class="rc-imageselect-desc">Select all images with cars</div>
    <div class="rc-imageselect-tile">A</div>
    <div class="rc-imageselect-tile">B</div>
    <div class="rc-imageselect-tile">C</div>
    <button id="recaptcha-verify-button">Verify</button>
</body>
</html>
"#;

    let _: String = page
        .evaluate(format!("document.open(); document.write(`{}`); document.close(); 'done'", html))
        .await
        .unwrap()
        .into_value()
        .unwrap();

    let info = detect::detect(&page).await.unwrap();
    let chain = CaptchaSolverChain::default_chain();

    // Chain should run without panic even when YOLO finds no real objects.
    let result = chain.solve(&page, &info).await;
    assert!(result.time_ms < 60_000, "chain should complete within 60s");

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

// ---------------------------------------------------------------------------
// Model Hub — Download and Cache
// ---------------------------------------------------------------------------

#[tokio::test]
#[cfg(feature = "vision")]
async fn model_hub_caches_yolov8n_after_first_download() {
    let hub = vision::ModelHub::new();

    // First call may download.
    let path1 = hub.resolve(vision::ModelId::YoloV8n).await.unwrap();
    assert!(path1.exists());

    // Second call should be instant from cache.
    let path2 = hub.resolve(vision::ModelId::YoloV8n).await.unwrap();
    assert_eq!(path1, path2);
}

#[tokio::test]
#[cfg(feature = "vision")]
async fn model_hub_reports_missing_models() {
    // Use a temporary empty cache so the model is guaranteed missing.
    let tmp_dir = tempfile::tempdir().unwrap();
    let hub = vision::ModelHub::with_root(tmp_dir.path());
    // CRNN model has no download URL — should fail with a descriptive error.
    let result = hub.resolve(vision::ModelId::CrnnText).await;
    assert!(result.is_err(), "CrnnText should fail when model is not present");
}

// ---------------------------------------------------------------------------
// Inference Benchmarks (criterion-style sanity checks)
// ---------------------------------------------------------------------------

#[test]
#[cfg(feature = "vision")]
fn yolo_inference_under_500ms_on_cpu() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    let hub = vision::ModelHub::new();
    let path = rt.block_on(async { hub.resolve(vision::ModelId::YoloV8n).await }).unwrap();

    let mut detector = vision::YoloDetector::load(&path).unwrap();

    // Create a 640x480 test image.
    let img = image::DynamicImage::new_rgb8(640, 480);

    let t0 = std::time::Instant::now();
    let detections = detector.detect(&img).unwrap();
    let elapsed = t0.elapsed().as_millis() as u64;

    println!("YOLO inference: {} ms, detections: {}", elapsed, detections.len());
    assert!(elapsed < 500, "YOLO inference should complete within 500ms on CPU, got {}ms", elapsed);
}

#[test]
#[cfg(feature = "vision")]
fn yolo_inference_under_100ms_on_cached_model() {
    // This test verifies that repeated inference is fast (model already loaded).
    let rt = tokio::runtime::Runtime::new().unwrap();
    let hub = vision::ModelHub::new();
    let path = rt.block_on(async { hub.resolve(vision::ModelId::YoloV8n).await }).unwrap();

    let mut detector = vision::YoloDetector::load(&path).unwrap();
    let img = image::DynamicImage::new_rgb8(640, 480);

    // Warm-up run.
    let _ = detector.detect(&img).unwrap();

    // Timed run.
    let t0 = std::time::Instant::now();
    let _ = detector.detect(&img).unwrap();
    let elapsed = t0.elapsed().as_millis() as u64;

    println!("YOLO warm inference: {} ms", elapsed);
    // On GPU this should be <20ms; on CPU <100ms is a reasonable ceiling.
    assert!(elapsed < 200, "warm YOLO inference should complete within 200ms, got {}ms", elapsed);
}

// ---------------------------------------------------------------------------
// Feature-gate behaviour
// ---------------------------------------------------------------------------

#[test]
#[cfg(not(feature = "vision"))]
fn vision_solvers_are_inert_without_feature() {
    let yolo = YoloGridSolver::new();
    assert!(!yolo.supports(&captchaforge::captcha_detect::DetectedCaptcha::RecaptchaV2));

    let crnn = CrnnTextSolver::new();
    assert!(!crnn.supports(&captchaforge::captcha_detect::DetectedCaptcha::ImageCaptcha));
}