use captchaforge::detect::{detect, is_captcha, DetectedCaptcha};
use captchaforge::solver::{
BehavioralCaptchaSolver, CaptchaSolver, CaptchaSolverChain, SolveConfig,
};
mod common;
#[tokio::test]
async fn detect_turnstile_on_mock_page() {
let browser = common::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]
async fn detect_recaptcha_v2_on_mock_page() {
let browser = common::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]
async fn detect_none_on_plain_page() {
let browser = common::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]
async fn chain_falls_through_on_unknown_captcha() {
let browser = common::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]
async fn detect_challenge_page_title() {
let browser = common::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]
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]
async fn e2e_turnstile_behavioral_solve() {
let browser = common::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 = common::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.is_empty(),
"solution must be non-empty on success: {result:?}"
);
assert!(
result.solution.len() >= 8,
"solution too short to be a real or labelled outcome: {:?}",
result.solution
);
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 browser = common::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 = common::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();
}
#[tokio::test]
async fn behavioral_solver_returns_real_harvested_token_not_label() {
let browser = common::launch_browser().await;
let page = browser.new_page("about:blank").await.unwrap();
let synthetic_token = "0.AAAA.synthetic-real-looking-cf-turnstile-token.BBBB";
let js = format!(
r#"
document.body.innerHTML = '<div class="cf-turnstile" data-sitekey="0xTEST"></div>' +
'<input type="hidden" name="cf-turnstile-response" id="cf-turnstile-response" value="{synthetic_token}">';
"true"
"#
);
let _: String = page
.evaluate(js.as_str())
.await
.unwrap()
.into_value()
.unwrap();
let info = detect(&page).await.unwrap();
assert!(is_captcha(&info));
assert_eq!(info.kind, DetectedCaptcha::Turnstile);
let solver = BehavioralCaptchaSolver::new();
let result = solver.solve(&page, &info).await.unwrap();
assert!(
result.success,
"pre-populated token should be harvested as success"
);
assert_eq!(
result.solution, synthetic_token,
"solution must be the harvested token value, not a label string. \
got: {:?}",
result.solution
);
assert!(
!result.solution.starts_with("behavioral:"),
"solution must NOT be a label string starting with 'behavioral:': {:?}",
result.solution
);
assert!(
!result.solution.contains(":pre-pass") && !result.solution.contains(":doom"),
"solution must NOT contain hardcoded path labels: {:?}",
result.solution
);
browser.close().await.unwrap();
}
#[tokio::test]
async fn behavioral_solver_returns_real_recaptcha_token_not_label() {
let browser = common::launch_browser().await;
let page = browser.new_page("about:blank").await.unwrap();
let synthetic_token = "03AGdBq25-synthetic-recaptcha-token-value-for-test";
let js = format!(
r#"
document.body.innerHTML = '<div class="g-recaptcha" data-sitekey="6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI"></div>' +
'<textarea name="g-recaptcha-response" id="g-recaptcha-response">{synthetic_token}</textarea>';
"true"
"#
);
let _: String = page
.evaluate(js.as_str())
.await
.unwrap()
.into_value()
.unwrap();
let info = detect(&page).await.unwrap();
assert!(is_captcha(&info));
let solver = BehavioralCaptchaSolver::new();
let result = solver.solve(&page, &info).await.unwrap();
assert!(result.success);
assert_eq!(result.solution, synthetic_token);
assert!(
!result.solution.starts_with("recaptcha_v"),
"solution must NOT be a label string: {:?}",
result.solution
);
browser.close().await.unwrap();
}