#![recursion_limit = "512"]
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;
#[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>
"#;
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"
);
}
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");
}
}
}
#[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;
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();
}
#[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;
}
#[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")
};
wait_for_chrome_exit(pid, "ChromiumBackend drop").await;
}
#[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();
}