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
//! `MultiStepCaptchaSolver` must advance a wizard whose steps gate on
//! `event.isTrusted`: i.e. it must deliver REAL (trusted) BiDi clicks, not the
//! synthetic JS `el.click()` it used to dispatch from inside a `page.evaluate`.
//!
//! Regression context: the orchestrator previously did `btn.click()` / `tile.click()`
//! INSIDE the evaluate. Those arrive `event.isTrusted === false` and are rejected by
//! any challenge that scores input trust, the exact reason synthetic clicks never
//! solve a real captcha (pinned generally by `foxdriver::cross_origin_click`'s
//! negative case). The solver now returns the advance control's viewport centre and
//! delivers a TRUSTED `click_at` from Rust. This fixture trust-gates BOTH the math
//! step's Next button and the final Verify button, so it can ONLY be solved with
//! trusted clicks (a revert to JS clicks fails this test).
//!
//! Needs `firefox` on PATH (the shared `common::launch_browser`, stock headless 
//! BiDi `input.performActions` is trusted even headless, per cross_origin_click).

use captchaforge::detect::{CaptchaInfo, DetectedCaptcha};
use captchaforge::solver::{CaptchaSolver, MultiStepCaptchaSolver};

mod common;

/// Two-step wizard. Each advance handler refuses unless `event.isTrusted`:
///   step1: math "3 + 4" → fill input with 7 → Next (trusted) → step2.
///   step2: Verify (trusted) → title flips to "Solved" AND step2 deactivates
///          (the orchestrator's loop only checks the title once the active step
///          changes, so the final step must clear its own `active` class).
const WIZARD: &str = r#"<!doctype html><html><head><meta charset="utf-8"><title>wizard</title>
<style>.step{display:none}.step.active{display:block}</style></head><body>
<div class="step active" id="step1">
  <p>Solve: 3 + 4</p>
  <input id="ans1" type="number">
  <button id="next1">Next</button>
</div>
<div class="step" id="step2">
  <p>One more thing</p>
  <button id="verify2">Verify</button>
</div>
<script>
  document.getElementById('next1').addEventListener('click', function(e){
    if (e.isTrusted && document.getElementById('ans1').value === '7') {
      document.getElementById('step1').classList.remove('active');
      document.getElementById('step2').classList.add('active');
    }
  });
  document.getElementById('verify2').addEventListener('click', function(e){
    if (e.isTrusted) {
      document.getElementById('step2').classList.remove('active');
      document.title = 'Solved';
    }
  });
</script></body></html>"#;

#[tokio::test]
async fn multi_step_solver_advances_a_trusted_click_gated_wizard() {
    if which::which("firefox").is_err() {
        eprintln!("SKIP multi_step_trusted_click: firefox not on PATH");
        return;
    }

    let addr = common::serve_once(WIZARD.to_string()).await;
    let url = format!("http://{addr}/");
    let browser = common::launch_browser().await;
    let page = browser.new_page(&url).await.expect("navigate to wizard");

    // NEGATIVE CONTROL: prove the gate is real. Fill the correct answer and fire a
    // SYNTHETIC (untrusted) JS click on Next, it must NOT advance, so a JS-click
    // solver would be stuck on step1 forever.
    let after_untrusted = page
        .evaluate(
            r#"(() => {
                document.getElementById('ans1').value = '7';
                document.getElementById('next1').click();
                const a = document.querySelector('.step.active');
                return a ? a.id : '';
            })()"#,
        )
        .await
        .expect("untrusted-click control eval")
        .into_value::<String>()
        .unwrap_or_default();
    assert_eq!(
        after_untrusted, "step1",
        "fixture sanity: an untrusted JS click must NOT advance the wizard (it stayed on \
         {after_untrusted:?} instead of step1), otherwise the test cannot prove trusted delivery"
    );

    // The real solver: it must deliver TRUSTED clicks and drive the wizard to "Solved".
    let info = CaptchaInfo {
        kind: DetectedCaptcha::MultiStepCaptcha,
        site_key: None,
        page_url: url.clone(),
        container_selector: Some(".step.active".into()),
    };
    let res = MultiStepCaptchaSolver::new()
        .solve(&page, &info)
        .await
        .expect("solver returned an error");

    let title = page
        .evaluate("document.title")
        .await
        .ok()
        .and_then(|e| e.into_value::<String>().ok())
        .unwrap_or_default();
    let _ = browser.close().await;

    assert!(
        res.success,
        "MultiStepCaptchaSolver must report success on the trusted-click-gated wizard \
         (got success={}, solution={:?}), a JS el.click() (isTrusted=false) would be \
         rejected by every step and never reach 'Solved'",
        res.success, res.solution
    );
    assert_eq!(
        title, "Solved",
        "the wizard's title must flip to 'Solved', only trusted clicks on Next + Verify \
         advance it; got title={title:?}"
    );
}