car-browser 0.12.0

Browser automation and perception pipeline for Common Agent Runtime
Documentation
//! Page signal detection from accessibility tree.

use super::ui_map::PageSignals;
use crate::models::A11yNode;

/// Detects page-level signals from the accessibility tree.
pub struct SignalDetector;

impl SignalDetector {
    pub fn new() -> Self {
        Self
    }

    /// Detect page signals from accessibility nodes.
    pub fn detect(&self, nodes: &[A11yNode]) -> PageSignals {
        let mut signals = PageSignals::default();

        for node in nodes {
            let role = node.role.to_lowercase();
            let name_lower = node
                .name
                .as_deref()
                .map(|n| n.to_lowercase())
                .unwrap_or_default();

            // Modal/dialog detection
            if matches!(role.as_str(), "dialog" | "alertdialog" | "sheet") {
                signals.modal_present = true;
            }

            // Cookie banner detection
            if name_lower.contains("cookie")
                || name_lower.contains("consent")
                || name_lower.contains("accept all")
            {
                signals.cookie_banner = true;
            }

            // Error banner detection
            if role == "alert" || name_lower.contains("error") || name_lower.contains("failed") {
                signals.error_banner = true;
            }

            // Loading indicator detection
            if role == "progressbar"
                || name_lower.contains("loading")
                || name_lower.contains("spinner")
            {
                signals.loading_indicator = true;
            }

            // Page type hints
            if signals.page_type_hint.is_none() {
                if name_lower.contains("sign in")
                    || name_lower.contains("log in")
                    || name_lower.contains("password")
                {
                    signals.page_type_hint = Some("login".to_string());
                } else if name_lower.contains("checkout")
                    || name_lower.contains("payment")
                    || name_lower.contains("place order")
                {
                    signals.page_type_hint = Some("checkout".to_string());
                } else if name_lower.contains("search results")
                    || name_lower.contains("results for")
                {
                    signals.page_type_hint = Some("search_results".to_string());
                }
            }
        }

        signals
    }
}

impl Default for SignalDetector {
    fn default() -> Self {
        Self::new()
    }
}

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

    #[test]
    fn test_detect_modal() {
        let detector = SignalDetector::new();
        let nodes = vec![A11yNode {
            node_id: "n0".to_string(),
            role: "dialog".to_string(),
            name: Some("Confirm action".to_string()),
            value: None,
            bounds: Bounds::new(100.0, 100.0, 400.0, 300.0),
            children: vec![],
            focusable: false,
            focused: false,
            disabled: false,
        }];

        let signals = detector.detect(&nodes);
        assert!(signals.modal_present);
    }

    #[test]
    fn test_detect_login_page() {
        let detector = SignalDetector::new();
        let nodes = vec![A11yNode {
            node_id: "n0".to_string(),
            role: "button".to_string(),
            name: Some("Sign In".to_string()),
            value: None,
            bounds: Bounds::new(100.0, 100.0, 80.0, 30.0),
            children: vec![],
            focusable: true,
            focused: false,
            disabled: false,
        }];

        let signals = detector.detect(&nodes);
        assert_eq!(signals.page_type_hint.as_deref(), Some("login"));
    }
}