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
//! Snapshot tests on the JSON wire shapes of every public type that
//! escapes the captchaforge boundary. A refactor that changes any
//! field name / type / nesting requires explicit `cargo insta review`
//! before the snapshot diff can land.
//!
//! Wire stability matters because:
//! - The HTTP `serve` API returns these shapes; clients depend on
//!   them.
//! - The `bench` Observation is consumed by external dashboards.
//! - `TrainingSample` / `VlmSample` JSONL are persisted; older
//!   captchaforge versions must still parse them.
//!
//! Run with `cargo test --test snapshot_wire_shapes`: failing
//! snapshots write `.snap.new` files; review with `cargo insta
//! review` and commit the new `.snap` only after confirming the
//! wire shape change is intentional + downstream consumers are
//! migrated.

use captchaforge::detect::{CaptchaInfo, DetectedCaptcha};
use captchaforge::solver::{CaptchaSolveResult, SolveMethod};
use captchaforge::training_corpus::TrainingSample;

fn fixed_solve_result() -> CaptchaSolveResult {
    CaptchaSolveResult {
        success: true,
        confidence: 0.93,
        method: SolveMethod::BehavioralBypass,
        time_ms: 1234,
        solution: "0.AAAA.example-cf-turnstile-token.BBBB".to_string(),
        screenshot: None,
        cookies: Vec::new(),
        verified_outcome: None,
    }
}

fn fixed_captcha_info() -> CaptchaInfo {
    CaptchaInfo {
        kind: DetectedCaptcha::Turnstile,
        site_key: Some("0xTEST_SITEKEY".to_string()),
        container_selector: Some(".cf-turnstile".to_string()),
        page_url: "https://example.com/protected".to_string(),
    }
}

fn fixed_training_sample() -> TrainingSample {
    TrainingSample {
        solver: "BehavioralCaptchaSolver".into(),
        vendor: "cloudflare-turnstile".into(),
        detected_kind: "Turnstile".into(),
        url: "https://example.com/x".into(),
        outcome: "failure".into(),
        confidence: Some(0.42),
        time_ms: 5000,
        screenshot_b64: None,
        dom_snapshot: None,
        verified_outcome: Some("silent_fail".into()),
        captured_at_unix: 1_700_000_000,
    }
}

#[test]
fn captcha_solve_result_wire_shape() {
    let r = fixed_solve_result();
    let json = serde_json::to_string_pretty(&r).expect("serialize");
    insta::assert_snapshot!(json);
}

#[test]
fn captcha_info_wire_shape() {
    let info = fixed_captcha_info();
    let json = serde_json::to_string_pretty(&info).expect("serialize");
    insta::assert_snapshot!(json);
}

#[test]
fn training_sample_wire_shape() {
    let s = fixed_training_sample();
    let json = serde_json::to_string_pretty(&s).expect("serialize");
    insta::assert_snapshot!(json);
}

/// Backward-compat: any TrainingSample written by an older
/// captchaforge MUST round-trip through the current parser. Locks
/// the JSONL persistence forever-stable.
#[test]
fn training_sample_round_trips_through_serde() {
    let original = fixed_training_sample();
    let json = serde_json::to_string(&original).unwrap();
    let back: TrainingSample = serde_json::from_str(&json).unwrap();
    assert_eq!(back.solver, original.solver);
    assert_eq!(back.vendor, original.vendor);
    assert_eq!(back.outcome, original.outcome);
    assert_eq!(back.confidence, original.confidence);
    assert_eq!(back.captured_at_unix, original.captured_at_unix);
    assert_eq!(back.verified_outcome, original.verified_outcome);
}

/// Wire-stability: a CaptchaSolveResult must round-trip cleanly so
/// the bench JSON output is rehydrated by external tooling.
#[test]
fn captcha_solve_result_round_trips_through_serde() {
    let original = fixed_solve_result();
    let json = serde_json::to_string(&original).unwrap();
    let back: CaptchaSolveResult = serde_json::from_str(&json).unwrap();
    assert_eq!(back.success, original.success);
    assert_eq!(back.solution, original.solution);
    assert_eq!(back.time_ms, original.time_ms);
    assert!((back.confidence - original.confidence).abs() < 1e-6);
}