browsing 0.1.7

Browser automation: navigate, click, extract, screenshot. Standalone browser control via CDP.
Documentation
//! Tests for error recovery and retry logic

use browsing::browser::cdp::CdpClient;
use browsing::error::BrowsingError;
use std::time::Duration;

const MAX_RETRY_ATTEMPTS: u32 = 3;

#[tokio::test]
async fn test_cdp_retry_logic_transient_error() {
    // This test would verify that transient errors are retried
    // Since we can't easily mock the CDP client, we document the expected behavior

    // Expected behavior:
    // 1. Transient errors (connection lost, no response) should trigger retries
    // 2. Retry should use exponential backoff
    // 3. After MAX_RETRY_ATTEMPTS, should return RetryLimitExceeded error

    let mut client = CdpClient::new("ws://localhost:9222".to_string());

    // Start the client (will fail since no browser is running)
    let result: Result<(), BrowsingError> = client.start().await;

    // Should get a CDP error (connection failed)
    assert!(result.is_err());

    if let Err(BrowsingError::Cdp(msg)) = result {
        assert!(msg.contains("Failed to connect") || msg.contains("CDP"));
    } else {
        panic!("Expected CDP error");
    }
}

#[tokio::test]
async fn test_cdp_retry_limit_exceeded() {
    // Test that retry limit is respected when connection is not established
    let client = CdpClient::new("ws://localhost:9999".to_string());

    // Try to send a command without starting the client
    // This will be treated as a connection error and will be retried
    let result: Result<serde_json::Value, BrowsingError> = client
        .send_command(
            "Page.navigate",
            serde_json::json!({"url": "https://example.com"}),
        )
        .await;

    // Should fail (no browser running)
    assert!(result.is_err());

    // The error should be a CDP error (not RetryLimitExceeded since connection failed immediately)
    match result {
        Err(BrowsingError::Cdp(_)) => {
            // Expected - connection failed without retries
        }
        Err(BrowsingError::RetryLimitExceeded(attempts, _)) => {
            // Also acceptable if retries were attempted
            assert!(attempts <= MAX_RETRY_ATTEMPTS + 1);
        }
        _ => {
            panic!("Expected CDP or RetryLimitExceeded error");
        }
    }
}

#[test]
fn test_backoff_delay_calculation() {
    // Test that backoff delay follows exponential pattern
    const INITIAL_RETRY_DELAY_MS: u64 = 100;
    const MAX_RETRY_DELAY_MS: u64 = 5000;

    let delays: Vec<u64> = (0..5)
        .map(|attempt| {
            let delay = INITIAL_RETRY_DELAY_MS * 2_u64.pow(attempt);
            delay.min(MAX_RETRY_DELAY_MS)
        })
        .collect();

    // Expected: 100, 200, 400, 800, 1600
    assert_eq!(delays[0], 100);
    assert_eq!(delays[1], 200);
    assert_eq!(delays[2], 400);
    assert_eq!(delays[3], 800);
    assert_eq!(delays[4], 1600);

    // Cap at MAX_RETRY_DELAY_MS (5000)
    let delay_10 = INITIAL_RETRY_DELAY_MS * 2_u64.pow(10);
    assert_eq!(delay_10.min(MAX_RETRY_DELAY_MS), MAX_RETRY_DELAY_MS);
}

#[test]
fn test_retryable_error_classification() {
    // Test that errors are correctly classified as retryable or not

    // Retryable errors
    let retryable_errors = vec![
        BrowsingError::Cdp("Failed to send command".to_string()),
        BrowsingError::Cdp("No response received".to_string()),
        BrowsingError::Cdp("connection lost".to_string()),
        BrowsingError::Cdp("WebSocket closed".to_string()),
        BrowsingError::Cdp("Target not found".to_string()),
        BrowsingError::Cdp("Session not found".to_string()),
    ];

    let client = CdpClient::new("ws://localhost:9222".to_string());

    for error in retryable_errors {
        assert!(
            client.is_retryable_error(&error),
            "Error should be retryable: {}",
            error
        );
    }

    // Non-retryable errors
    let non_retryable_errors = vec![
        BrowsingError::Config("Invalid config".to_string()),
        BrowsingError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "test")),
        BrowsingError::Browser("Browser crashed".to_string()),
        BrowsingError::Validation("Invalid input".to_string()),
    ];

    for error in non_retryable_errors {
        assert!(
            !client.is_retryable_error(&error),
            "Error should not be retryable: {}",
            error
        );
    }
}

#[test]
fn test_error_display() {
    // Test that all error types display correctly
    let errors = vec![
        BrowsingError::Config("config error".to_string()),
        BrowsingError::Cdp("CDP error".to_string()),
        BrowsingError::Browser("browser error".to_string()),
        BrowsingError::Agent("agent error".to_string()),
        BrowsingError::Dom("DOM error".to_string()),
        BrowsingError::Tool("tool error".to_string()),
        BrowsingError::Validation("validation error".to_string()),
        BrowsingError::RetryLimitExceeded(3, "test error".to_string()),
        BrowsingError::ConnectionLost("connection lost".to_string()),
    ];

    for error in errors {
        let display = format!("{}", error);
        assert!(!display.is_empty());
        assert!(display.len() > 10);
    }
}

#[test]
fn test_metrics_edge_cases() {
    use browsing::metrics::Metrics;

    // Test with zero operations
    let mut metrics = Metrics::new();
    let summary = metrics.summary();

    assert!(summary.cdp_avg_time.is_none());
    assert!(summary.cdp_success_rate.is_none());
    assert!(summary.dom_avg_time.is_none());
    assert!(summary.dom_avg_elements.is_none());
    assert!(summary.nav_avg_time.is_none());
    assert!(summary.screenshot_avg_time.is_none());

    // Test with single operation
    metrics.record_cdp_command("test", Duration::from_millis(100), true);
    let summary = metrics.summary();

    assert_eq!(summary.cdp_avg_time, Some(Duration::from_millis(100)));
    assert_eq!(summary.cdp_success_rate, Some(100.0));

    // Test with all failures
    let mut fail_metrics = Metrics::new();
    fail_metrics.record_cdp_command("test", Duration::from_millis(100), false);
    fail_metrics.record_cdp_command("test", Duration::from_millis(100), false);
    fail_metrics.record_cdp_command("test", Duration::from_millis(100), false);

    let summary = fail_metrics.summary();
    assert_eq!(summary.cdp_success_rate, Some(0.0));
}

#[tokio::test]
async fn test_xpath_selector_generation() {
    use browsing::dom::serializer::DOMTreeSerializer;
    use browsing::dom::views::{EnhancedDOMTreeNode, NodeType};
    use std::collections::HashMap;

    // Test XPath generation with different element attributes
    let mut node = EnhancedDOMTreeNode {
        node_id: 1,
        backend_node_id: 1,
        node_type: NodeType::ElementNode,
        node_name: "button".to_string(),
        node_value: String::new(),
        attributes: HashMap::new(),
        is_scrollable: None,
        is_visible: None,
        absolute_position: None,
        target_id: String::new(),
        frame_id: None,
        session_id: None,
        content_document: None,
        shadow_root_type: None,
        shadow_roots: None,
        parent_node: None,
        children_nodes: None,
        ax_node: None,
        snapshot_node: None,
        semantic_role: None,
        semantic_confidence: 0.0,
        uuid: "test-uuid".to_string(),
    };

    let serializer = DOMTreeSerializer::new(node.clone());

    // Test with id attribute
    node.attributes
        .insert("id".to_string(), "submit-btn".to_string());
    let xpath = serializer.generate_xpath_selector(&node);
    assert_eq!(xpath, "//*[@id='submit-btn']");

    // Test with name attribute
    node.attributes.clear();
    node.attributes
        .insert("name".to_string(), "username".to_string());
    node.node_name = "input".to_string();
    let xpath = serializer.generate_xpath_selector(&node);
    assert_eq!(xpath, "//input[@name='username']");

    // Test with data-testid
    node.attributes.clear();
    node.attributes
        .insert("data-testid".to_string(), "login-button".to_string());
    node.node_name = "button".to_string();
    let xpath = serializer.generate_xpath_selector(&node);
    assert_eq!(xpath, "//*[@data-testid='login-button']");

    // Test with special characters in id
    node.attributes.clear();
    node.attributes
        .insert("id".to_string(), "test'quote".to_string());
    let xpath = serializer.generate_xpath_selector(&node);
    assert!(xpath.contains("\"test'quote\""));
}

#[test]
fn test_proxy_config_parsing() {
    use browsing::config::parse_proxy_config_from_env;

    // Test with no proxy environment variables
    unsafe { std::env::remove_var("HTTP_PROXY") };
    unsafe { std::env::remove_var("HTTPS_PROXY") };
    unsafe { std::env::remove_var("NO_PROXY") };
    let proxy = parse_proxy_config_from_env();
    assert!(proxy.is_none());

    // Test with HTTP_PROXY
    unsafe { std::env::set_var("HTTP_PROXY", "http://proxy.example.com:8080") };
    let proxy = parse_proxy_config_from_env();
    assert!(proxy.is_some());
    assert_eq!(proxy.unwrap().server, "http://proxy.example.com:8080");

    // Test with HTTP_PROXY (first match is returned)
    unsafe { std::env::set_var("HTTP_PROXY", "http://proxy.example.com:8080") };
    unsafe { std::env::set_var("HTTPS_PROXY", "https://secure.proxy.com:3128") };
    let proxy = parse_proxy_config_from_env();
    assert!(proxy.is_some());
    assert_eq!(proxy.unwrap().server, "http://proxy.example.com:8080");

    // Test with NO_PROXY
    unsafe { std::env::set_var("HTTP_PROXY", "http://proxy.example.com:8080") };
    unsafe { std::env::set_var("NO_PROXY", "localhost,127.0.0.1,.example.com") };
    let proxy = parse_proxy_config_from_env();
    assert!(proxy.is_some());
    assert_eq!(
        proxy.unwrap().bypass,
        Some("localhost,127.0.0.1,.example.com".to_string())
    );

    // Test with authentication
    unsafe { std::env::set_var("HTTP_PROXY", "http://proxy.example.com:8080") };
    unsafe { std::env::set_var("PROXY_USERNAME", "testuser") };
    unsafe { std::env::set_var("PROXY_PASSWORD", "testpass") };
    let proxy = parse_proxy_config_from_env();
    assert!(proxy.is_some());
    let config = proxy.unwrap();
    assert_eq!(config.username, Some("testuser".to_string()));
    assert_eq!(config.password, Some("testpass".to_string()));

    // Clean up
    unsafe { std::env::remove_var("HTTP_PROXY") };
    unsafe { std::env::remove_var("HTTPS_PROXY") };
    unsafe { std::env::remove_var("NO_PROXY") };
    unsafe { std::env::remove_var("PROXY_USERNAME") };
    unsafe { std::env::remove_var("PROXY_PASSWORD") };
}

#[test]
fn test_config_validation() {
    use browsing::config::{AgentConfig, BrowserProfile, Config};

    // Test valid configuration
    let config = Config {
        browser_profile: BrowserProfile {
            headless: Some(true),
            user_data_dir: None,
            allowed_domains: None,
            downloads_path: None,
            proxy: None,
            ..Default::default()
        },
        agent: AgentConfig {
            max_steps: Some(100),
            use_vision: Some(false),
            system_prompt: None,
        },
    };

    assert!(config.validate().is_ok());

    // Test max_steps zero
    let invalid_config = Config {
        agent: AgentConfig {
            max_steps: Some(0),
            ..Default::default()
        },
        ..Default::default()
    };

    let errors = invalid_config.validate();
    assert!(errors.is_err());
    assert!(
        errors
            .unwrap_err()
            .iter()
            .any(|e| e.contains("cannot be zero"))
    );

    // Test empty allowed domains
    let invalid_config = Config {
        browser_profile: BrowserProfile {
            allowed_domains: Some(vec![]),
            ..Default::default()
        },
        ..Default::default()
    };

    let errors = invalid_config.validate();
    assert!(errors.is_err());
    assert!(errors.unwrap_err().iter().any(|e| e.contains("empty")));

    // Test invalid proxy URL
    let invalid_config = Config {
        browser_profile: BrowserProfile {
            proxy: Some(browsing::browser::profile::ProxyConfig {
                server: "invalid-url".to_string(),
                bypass: None,
                username: None,
                password: None,
            }),
            ..Default::default()
        },
        ..Default::default()
    };

    let errors = invalid_config.validate();
    assert!(errors.is_err());
    assert!(
        errors
            .unwrap_err()
            .iter()
            .any(|e| e.contains("Invalid proxy URL"))
    );

    // Test validate_or_error
    let invalid_config = Config {
        agent: AgentConfig {
            max_steps: Some(0),
            ..Default::default()
        },
        ..Default::default()
    };

    assert!(invalid_config.validate_or_error().is_err());
}