#[cfg(feature = "vision")]
use captchaforge::solver::CaptchaSolverChain;
use captchaforge::solver::{CaptchaSolver, CrnnTextSolver, YoloGridSolver};
#[cfg(feature = "vision")]
use captchaforge::{detect, vision};
#[cfg(feature = "vision")]
mod common;
#[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();
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();
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();
let result = solver.solve(&page, &info).await.unwrap();
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();
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();
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();
}
#[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();
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();
}
#[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();
let result = chain.solve(&page, &info).await;
assert!(result.time_ms < 60_000, "chain should complete within 60s");
browser.close().await.unwrap();
}
#[tokio::test]
#[cfg(feature = "vision")]
async fn model_hub_caches_yolov8n_after_first_download() {
let hub = vision::ModelHub::new();
let path1 = hub.resolve(vision::ModelId::YoloV8n).await.unwrap();
assert!(path1.exists());
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() {
let tmp_dir = tempfile::tempdir().unwrap();
let hub = vision::ModelHub::with_root(tmp_dir.path());
let result = hub.resolve(vision::ModelId::CrnnText).await;
assert!(
result.is_err(),
"CrnnText should fail when model is not present"
);
}
#[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();
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() {
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);
let _ = detector.detect(&img).unwrap();
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);
assert!(
elapsed < 200,
"warm YOLO inference should complete within 200ms, got {}ms",
elapsed
);
}
#[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));
}