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
//! Unit tests for [`super`] (telemetry event recording, aggregation, and export).

use super::*;
use std::sync::{Arc, Mutex};

/// In-memory telemetry sink for assertion in tests / examples.
struct CapturingTelemetry {
    events: Mutex<Vec<(SolveOutcome, String, u64)>>,
}

impl CapturingTelemetry {
    fn new() -> Self {
        Self {
            events: Mutex::new(Vec::new()),
        }
    }
}

impl SolverTelemetry for CapturingTelemetry {
    fn record(&self, evt: &SolveEvent<'_>) {
        self.events
            .lock()
            .unwrap()
            .push((evt.outcome, evt.domain.to_owned(), evt.time_ms));
    }
}

#[test]
fn noop_telemetry_drops_events() {
    let t = NoopTelemetry;
    let kind = DetectedCaptcha::Turnstile;
    let captcha_type = CaptchaType::CloudflareTurnstile;
    let method = SolveMethod::BehavioralBypass;
    let evt = SolveEvent {
        solver: "TestSolver",
        captcha_type: &captcha_type,
        kind: &kind,
        domain: "example.com",
        outcome: SolveOutcome::Success,
        time_ms: 1234,
        confidence: Some(0.95),
        method: &method,
    };
    t.record(&evt);
    // No assertion needed; we're verifying it doesn't panic.
}

#[test]
fn metrics_telemetry_aggregates_per_solver_outcome_counts() {
    let m = MetricsTelemetry::new();
    let kind = DetectedCaptcha::Turnstile;
    let captcha_type = CaptchaType::CloudflareTurnstile;
    let method = SolveMethod::BehavioralBypass;

    for (solver, outcome) in [
        ("BehavioralCaptchaSolver", SolveOutcome::Success),
        ("BehavioralCaptchaSolver", SolveOutcome::Success),
        ("BehavioralCaptchaSolver", SolveOutcome::Failure),
        ("VlmCaptchaSolver", SolveOutcome::Success),
    ] {
        m.record(&SolveEvent {
            solver,
            captcha_type: &captcha_type,
            kind: &kind,
            domain: "ex.com",
            outcome,
            time_ms: 100,
            confidence: None,
            method: &method,
        });
    }
    let snap = m.snapshot();
    let beh_succ = snap
        .counts
        .iter()
        .find(|c| c.solver == "BehavioralCaptchaSolver" && c.outcome == "success");
    assert_eq!(beh_succ.map(|c| c.count), Some(2));
    let beh_fail = snap
        .counts
        .iter()
        .find(|c| c.solver == "BehavioralCaptchaSolver" && c.outcome == "failure");
    assert_eq!(beh_fail.map(|c| c.count), Some(1));
}

#[test]
fn metrics_histogram_bucket_index_partitions_correctly() {
    // Boundary values land in the bucket whose upper bound
    // they exactly equal, not the next one up.
    assert_eq!(histogram_bucket_index(0), 0);
    assert_eq!(histogram_bucket_index(10), 0);
    assert_eq!(histogram_bucket_index(11), 1);
    assert_eq!(histogram_bucket_index(50), 1);
    assert_eq!(histogram_bucket_index(100), 2);
    assert_eq!(histogram_bucket_index(500), 3);
    assert_eq!(histogram_bucket_index(1_000), 4);
    assert_eq!(histogram_bucket_index(5_000), 5);
    assert_eq!(histogram_bucket_index(10_000), 6);
    assert_eq!(histogram_bucket_index(30_000), 7);
    // Anything past 30s lands in the +inf bucket.
    assert_eq!(histogram_bucket_index(31_000), 8);
    assert_eq!(histogram_bucket_index(u64::MAX), 8);
}

#[test]
fn metrics_to_prometheus_renders_valid_exposition_format() {
    let m = MetricsTelemetry::new();
    let kind = DetectedCaptcha::Turnstile;
    let captcha_type = CaptchaType::CloudflareTurnstile;
    let method = SolveMethod::BehavioralBypass;
    m.record(&SolveEvent {
        solver: "TestSolver",
        captcha_type: &captcha_type,
        kind: &kind,
        domain: "ex.com",
        outcome: SolveOutcome::Success,
        time_ms: 250,
        confidence: Some(0.9),
        method: &method,
    });
    let prom = m.snapshot().to_prometheus();
    // Must contain the documented metric names + label format.
    assert!(prom.contains("# HELP captchaforge_solve_total"));
    assert!(prom.contains("# TYPE captchaforge_solve_total counter"));
    assert!(prom.contains(r#"captchaforge_solve_total{solver="TestSolver",outcome="success"} 1"#));
    assert!(prom.contains("# HELP captchaforge_solve_duration_ms"));
    assert!(prom.contains("# TYPE captchaforge_solve_duration_ms histogram"));
    // Cumulative bucket count: a single 250ms event lands in
    // bucket 3 (le=500), so every bucket from le=500 onward
    // should report cumulative >=1.
    assert!(prom.contains(
        r#"captchaforge_solve_duration_ms_bucket{solver="TestSolver",outcome="success",le="500"} 1"#
    ));
    assert!(prom.contains(r#"captchaforge_solve_duration_ms_bucket{solver="TestSolver",outcome="success",le="+Inf"} 1"#));
    // Smaller buckets should report 0.
    assert!(prom.contains(
        r#"captchaforge_solve_duration_ms_bucket{solver="TestSolver",outcome="success",le="10"} 0"#
    ));
}

#[test]
fn metrics_to_prometheus_escapes_problematic_label_chars() {
    let m = MetricsTelemetry::new();
    let kind = DetectedCaptcha::Turnstile;
    let captcha_type = CaptchaType::CloudflareTurnstile;
    let method = SolveMethod::BehavioralBypass;
    // Solver name with backslash + quote, must escape so the
    // exposition format stays parseable.
    m.record(&SolveEvent {
        solver: "Solver\"with\\quotes",
        captcha_type: &captcha_type,
        kind: &kind,
        domain: "ex.com",
        outcome: SolveOutcome::Success,
        time_ms: 10,
        confidence: None,
        method: &method,
    });
    let prom = m.snapshot().to_prometheus();
    // Quote → \", backslash → \\.
    assert!(prom.contains(r#"solver="Solver\"with\\quotes""#));
}

#[test]
fn outcome_label_returns_stable_strings() {
    // Stability matters: Prometheus / log queries join on these
    // exact strings. A typo here breaks every dashboard.
    assert_eq!(outcome_label(SolveOutcome::Success), "success");
    assert_eq!(outcome_label(SolveOutcome::Failure), "failure");
    assert_eq!(outcome_label(SolveOutcome::Error), "error");
    assert_eq!(outcome_label(SolveOutcome::Timeout), "timeout");
}

#[test]
fn json_telemetry_does_not_panic_on_record() {
    // We can't easily intercept the tracing output here without
    // a subscriber; the lightest assertion is just "doesn't
    // panic + doesn't allocate-bomb".
    let t = JsonTelemetry;
    let kind = DetectedCaptcha::Turnstile;
    let captcha_type = CaptchaType::CloudflareTurnstile;
    let method = SolveMethod::BehavioralBypass;
    for outcome in [
        SolveOutcome::Success,
        SolveOutcome::Failure,
        SolveOutcome::Error,
        SolveOutcome::Timeout,
    ] {
        t.record(&SolveEvent {
            solver: "TestSolver",
            captcha_type: &captcha_type,
            kind: &kind,
            domain: "x.test",
            outcome,
            time_ms: 100,
            confidence: None,
            method: &method,
        });
    }
}

#[test]
fn capturing_telemetry_round_trips_event_fields() {
    let cap = Arc::new(CapturingTelemetry {
        events: Mutex::new(Vec::new()),
    });
    let kind = DetectedCaptcha::HCaptcha;
    let captcha_type = CaptchaType::HCaptcha;
    let method = SolveMethod::VisionLLM;

    for (outcome, ms) in [
        (SolveOutcome::Success, 1000_u64),
        (SolveOutcome::Failure, 2000),
        (SolveOutcome::Error, 50),
        (SolveOutcome::Timeout, 30000),
    ] {
        let evt = SolveEvent {
            solver: "TestSolver",
            captcha_type: &captcha_type,
            kind: &kind,
            domain: "x.test",
            outcome,
            time_ms: ms,
            confidence: None,
            method: &method,
        };
        cap.record(&evt);
    }
    let events = cap.events.lock().unwrap();
    assert_eq!(events.len(), 4);
    assert_eq!(events[0].0, SolveOutcome::Success);
    assert_eq!(events[3].2, 30000);
}

#[test]
fn fanout_telemetry_dispatches_to_every_sink_in_order() {
    use std::sync::Arc;
    let a = Arc::new(CapturingTelemetry::new());
    let b = Arc::new(CapturingTelemetry::new());
    let c = Arc::new(CapturingTelemetry::new());
    let fanout = FanoutTelemetry::new()
        .with_sink(a.clone() as Arc<dyn SolverTelemetry>)
        .with_sink(b.clone() as Arc<dyn SolverTelemetry>)
        .with_sink(c.clone() as Arc<dyn SolverTelemetry>);
    assert_eq!(fanout.len(), 3);
    assert!(!fanout.is_empty());

    let kind = crate::detect::DetectedCaptcha::Turnstile;
    let captcha_type = crate::solver::CaptchaType::CloudflareTurnstile;
    let method = crate::solver::SolveMethod::BehavioralBypass;
    let evt = SolveEvent::new(
        "S",
        &captcha_type,
        &kind,
        "ex.com",
        SolveOutcome::Success,
        42,
        Some(0.91),
        &method,
    );
    fanout.record(&evt);
    // All three sinks observed the event exactly once.
    assert_eq!(a.events.lock().unwrap().len(), 1);
    assert_eq!(b.events.lock().unwrap().len(), 1);
    assert_eq!(c.events.lock().unwrap().len(), 1);
}

#[test]
fn fanout_telemetry_empty_default_is_noop() {
    let fanout = FanoutTelemetry::default();
    assert!(fanout.is_empty());
    let kind = crate::detect::DetectedCaptcha::Turnstile;
    let captcha_type = crate::solver::CaptchaType::CloudflareTurnstile;
    let method = crate::solver::SolveMethod::BehavioralBypass;
    let evt = SolveEvent::new(
        "S",
        &captcha_type,
        &kind,
        "ex.com",
        SolveOutcome::Success,
        0,
        None,
        &method,
    );
    // Must not panic.
    fanout.record(&evt);
}