car-browser 0.55.0

Browser automation and perception pipeline for Common Agent Runtime
Documentation
// Raise above the default 128: this test's async fixtures sit at the
// recursion-limit edge for async-fn layout ("queries overflow the depth
// limit") — the documented fix car-server/-core already carry.
#![recursion_limit = "512"]

//! Headless Chromium integration tests.
//!
//! These tests require Chrome/Chromium installed on the system. They are
//! ignored by default and use only a loopback fixture server when selected:
//!
//! ```text
//! cargo test -p car-browser --test headless -- --ignored --test-threads=1
//! ```

use car_browser::backend::BrowserBackend;
use car_browser::chromium::ChromiumBackend;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::thread::JoinHandle;
use std::time::Duration;
// `Instant` is used only by the `#[cfg(unix)]` `wait_for_chrome_exit` below —
// `waitpid` has no Windows counterpart here — so an unconditional import is an
// unused import on Windows and `-D warnings` fails `check-windows`, which no
// per-PR job runs. Keep the import's cfg as narrow as its consumer.
#[cfg(unix)]
use std::time::Instant;

const FIXTURE_HTML: &str = r#"<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>CAR Browser Fixture</title>
  <link rel="icon" href="data:,">
</head>
<body>
  <main>
    <h1>Deterministic browser fixture</h1>
    <p id="status">ready</p>
    <a href="/next" aria-label="fixture link">Open fixture link</a>
    <button type="button" aria-label="Run fixture action">Run</button>
  </main>
</body>
</html>
"#;

/// Bounded loopback fixture server. It is listening before the URL is returned,
/// serves the same immutable page for every Chrome request, and joins on drop.
struct FixtureServer {
    url: String,
    stop: Option<mpsc::Sender<()>>,
    thread: Option<JoinHandle<()>>,
}

impl FixtureServer {
    fn start() -> Self {
        let listener = TcpListener::bind("127.0.0.1:0").expect("bind browser fixture");
        listener
            .set_nonblocking(true)
            .expect("make browser fixture accept bounded");
        let address = listener.local_addr().expect("browser fixture address");
        let (stop_tx, stop_rx) = mpsc::channel();
        let thread = std::thread::spawn(move || loop {
            if stop_rx.try_recv().is_ok() {
                return;
            }
            let (mut stream, _) = match listener.accept() {
                Ok(connection) => connection,
                Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
                    std::thread::sleep(Duration::from_millis(5));
                    continue;
                }
                Err(error) => panic!("accept browser fixture request: {error}"),
            };
            stream
                .set_nonblocking(false)
                .expect("blocking browser fixture request");
            stream
                .set_read_timeout(Some(Duration::from_secs(2)))
                .expect("bound browser fixture request read");
            let mut request = Vec::with_capacity(1024);
            while !request.ends_with(b"\r\n\r\n") {
                let mut chunk = [0_u8; 1024];
                let read = match stream.read(&mut chunk) {
                    Ok(0) => break,
                    Ok(read) => read,
                    Err(error)
                        if matches!(
                            error.kind(),
                            std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
                        ) =>
                    {
                        break;
                    }
                    Err(error) => panic!("read browser fixture request: {error}"),
                };
                request.extend_from_slice(&chunk[..read]);
                assert!(
                    request.len() <= 16 * 1024,
                    "browser request headers too large"
                );
            }
            // Chrome may speculatively connect and close without sending a
            // request. That is not a fixture failure and must not poison the
            // next real navigation request.
            if !request.ends_with(b"\r\n\r\n") {
                continue;
            }
            if let Err(error) = write!(
                stream,
                "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{}",
                FIXTURE_HTML.len(),
                FIXTURE_HTML
            ) {
                assert!(
                    matches!(
                        error.kind(),
                        std::io::ErrorKind::BrokenPipe
                            | std::io::ErrorKind::ConnectionReset
                            | std::io::ErrorKind::ConnectionAborted
                    ),
                    "write browser fixture response: {error}"
                );
            }
        });
        Self {
            url: format!("http://{address}/fixture"),
            stop: Some(stop_tx),
            thread: Some(thread),
        }
    }

    fn url(&self) -> &str {
        &self.url
    }
}

impl Drop for FixtureServer {
    fn drop(&mut self) {
        if let Some(stop) = self.stop.take() {
            let _ = stop.send(());
        }
        if let Some(thread) = self.thread.take() {
            thread.join().expect("join browser fixture server");
        }
    }
}

/// Synchronize on and reap the known child with `waitpid`, rather than probing
/// whether some process currently owns the PID. `ECHILD` means chromiumoxide
/// already reaped it. `WNOHANG` keeps the failure deadline enforceable without
/// leaving an uncancellable blocking waiter behind.
#[cfg(unix)]
async fn wait_for_chrome_exit(pid: u32, context: &'static str) {
    let deadline = Instant::now() + Duration::from_secs(5);
    loop {
        let mut status = 0;
        // SAFETY: `waitpid` writes only to the supplied status and observes the
        // known child PID captured from ChromiumBackend.
        let result = unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) };
        if result == pid as libc::pid_t {
            return;
        }
        if result == -1 {
            let error = std::io::Error::last_os_error();
            if error.raw_os_error() == Some(libc::EINTR) {
                continue;
            }
            if error.raw_os_error() == Some(libc::ECHILD) {
                return;
            }
            panic!("wait for Chrome PID {pid} after {context}: {error}");
        }
        assert!(
            Instant::now() < deadline,
            "Chrome PID {pid} did not exit after {context} within 5s"
        );
        tokio::time::sleep(Duration::from_millis(10)).await;
    }
}

#[tokio::test]
#[ignore = "requires installed Chrome; uses a loopback fixture only"]
async fn test_headless_launch_and_navigate() {
    let fixture = FixtureServer::start();
    let backend = ChromiumBackend::launch()
        .await
        .expect("Failed to launch Chrome — is it installed?");

    backend.navigate(fixture.url()).await.unwrap();
    assert_eq!(
        backend.get_page_title().await.unwrap(),
        "CAR Browser Fixture"
    );

    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();
}

/// `shutdown().await` must not return until Chrome has exited or an explicit
/// bounded failure is surfaced.
#[cfg(unix)]
#[tokio::test]
#[ignore = "requires installed Chrome"]
async fn test_headless_shutdown_terminates_chrome() {
    let backend = ChromiumBackend::launch()
        .await
        .expect("Failed to launch Chrome — is it installed?");
    let pid = backend.chrome_pid().expect("Chrome PID should be captured");

    backend.shutdown().await.expect("bounded Chrome shutdown");
    wait_for_chrome_exit(pid, "shutdown()").await;
}

/// Dropping the backend without `shutdown()` must still terminate Chrome; wait
/// directly for the known child so success cannot be inferred from PID reuse.
#[cfg(unix)]
#[tokio::test]
#[ignore = "requires installed Chrome"]
async fn test_headless_drop_terminates_chrome() {
    let pid = {
        let backend = ChromiumBackend::launch()
            .await
            .expect("Failed to launch Chrome");
        backend.chrome_pid().expect("Chrome PID should be captured")
        // backend dropped here without shutdown()
    };

    wait_for_chrome_exit(pid, "ChromiumBackend drop").await;
}

/// Navigating from a closed-last-tab state must reopen a tab and load the
/// caller's page, rather than leaving all subsequent browser calls wedged.
#[tokio::test]
#[ignore = "requires installed Chrome; uses a loopback fixture only"]
async fn test_navigate_from_empty_state_reopens_a_tab() {
    let fixture = FixtureServer::start();
    let backend = ChromiumBackend::launch()
        .await
        .expect("Failed to launch Chrome — is it installed?");

    let only_tab = backend.active_tab_id().expect("launch opens one tab");
    backend
        .close_tab(only_tab)
        .await
        .expect("close the only open tab");
    assert!(
        backend.active_tab_id().is_none(),
        "precondition: the empty state — no tabs open"
    );

    backend
        .navigate(fixture.url())
        .await
        .expect("navigate must reopen a tab rather than fail with 'Page closed'");

    assert_eq!(
        backend.get_page_title().await.unwrap(),
        "CAR Browser Fixture"
    );
    backend.shutdown().await.unwrap();
}

#[tokio::test]
#[ignore = "requires installed Chrome; uses a loopback fixture only"]
async fn test_headless_accessibility_tree() {
    let fixture = FixtureServer::start();
    let backend = ChromiumBackend::launch()
        .await
        .expect("Failed to launch Chrome");

    backend.navigate(fixture.url()).await.unwrap();
    let nodes = backend.get_accessibility_tree().await.unwrap();
    assert!(!nodes.is_empty(), "A11y tree should not be empty");
    assert!(
        nodes.iter().any(|node| node.role.contains("heading")),
        "fixture heading missing; roles: {:?}",
        nodes.iter().map(|node| &node.role).collect::<Vec<_>>()
    );
    assert!(
        nodes.iter().any(|node| node.role.contains("link")),
        "fixture link missing; roles: {:?}",
        nodes.iter().map(|node| &node.role).collect::<Vec<_>>()
    );

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

#[tokio::test]
#[ignore = "requires installed Chrome; uses a loopback fixture only"]
async fn test_headless_full_stack_with_car() {
    use car_browser::perception::pipeline::BasicPerceptionPipeline;
    use car_browser::perception::PerceptionPipeline;
    use car_browser::BrowserToolExecutor;
    use car_engine::{Runtime, ToolExecutor};
    use car_ir::{Action, ActionProposal, ActionStatus, ActionType};
    use serde_json::json;
    use std::collections::HashMap;
    use std::sync::Arc;

    let fixture = FixtureServer::start();
    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;
    }

    let nav = ActionProposal {
        id: "p1".into(),
        source: "test".into(),
        actions: vec![{
            let mut action = Action::new(ActionType::ToolCall);
            action.id = "a1".into();
            action.tool = Some("browse_navigate".into());
            action.parameters = HashMap::from([("url".into(), json!(fixture.url()))]);
            action.max_retries = 1;
            action.timeout_ms = Some(30_000);
            action
        }],
        timestamp: chrono::Utc::now(),
        context: HashMap::new(),
    };

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

    let observe = ActionProposal {
        id: "p2".into(),
        source: "test".into(),
        actions: vec![{
            let mut action = Action::new(ActionType::ToolCall);
            action.id = "a2".into();
            action.tool = Some("browse_observe".into());
            action.idempotent = true;
            action.max_retries = 1;
            action.timeout_ms = Some(30_000);
            action
        }],
        timestamp: chrono::Utc::now(),
        context: HashMap::new(),
    };

    let result = rt.execute(&observe).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(), "fixture UiMap should have content");
    assert!(
        output["element_count"].as_u64().unwrap() > 0,
        "fixture should produce interactive elements"
    );

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

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