captchaforge 0.2.39

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
use super::*;

#[test]
fn vlm_solver_defaults_when_env_returns_none() {
    let s = VlmCaptchaSolver::new_with_env(|_| None);
    assert_eq!(s.endpoint, "http://localhost:11434");
    assert_eq!(s.model, "qwen3-vl:8b-thinking");
}

#[test]
fn vlm_solver_env_overrides_defaults() {
    let s = VlmCaptchaSolver::new_with_env(|k| match k {
        "CAPTCHAFORGE_VLM_ENDPOINT" => Some("http://env-host:11434".into()),
        "CAPTCHAFORGE_VLM_MODEL" => Some("env-model:latest".into()),
        _ => None,
    });
    assert_eq!(s.endpoint, "http://env-host:11434");
    assert_eq!(s.model, "env-model:latest");
}

#[test]
fn vlm_solver_builder_overrides_env() {
    let s = VlmCaptchaSolver::new_with_env(|k| match k {
        "CAPTCHAFORGE_VLM_ENDPOINT" => Some("http://env-host:11434".into()),
        "CAPTCHAFORGE_VLM_MODEL" => Some("env-model:latest".into()),
        _ => None,
    })
    .with_endpoint("http://builder:11434")
    .with_model("builder-model:7b");
    assert_eq!(s.endpoint, "http://builder:11434");
    assert_eq!(s.model, "builder-model:7b");
}

#[test]
fn vlm_solver_empty_env_falls_through_to_defaults() {
    let s = VlmCaptchaSolver::new_with_env(|_| Some(String::new()));
    assert_eq!(s.endpoint, "http://localhost:11434");
    assert_eq!(s.model, "qwen3-vl:8b-thinking");
}

#[test]
fn vlm_solver_env_constants_match_documented_names() {
    // The CLAUDE.md / README contract is the env-var names. Pin
    // them so a rename is a deliberate breaking change.
    assert_eq!(ENV_VLM_ENDPOINT, "CAPTCHAFORGE_VLM_ENDPOINT");
    assert_eq!(ENV_VLM_MODEL, "CAPTCHAFORGE_VLM_MODEL");
    assert_eq!(ENV_ANTHROPIC_KEY, "ANTHROPIC_API_KEY");
    assert_eq!(ENV_OPENAI_KEY, "OPENAI_API_KEY");
}

#[test]
fn vlm_solver_no_api_key_picks_ollama() {
    let s = VlmCaptchaSolver::new_with_env(|_| None);
    assert_eq!(s.provider, VlmProvider::Ollama);
    assert!(s.api_key.is_none());
    assert_eq!(s.endpoint, "http://localhost:11434");
    assert_eq!(s.model, "qwen3-vl:8b-thinking");
}

#[test]
fn vlm_solver_anthropic_api_key_picks_anthropic() {
    let s = VlmCaptchaSolver::new_with_env(|k| match k {
        "ANTHROPIC_API_KEY" => Some("sk-ant-test".into()),
        _ => None,
    });
    assert_eq!(s.provider, VlmProvider::Anthropic);
    assert_eq!(s.api_key.as_deref(), Some("sk-ant-test"));
    assert_eq!(s.endpoint, "https://api.anthropic.com");
    assert_eq!(s.model, "claude-haiku-4-5");
}

#[test]
fn vlm_solver_openai_api_key_picks_openai_when_no_anthropic() {
    let s = VlmCaptchaSolver::new_with_env(|k| match k {
        "OPENAI_API_KEY" => Some("sk-openai-test".into()),
        _ => None,
    });
    assert_eq!(s.provider, VlmProvider::OpenAI);
    assert_eq!(s.api_key.as_deref(), Some("sk-openai-test"));
    assert_eq!(s.endpoint, "https://api.openai.com");
    assert_eq!(s.model, "gpt-4o-mini");
}

#[test]
fn vlm_solver_anthropic_wins_over_openai_when_both_set() {
    // Anthropic is preferred (strongest visual reasoning of the three).
    // If users want OpenAI specifically, they unset ANTHROPIC_API_KEY.
    let s = VlmCaptchaSolver::new_with_env(|k| match k {
        "ANTHROPIC_API_KEY" => Some("sk-ant-test".into()),
        "OPENAI_API_KEY" => Some("sk-openai-test".into()),
        _ => None,
    });
    assert_eq!(s.provider, VlmProvider::Anthropic);
    assert_eq!(s.api_key.as_deref(), Some("sk-ant-test"));
}

#[test]
fn vlm_solver_with_provider_overrides_env_detection() {
    // Force Ollama even when Anthropic env key is present, useful
    // for integration tests that need a deterministic local back-end.
    let s = VlmCaptchaSolver::new_with_env(|k| match k {
        "ANTHROPIC_API_KEY" => Some("sk-ant-test".into()),
        _ => None,
    })
    .with_provider(VlmProvider::Ollama);
    assert_eq!(s.provider, VlmProvider::Ollama);
}

#[test]
fn vlm_solver_with_api_key_overrides_env_detection() {
    let s = VlmCaptchaSolver::new_with_env(|_| None).with_api_key("override-key");
    assert_eq!(s.api_key.as_deref(), Some("override-key"));
}

#[test]
fn vlm_solver_empty_anthropic_key_falls_through_to_ollama() {
    // Empty string treated same as unset (matches the existing
    // CAPTCHAFORGE_VLM_ENDPOINT empty-fallthrough contract).
    let s = VlmCaptchaSolver::new_with_env(|k| match k {
        "ANTHROPIC_API_KEY" => Some(String::new()),
        _ => None,
    });
    assert_eq!(s.provider, VlmProvider::Ollama);
}

#[test]
fn vlm_provider_default_endpoints_and_models_consistent() {
    // Catch a regression where the per-provider defaults drift
    // out of sync with the documented constants.
    assert_eq!(VlmProvider::Ollama.default_endpoint(), DEFAULT_OLLAMA_BASE);
    assert_eq!(VlmProvider::Ollama.default_model(), DEFAULT_OLLAMA_MODEL);
    assert_eq!(
        VlmProvider::Anthropic.default_endpoint(),
        DEFAULT_ANTHROPIC_BASE
    );
    assert_eq!(
        VlmProvider::Anthropic.default_model(),
        DEFAULT_ANTHROPIC_MODEL
    );
    assert_eq!(VlmProvider::OpenAI.default_endpoint(), DEFAULT_OPENAI_BASE);
    assert_eq!(VlmProvider::OpenAI.default_model(), DEFAULT_OPENAI_MODEL);
    assert_eq!(
        VlmProvider::LlamaCpp.default_endpoint(),
        DEFAULT_LLAMACPP_BASE
    );
    assert_eq!(
        VlmProvider::LlamaCpp.default_model(),
        DEFAULT_LLAMACPP_MODEL
    );
}

#[test]
fn vlm_solver_llamacpp_endpoint_picks_llamacpp() {
    let s = VlmCaptchaSolver::new_with_env(|k| match k {
        "LLAMACPP_VLM_ENDPOINT" => Some("http://gpu:8000".into()),
        _ => None,
    });
    assert_eq!(s.provider, VlmProvider::LlamaCpp);
    assert!(s.api_key.is_none());
    assert_eq!(s.endpoint, "http://gpu:8000");
    assert_eq!(s.model, "qwen3.6-35b-a3b");
}

#[test]
fn vlm_solver_llamacpp_env_constant_matches_documented_name() {
    assert_eq!(ENV_LLAMACPP_ENDPOINT, "LLAMACPP_VLM_ENDPOINT");
}

#[test]
fn vlm_solver_custom_endpoint() {
    let s = VlmCaptchaSolver::new().with_endpoint("http://ollama.internal:11434");
    assert_eq!(s.endpoint, "http://ollama.internal:11434");
}

#[test]
fn vlm_solver_custom_model() {
    let s = VlmCaptchaSolver::new().with_model("llava:13b");
    assert_eq!(s.model, "llava:13b");
}

#[test]
fn vlm_solver_chained_builders() {
    let s = VlmCaptchaSolver::new()
        .with_endpoint("http://gpu-box:11434")
        .with_model("qwen3-vl:8b-thinking");
    assert_eq!(s.endpoint, "http://gpu-box:11434");
    assert_eq!(s.model, "qwen3-vl:8b-thinking");
}

#[test]
fn grid_prompt_includes_task() {
    let prompt = VlmCaptchaSolver::grid_prompt("Select all traffic lights");
    assert!(prompt.contains("Select all traffic lights"));
    assert!(prompt.contains("selected"));
    assert!(prompt.contains("confidence"));
}

#[test]
fn text_captcha_prompt_is_valid() {
    let prompt = VlmCaptchaSolver::text_captcha_prompt();
    assert!(prompt.contains("text"));
    assert!(prompt.contains("confidence"));
}

#[test]
fn click_target_prompt_includes_target() {
    let prompt = VlmCaptchaSolver::click_target_prompt("submit button");
    assert!(prompt.contains("submit button"));
    assert!(prompt.contains('"'));
}

#[test]
fn extract_json_from_plain_text() {
    let s = r#"Some preamble {"key": "value"} trailing"#;
    assert_eq!(extract_json(s), Some(r#"{"key": "value"}"#));
}

#[test]
fn extract_json_from_markdown_fence() {
    let s = "```json\n{\"answer\": 42}\n```";
    assert_eq!(extract_json(s), Some("{\"answer\": 42}"));
}

#[test]
fn extract_json_no_json_returns_none() {
    assert_eq!(extract_json("no json here"), None);
}

#[test]
fn openai_body_disable_thinking_adds_chat_template_kwargs() {
    // The LlamaCpp path MUST send enable_thinking=false or a reasoning model
    // returns empty content and every solve fails.
    let body = openai_request_body("qwen3.6-35b-a3b", "QUJD", "solve", true);
    assert_eq!(
        body["chat_template_kwargs"]["enable_thinking"],
        serde_json::json!(false)
    );
    assert_eq!(body["model"], "qwen3.6-35b-a3b");
    // image is embedded as a data URL content part
    assert!(body["messages"][0]["content"][1]["image_url"]["url"]
        .as_str()
        .unwrap()
        .starts_with("data:image/png;base64,QUJD"));
}

#[test]
fn openai_body_without_disable_thinking_omits_kwargs() {
    // Hosted OpenAI rejects unknown fields → the toggle must be ABSENT.
    let body = openai_request_body("gpt-4o-mini", "QUJD", "solve", false);
    assert!(body.get("chat_template_kwargs").is_none());
}