runtime-foxdriver 0.1.0

Firefox browser automation via WebDriver BiDi (rustenium)
Documentation
//! Cross-origin click-delivery contract (live Firefox).
//!
//! Captcha checkboxes (Turnstile / hCaptcha / reCAPTCHA) live in a
//! *cross-origin* iframe — Turnstile in a doubly-nested one. This test pins
//! down, deterministically and with NO real vendor, exactly what click
//! delivery foxdriver guarantees, so a regression in the input path (or a
//! Firefox change) is caught here instead of silently zeroing a solver's
//! live success rate.
//!
//! Two-pair contract:
//!   POSITIVE — a real BiDi pointer click (top-context viewport OR
//!     iframe-context-local via `click_at_in`) lands a `isTrusted === true`
//!     event inside the cross-origin frame, single AND nested.
//!   NEGATIVE — a synthetic JS `.click()` reaches the element but arrives
//!     `isTrusted === false`: the exact reason a JS-dispatched click never
//!     solves a real captcha.
//!
//! Live test: requires `firefox` on PATH. Skips (does not fail) when absent
//! so non-browser CI stays green; Firefox-equipped runners enforce it.

use std::io::{Read, Write};
use std::net::TcpListener;
use std::time::Duration;

use runtime_foxdriver::{launch_firefox, FoxBrowserConfig, FrameId, Page};

const IFRAME_LEFT: f64 = 60.0;
const IFRAME_TOP: f64 = 80.0;
const CB_LOCAL_X: f64 = 30.0;
const CB_LOCAL_Y: f64 = 34.0;
const INNER_LEFT_IN_MID: f64 = 20.0;
const INNER_TOP_IN_MID: f64 = 20.0;
const NESTED_TOP_X: f64 = IFRAME_LEFT + INNER_LEFT_IN_MID + CB_LOCAL_X;
const NESTED_TOP_Y: f64 = IFRAME_TOP + INNER_TOP_IN_MID + CB_LOCAL_Y;

fn parent_html(box_port: u16, mid_port: u16) -> String {
    format!(
        r#"<!doctype html><html><head><meta charset="utf-8"></head>
<body style="margin:0;padding:0">
<iframe id="cap" src="http://127.0.0.1:{box_port}/box"
  style="position:absolute;left:{IFRAME_LEFT}px;top:{IFRAME_TOP}px;width:300px;height:65px;border:0"></iframe>
<iframe id="mid" src="http://127.0.0.1:{mid_port}/mid"
  style="position:absolute;left:{IFRAME_LEFT}px;top:{IFRAME_TOP}px;width:300px;height:200px;border:0;display:none"></iframe>
<script>
window.__delivered=false; window.__trusted=null; window.__moveTrusted=null;
window.addEventListener('mousemove', function(e){{ window.__moveTrusted=e.isTrusted; }}, true);
window.addEventListener('message', function(e){{
  if(e.data&&e.data.t==='clicked'){{ window.__delivered=true; window.__trusted=!!e.data.trusted; }}
  if(e.data&&e.data.t==='show_nested'){{ document.getElementById('cap').style.display='none'; document.getElementById('mid').style.display='block'; }}
}});
</script></body></html>"#
    )
}

const CHECKBOX_HTML: &str = r#"<!doctype html><html><head><meta charset="utf-8"></head>
<body style="margin:0;padding:0">
<div id="cb" style="position:absolute;left:18px;top:22px;width:24px;height:24px;background:#3a7"></div>
<script>document.getElementById('cb').addEventListener('click',function(e){window.top.postMessage({t:'clicked',trusted:e.isTrusted},'*');});</script>
</body></html>"#;

fn mid_html(inner_port: u16) -> String {
    format!(
        r#"<!doctype html><html><head><meta charset="utf-8"></head>
<body style="margin:0;padding:0">
<iframe id="inner" src="http://127.0.0.1:{inner_port}/inner"
  style="position:absolute;left:{INNER_LEFT_IN_MID}px;top:{INNER_TOP_IN_MID}px;width:250px;height:120px;border:0"></iframe>
</body></html>"#
    )
}

fn serve(listener: TcpListener, route: impl Fn(&str) -> Option<String> + Send + 'static) {
    for stream in listener.incoming() {
        let Ok(mut s) = stream else { continue };
        let mut buf = [0u8; 2048];
        let n = s.read(&mut buf).unwrap_or(0);
        let req = String::from_utf8_lossy(&buf[..n]);
        let path = req.lines().next().and_then(|l| l.split_whitespace().nth(1)).unwrap_or("/");
        let body = route(path);
        let (status, body) = match body {
            Some(b) => ("200 OK", b),
            None => ("404 Not Found", "no".to_string()),
        };
        let resp = format!(
            "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
            body.len()
        );
        let _ = s.write_all(resp.as_bytes());
        let _ = s.flush();
    }
}

fn firefox_present() -> bool {
    std::process::Command::new("firefox")
        .arg("--version")
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

async fn delivered(page: &Page) -> (bool, Option<bool>) {
    let v = page
        .evaluate("JSON.stringify([window.__delivered===true, window.__trusted])")
        .await
        .ok()
        .and_then(|e| e.into_value::<String>().ok())
        .unwrap_or_default();
    let d = v.starts_with("[true");
    let t = if v.contains("true,true") { Some(true) } else if v.contains("true,false") { Some(false) } else { None };
    (d, t)
}

async fn reset(page: &Page) {
    let _ = page.evaluate("window.__delivered=false;window.__trusted=null;void 0").await;
}

async fn nonmain_ctx(page: &Page) -> Option<FrameId> {
    let frames = page.frames().await.ok()?;
    let main = page.mainframe().await.ok().flatten();
    frames.into_iter().find(|c| Some(c) != main.as_ref())
}

async fn ctx_with_path(page: &Page, needle: &str) -> Option<FrameId> {
    for c in page.frames().await.ok()? {
        if let Ok(r) = page.evaluate_in_context("location.pathname", &c).await {
            if r.into_value::<String>().map(|p| p.contains(needle)).unwrap_or(false) {
                return Some(c);
            }
        }
    }
    None
}

async fn run() {
    let l_parent = TcpListener::bind("127.0.0.1:0").unwrap();
    let l_box = TcpListener::bind("127.0.0.1:0").unwrap();
    let l_mid = TcpListener::bind("127.0.0.1:0").unwrap();
    let l_inner = TcpListener::bind("127.0.0.1:0").unwrap();
    let p_parent = l_parent.local_addr().unwrap().port();
    let p_box = l_box.local_addr().unwrap().port();
    let p_mid = l_mid.local_addr().unwrap().port();
    let p_inner = l_inner.local_addr().unwrap().port();

    let phtml = parent_html(p_box, p_mid);
    std::thread::spawn(move || serve(l_parent, move |p| (p == "/").then(|| phtml.clone())));
    std::thread::spawn(move || serve(l_box, |p| p.starts_with("/box").then(|| CHECKBOX_HTML.to_string())));
    let midhtml = mid_html(p_inner);
    std::thread::spawn(move || serve(l_mid, move |p| p.starts_with("/mid").then(|| midhtml.clone())));
    std::thread::spawn(move || serve(l_inner, |p| p.starts_with("/inner").then(|| CHECKBOX_HTML.to_string())));

    let page = launch_firefox(FoxBrowserConfig {
        executable_path: None,
        profile_dir: None,
        headless: true,
        viewport_width: 1280,
        viewport_height: 800,
        user_agent: None,
        user_js_content: None,
        proxy: None,
    })
    .await
    .expect("launch firefox");
    page.goto(&format!("http://127.0.0.1:{p_parent}/")).await.expect("goto");

    for _ in 0..50 {
        if page.frames().await.unwrap().len() >= 2 { break; }
        tokio::time::sleep(Duration::from_millis(100)).await;
    }

    let box_ctx = nonmain_ctx(&page).await.expect("cross-origin iframe context tracked");
    let top_x = IFRAME_LEFT + CB_LOCAL_X;
    let top_y = IFRAME_TOP + CB_LOCAL_Y;

    // POSITIVE 1: top-context viewport click → trusted, single cross-origin.
    reset(&page).await;
    page.click_at(top_x, top_y).await.expect("top-context click");
    tokio::time::sleep(Duration::from_millis(400)).await;
    assert_eq!(delivered(&page).await, (true, Some(true)), "top-context click must deliver a TRUSTED event into the cross-origin iframe");

    // POSITIVE 2: iframe-context local click → trusted, single cross-origin.
    reset(&page).await;
    page.click_at_in(&box_ctx, CB_LOCAL_X, CB_LOCAL_Y).await.expect("iframe-context click");
    tokio::time::sleep(Duration::from_millis(400)).await;
    assert_eq!(delivered(&page).await, (true, Some(true)), "click_at_in must deliver a TRUSTED event in the iframe's own context");

    // NEGATIVE: synthetic JS click → reaches the element but UNtrusted.
    reset(&page).await;
    page.evaluate_in_context("document.getElementById('cb').click()", &box_ctx).await.expect("synthetic click");
    tokio::time::sleep(Duration::from_millis(400)).await;
    assert_eq!(delivered(&page).await, (true, Some(false)), "synthetic JS click must arrive UNtrusted (the reason JS clicks never solve a real captcha)");

    // NESTED (Turnstile-shaped): swap to the parent→mid→inner widget.
    let _ = page.evaluate("window.postMessage({t:'show_nested'},'*')").await;
    for _ in 0..60 {
        if page.frames().await.unwrap().len() >= 3 { break; }
        tokio::time::sleep(Duration::from_millis(100)).await;
    }

    // POSITIVE 3: top-context click routes two frames deep → trusted.
    reset(&page).await;
    page.click_at(NESTED_TOP_X, NESTED_TOP_Y).await.expect("nested top-context click");
    tokio::time::sleep(Duration::from_millis(400)).await;
    assert_eq!(delivered(&page).await, (true, Some(true)), "top-context click must route into a doubly-nested cross-origin frame, trusted");

    // POSITIVE 4: innermost-context local click → trusted.
    let inner = ctx_with_path(&page, "inner").await.expect("inner context resolved");
    reset(&page).await;
    page.click_at_in(&inner, CB_LOCAL_X, CB_LOCAL_Y).await.expect("inner-context click");
    tokio::time::sleep(Duration::from_millis(400)).await;
    assert_eq!(delivered(&page).await, (true, Some(true)), "click_at_in must deliver a TRUSTED event into the innermost nested frame");

    // POSITIVE 5: move_mouse_to dispatches a TRUSTED mousemove (isTrusted=true).
    // This is the primitive every human-trajectory generator now routes each
    // sampled point through; a synthetic JS mousemove would be isTrusted=false.
    // (500,400) is clear of the widgets, in the top page.
    let _ = page.evaluate("window.__moveTrusted=null; void 0").await;
    page.move_mouse_to(500.0, 400.0).await.expect("trusted move");
    tokio::time::sleep(Duration::from_millis(200)).await;
    let move_trusted = page
        .evaluate("JSON.stringify(window.__moveTrusted)")
        .await
        .ok()
        .and_then(|e| e.into_value::<String>().ok())
        .unwrap_or_default();
    assert_eq!(move_trusted, "true", "move_mouse_to must dispatch a TRUSTED mousemove (isTrusted=true), not a synthetic JS event");
}

#[test]
fn cross_origin_trusted_click_delivery() {
    if !firefox_present() {
        eprintln!("SKIP cross_origin_trusted_click_delivery: firefox not on PATH");
        return;
    }
    let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap();
    rt.block_on(run());
}