chromewright 0.4.0

Browser automation MCP server via Chrome DevTools Protocol (CDP)
Documentation
#![allow(dead_code)]

use chromewright::{BrowserError, BrowserSession, LaunchOptions, Result};
use serde_json::Value;
use std::path::PathBuf;
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant};

const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
const POLL_INTERVAL: Duration = Duration::from_millis(50);
const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";

fn launch_error_is_environmental(message: &str) -> bool {
    [
        "didn't give us a WebSocket URL before we timed out",
        "Could not auto detect a chrome executable",
        "Running as root without --no-sandbox is not supported",
    ]
    .iter()
    .any(|fragment| message.contains(fragment))
}

pub fn browser_test_guard() -> MutexGuard<'static, ()> {
    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
    match LOCK.get_or_init(|| Mutex::new(())).lock() {
        Ok(guard) => guard,
        Err(poisoned) => {
            eprintln!("Recovering browser test lock after a prior panic");
            poisoned.into_inner()
        }
    }
}

pub fn launch_or_skip() -> Option<BrowserSession> {
    for attempt in 1..=3 {
        match BrowserSession::launch(LaunchOptions::new().headless(true)) {
            Ok(mut session) => {
                session.tool_registry_mut().register_operator_tools();
                return Some(session);
            }
            Err(err) if launch_error_is_environmental(&err.to_string()) => {
                if attempt == 3 {
                    eprintln!(
                        "Skipping browser integration test due to environment after {} attempt(s): {}",
                        attempt, err
                    );
                    return None;
                }

                std::thread::sleep(Duration::from_millis(250));
            }
            Err(err) => panic!("Unexpected launch failure: {}", err),
        }
    }

    None
}

pub struct BrowserTestContext {
    _guard: MutexGuard<'static, ()>,
    session: BrowserSession,
}

impl BrowserTestContext {
    pub fn session(&self) -> &BrowserSession {
        &self.session
    }
}

pub fn browser_or_skip() -> Option<BrowserTestContext> {
    let guard = browser_test_guard();
    let session = launch_or_skip()?;

    Some(BrowserTestContext {
        _guard: guard,
        session,
    })
}

pub fn navigate_and_wait(session: &BrowserSession, url: &str) -> Result<()> {
    session.navigate(url)?;
    wait_for_document_ready(session)
}

pub fn navigate_html(session: &BrowserSession, html: impl AsRef<str>) -> Result<()> {
    let data_url = format!("data:text/html,{}", html.as_ref());
    navigate_and_wait(session, &data_url)
}

pub fn navigate_encoded_html(session: &BrowserSession, html: impl AsRef<str>) -> Result<()> {
    let data_url = encoded_html_url(html);
    navigate_and_wait(session, &data_url)
}

pub fn encoded_html_url(html: impl AsRef<str>) -> String {
    format!("data:text/html,{}", urlencoding::encode(html.as_ref()))
}

pub fn wait_for_document_ready(session: &BrowserSession) -> Result<()> {
    session.wait_for_document_ready_with_timeout(DEFAULT_TIMEOUT)
}

pub fn wait_for_url_contains(session: &BrowserSession, needle: &str) -> Result<()> {
    wait_until(
        &format!("active tab URL to contain {needle:?}"),
        DEFAULT_TIMEOUT,
        || Ok(session.document_metadata()?.url.contains(needle)),
    )
}

pub fn wait_for_tab_count(session: &BrowserSession, expected: usize) -> Result<()> {
    wait_until(
        &format!("tab count to equal {}", expected),
        DEFAULT_TIMEOUT,
        || Ok(session.list_tabs()?.len() == expected),
    )
}

pub fn wait_for_tab_count_at_least(session: &BrowserSession, minimum: usize) -> Result<()> {
    wait_until(
        &format!("tab count to reach at least {}", minimum),
        DEFAULT_TIMEOUT,
        || Ok(session.list_tabs()?.len() >= minimum),
    )
}

pub fn evaluate(session: &BrowserSession, js: &str) -> Result<Value> {
    let result = session.execute_tool(
        "evaluate",
        serde_json::json!({
            "code": js,
            "await_promise": false,
            "confirm_unsafe": true,
        }),
    )?;

    if !result.success {
        return Err(BrowserError::ToolExecutionFailed {
            tool: "evaluate".to_string(),
            reason: result
                .error
                .unwrap_or_else(|| "evaluate failed".to_string()),
        });
    }

    Ok(result
        .data
        .and_then(|data| data.get("result").cloned())
        .unwrap_or(Value::Null))
}

pub fn wait_for_eval_truthy(
    session: &BrowserSession,
    description: &str,
    js: &str,
    timeout: Duration,
) -> Result<()> {
    wait_until(description, timeout, || {
        let value = evaluate(session, js).map_err(|err| {
            BrowserError::EvaluationFailed(format!(
                "Failed to evaluate wait probe for {}: {}",
                description, err
            ))
        })?;

        Ok(json_value_is_truthy(Some(&value)))
    })
}

pub fn wait_until<F>(description: &str, timeout: Duration, mut check: F) -> Result<()>
where
    F: FnMut() -> Result<bool>,
{
    let start = Instant::now();

    loop {
        if check()? {
            return Ok(());
        }

        if start.elapsed() >= timeout {
            return Err(BrowserError::Timeout(format!(
                "Timed out waiting for {} within {} ms",
                description,
                timeout.as_millis()
            )));
        }

        std::thread::sleep(POLL_INTERVAL);
    }
}

fn json_value_is_truthy(value: Option<&Value>) -> bool {
    match value {
        Some(Value::Bool(value)) => *value,
        Some(Value::Null) | None => false,
        Some(Value::Number(value)) => value
            .as_i64()
            .map(|number| number != 0)
            .or_else(|| value.as_u64().map(|number| number != 0))
            .or_else(|| value.as_f64().map(|number| number != 0.0))
            .unwrap_or(false),
        Some(Value::String(value)) => !value.is_empty(),
        Some(Value::Array(value)) => !value.is_empty(),
        Some(Value::Object(value)) => !value.is_empty(),
    }
}

pub fn assert_png_screenshot_artifact(data: &Value) -> PathBuf {
    assert_eq!(data["format"].as_str(), Some("png"));
    assert_eq!(data["mime_type"].as_str(), Some("image/png"));
    assert!(
        data["artifact_uri"]
            .as_str()
            .unwrap_or_default()
            .starts_with("file://"),
        "artifact_uri should point at a managed file"
    );

    let artifact_path = PathBuf::from(
        data["artifact_path"]
            .as_str()
            .expect("artifact_path should be returned"),
    );
    assert!(
        artifact_path.is_file(),
        "artifact_path should exist on disk: {}",
        artifact_path.display()
    );

    let bytes = std::fs::read(&artifact_path).expect("screenshot artifact should be readable");
    assert!(
        bytes.starts_with(PNG_SIGNATURE),
        "screenshot artifact should be a PNG"
    );
    assert_eq!(data["byte_count"].as_u64(), Some(bytes.len() as u64));

    let (width, height) = png_dimensions(&bytes);
    assert_eq!(data["width"].as_u64(), Some(width as u64));
    assert_eq!(data["height"].as_u64(), Some(height as u64));

    artifact_path
}

fn png_dimensions(bytes: &[u8]) -> (u32, u32) {
    assert!(
        bytes.len() >= 24 && &bytes[..PNG_SIGNATURE.len()] == PNG_SIGNATURE,
        "PNG header should be present"
    );

    let width = u32::from_be_bytes(bytes[16..20].try_into().expect("width bytes should exist"));
    let height = u32::from_be_bytes(bytes[20..24].try_into().expect("height bytes should exist"));
    (width, height)
}