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;
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
}
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();
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();
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();
let _: String = page
.evaluate("document.body.innerHTML = '<h1>Hello</h1>'; 'done'")
.await
.unwrap()
.into_value()
.unwrap();
let info = detect(&page).await.unwrap();
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,
};
assert_eq!(config.checkbox_max_attempts, 3);
assert_eq!(config.vlm_http_timeout_ms, 1000);
}
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;
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();
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let info = detect(&page).await.unwrap();
assert!(is_captcha(&info), "Turnstile should be detected");
assert_eq!(info.kind, DetectedCaptcha::Turnstile);
let solver = BehavioralCaptchaSolver::new();
let result = solver.solve(&page, &info).await.unwrap();
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();
}
#[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();
tracing::info!(success = result.success, solution = %result.solution, "real key solve finished");
browser.close().await.unwrap();
}