car-browser 0.7.0

Browser automation and perception pipeline for Common Agent Runtime
Documentation
//! Core browser domain types.
//!
//! These types represent the perception boundary: what the AI can see
//! and how it can interact with web content.

use serde::{Deserialize, Serialize};

/// An element in the accessibility tree.
///
/// This is the primary unit of perception. Each node represents something
/// a screen reader (e.g., VoiceOver) would announce to a blind user.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct A11yNode {
    /// Unique identifier for this node within the page.
    pub node_id: String,
    /// ARIA role or inferred role from element type.
    pub role: String,
    /// Accessible name (aria-label or text content).
    pub name: Option<String>,
    /// Current value (for form inputs).
    pub value: Option<String>,
    /// Bounding box in viewport coordinates.
    pub bounds: Bounds,
    /// IDs of child nodes.
    pub children: Vec<String>,
    /// Whether this element can receive focus.
    pub focusable: bool,
    /// Whether this element currently has focus.
    pub focused: bool,
    /// Whether this element is disabled.
    pub disabled: bool,
}

/// Rectangle bounds in viewport coordinates.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Bounds {
    pub x: f64,
    pub y: f64,
    pub width: f64,
    pub height: f64,
}

impl Bounds {
    pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
        Self {
            x,
            y,
            width,
            height,
        }
    }

    /// Get the center point of the bounds.
    pub fn center(&self) -> (f64, f64) {
        (self.x + self.width / 2.0, self.y + self.height / 2.0)
    }

    /// Check if the bounds contain a point.
    pub fn contains(&self, x: f64, y: f64) -> bool {
        x >= self.x && x <= self.x + self.width && y >= self.y && y <= self.y + self.height
    }

    /// Check if this bounds overlaps with another.
    pub fn overlaps(&self, other: &Bounds) -> bool {
        self.x < other.x + other.width
            && self.x + self.width > other.x
            && self.y < other.y + other.height
            && self.y + self.height > other.y
    }

    /// Compute intersection-over-union with another bounds rectangle.
    pub fn iou(&self, other: &Bounds) -> f64 {
        let x1 = self.x.max(other.x);
        let y1 = self.y.max(other.y);
        let x2 = (self.x + self.width).min(other.x + other.width);
        let y2 = (self.y + self.height).min(other.y + other.height);

        if x2 <= x1 || y2 <= y1 {
            return 0.0;
        }

        let intersection = (x2 - x1) * (y2 - y1);
        let self_area = self.width * self.height;
        let other_area = other.width * other.height;
        let union = self_area + other_area - intersection;

        if union <= 0.0 {
            0.0
        } else {
            intersection / union
        }
    }
}

/// Viewport dimensions.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Viewport {
    pub width: u32,
    pub height: u32,
    pub device_pixel_ratio: f64,
}

/// Keyboard modifier keys.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Modifier {
    Shift,
    Control,
    Alt,
    Meta,
}

/// Conditions to wait for.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum WaitCondition {
    /// Wait for the URL to change.
    UrlChanged,
    /// Wait for specific text to appear in the accessibility tree.
    A11yContainsText { text: String },
    /// Wait for a specific element to exist in the accessibility tree.
    ElementWithName {
        name_contains: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        role: Option<String>,
    },
    /// Wait for page load to complete.
    PageLoaded,
}

/// A cookie to inject into the browser before navigation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CookieParam {
    pub name: String,
    pub value: String,
    pub domain: String,
    #[serde(default = "default_path")]
    pub path: String,
    #[serde(default)]
    pub secure: bool,
    #[serde(default)]
    pub http_only: bool,
    #[serde(default)]
    pub same_site: Option<String>,
}

fn default_path() -> String {
    "/".to_string()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_bounds_center() {
        let bounds = Bounds::new(100.0, 200.0, 50.0, 30.0);
        let (cx, cy) = bounds.center();
        assert_eq!(cx, 125.0);
        assert_eq!(cy, 215.0);
    }

    #[test]
    fn test_bounds_contains() {
        let bounds = Bounds::new(100.0, 200.0, 50.0, 30.0);
        assert!(bounds.contains(125.0, 215.0));
        assert!(!bounds.contains(50.0, 215.0));
    }

    #[test]
    fn test_bounds_iou() {
        let a = Bounds::new(0.0, 0.0, 10.0, 10.0);
        let b = Bounds::new(5.0, 5.0, 10.0, 10.0);
        let iou = a.iou(&b);
        // intersection = 5*5 = 25, union = 100 + 100 - 25 = 175
        assert!((iou - 25.0 / 175.0).abs() < 0.001);
    }

    #[test]
    fn test_bounds_no_overlap() {
        let a = Bounds::new(0.0, 0.0, 10.0, 10.0);
        let b = Bounds::new(20.0, 20.0, 10.0, 10.0);
        assert_eq!(a.iou(&b), 0.0);
        assert!(!a.overlaps(&b));
    }
}