car-browser 0.9.0

Browser automation and perception pipeline for Common Agent Runtime
Documentation
//! Headless Chromium integration tests.
//!
//! These tests require Chrome/Chromium installed on the system.
//! They are ignored by default — run with:
//!   cargo test -p car-browser --features chromium --test headless -- --ignored

#![cfg(feature = "chromium")]

use car_browser::chromium::ChromiumBackend;
use car_browser::backend::BrowserBackend;

#[tokio::test]
#[ignore] // Requires Chrome installed
async fn test_headless_launch_and_navigate() {
    let backend = ChromiumBackend::launch()
        .await
        .expect("Failed to launch Chrome — is it installed?");

    // Navigate to example.com
    backend.navigate("https://example.com").await.unwrap();

    // Get title
    let title = backend.get_page_title().await.unwrap();
    assert!(
        title.contains("Example"),
        "Title should contain 'Example', got: {}",
        title
    );

    // Take screenshot
    let screenshot = backend.capture_screenshot().await.unwrap();
    assert!(screenshot.len() > 1000, "Screenshot should be a real PNG");
    assert_eq!(&screenshot[..4], &[0x89, 0x50, 0x4E, 0x47], "Should start with PNG magic");

    backend.shutdown().await.unwrap();
}

#[tokio::test]
#[ignore] // Requires Chrome installed
async fn test_headless_accessibility_tree() {
    let backend = ChromiumBackend::launch()
        .await
        .expect("Failed to launch Chrome");

    backend.navigate("https://example.com").await.unwrap();

    // Extract a11y tree
    let nodes = backend.get_accessibility_tree().await.unwrap();
    assert!(!nodes.is_empty(), "A11y tree should not be empty");

    // Should find some known roles
    let has_link = nodes.iter().any(|n| n.role.contains("link"));
    let has_heading = nodes.iter().any(|n| n.role.contains("heading"));
    assert!(
        has_link || has_heading,
        "Should find links or headings on example.com. Roles found: {:?}",
        nodes.iter().map(|n| &n.role).collect::<Vec<_>>()
    );

    backend.shutdown().await.unwrap();
}

#[tokio::test]
#[ignore] // Requires Chrome installed
async fn test_headless_full_stack_with_car() {
    use std::collections::HashMap;
    use std::sync::Arc;
    use car_browser::BrowserToolExecutor;
    use car_browser::perception::pipeline::BasicPerceptionPipeline;
    use car_browser::perception::PerceptionPipeline;
    use car_engine::{Runtime, ToolExecutor};
    use car_ir::{Action, ActionProposal, ActionStatus, ActionType, FailureBehavior};
    use serde_json::json;

    let backend = Arc::new(
        ChromiumBackend::launch()
            .await
            .expect("Failed to launch Chrome"),
    );
    let pipeline: Arc<dyn PerceptionPipeline> = Arc::new(BasicPerceptionPipeline::new());
    let executor = Arc::new(BrowserToolExecutor::new(
        backend.clone() as Arc<dyn BrowserBackend>,
        pipeline,
    ));

    let rt = Runtime::new().with_executor(executor as Arc<dyn ToolExecutor>);
    for schema in BrowserToolExecutor::tool_schemas() {
        rt.register_tool_schema(schema).await;
    }

    // Navigate
    let nav = ActionProposal {
        id: "p1".into(),
        source: "test".into(),
        actions: vec![Action {
            id: "a1".into(),
            action_type: ActionType::ToolCall,
            tool: Some("browse_navigate".into()),
            parameters: {
                let mut m = HashMap::new();
                m.insert("url".into(), json!("https://example.com"));
                m
            },
            preconditions: vec![],
            expected_effects: HashMap::new(),
            state_dependencies: vec![],
            idempotent: false,
            max_retries: 1,
            failure_behavior: FailureBehavior::Abort,
            timeout_ms: Some(30_000),
            metadata: HashMap::new(),
        }],
        timestamp: chrono::Utc::now(),
        context: HashMap::new(),
    };

    let result = rt.execute(&nav).await;
    assert_eq!(result.results[0].status, ActionStatus::Succeeded);

    // Observe
    let obs = ActionProposal {
        id: "p2".into(),
        source: "test".into(),
        actions: vec![Action {
            id: "a2".into(),
            action_type: ActionType::ToolCall,
            tool: Some("browse_observe".into()),
            parameters: HashMap::new(),
            preconditions: vec![],
            expected_effects: HashMap::new(),
            state_dependencies: vec![],
            idempotent: true,
            max_retries: 1,
            failure_behavior: FailureBehavior::Abort,
            timeout_ms: Some(30_000),
            metadata: HashMap::new(),
        }],
        timestamp: chrono::Utc::now(),
        context: HashMap::new(),
    };

    let result = rt.execute(&obs).await;
    assert_eq!(result.results[0].status, ActionStatus::Succeeded);

    let output = result.results[0].output.as_ref().unwrap();
    let ui_map_text = output["ui_map"].as_str().unwrap();
    assert!(
        !ui_map_text.is_empty(),
        "UiMap should have content from example.com"
    );
    assert!(
        output["element_count"].as_u64().unwrap() > 0,
        "Should detect elements on example.com"
    );

    println!("Headless UiMap:\n{}", ui_map_text);
    println!("Elements: {}", output["element_count"]);

    backend.shutdown().await.unwrap();
}