browsing 0.1.7

Browser automation: navigate, click, extract, screenshot. Standalone browser control via CDP.
Documentation
//! Viewport and device emulation via CDP Emulation domain

use crate::browser::views::{DeviceMetrics, DevicePreset, ViewportConfig};
use crate::error::Result;
use std::sync::Arc;

/// Manages viewport and device emulation
#[derive(Debug, Clone)]
pub struct ViewportManager {
    client: Arc<crate::browser::cdp::CdpClient>,
}

impl ViewportManager {
    /// Create a new viewport manager
    pub fn new(client: Arc<crate::browser::cdp::CdpClient>) -> Self {
        Self { client }
    }

    /// Set device metrics override (emulate a specific device viewport)
    pub async fn set_device_metrics(&self, config: &ViewportConfig) -> Result<()> {
        let mut params = serde_json::json!({
            "width": config.width,
            "height": config.height,
            "deviceScaleFactor": config.device_scale_factor,
            "mobile": config.mobile,
        });

        if config.touch {
            params["touch"] = serde_json::json!({ "enabled": true });
        }
        if let Some(ref orientation) = config.orientation {
            params["screenOrientation"] = serde_json::json!({
                "type": orientation,
                "angle": if orientation.starts_with("landscape") { 90 } else { 0 }
            });
        }
        if let Some(scale) = config.scale {
            params["scale"] = serde_json::json!(scale);
        }

        self.client
            .send_command("Emulation.setDeviceMetricsOverride", params)
            .await?;
        Ok(())
    }

    /// Clear device metrics override (restore default viewport)
    pub async fn clear_device_metrics(&self) -> Result<()> {
        self.client
            .send_command("Emulation.clearDeviceMetricsOverride", serde_json::json!({}))
            .await?;
        Ok(())
    }

    /// Set touch emulation
    pub async fn set_touch_emulation(&self, enabled: bool) -> Result<()> {
        self.client
            .send_command(
                "Emulation.setTouchEmulationEnabled",
                serde_json::json!({ "enabled": enabled }),
            )
            .await?;
        Ok(())
    }

    /// Set user agent override
    pub async fn set_user_agent(&self, user_agent: &str, accept_language: Option<&str>) -> Result<()> {
        let mut params = serde_json::json!({
            "userAgent": user_agent,
        });
        if let Some(lang) = accept_language {
            params["acceptLanguage"] = serde_json::json!(lang);
        }
        self.client
            .send_command("Emulation.setUserAgentOverride", params)
            .await?;
        Ok(())
    }

    /// Set geolocation override
    pub async fn set_geolocation(&self, latitude: f64, longitude: f64, accuracy: Option<f64>) -> Result<()> {
        let mut params = serde_json::json!({
            "latitude": latitude,
            "longitude": longitude,
        });
        if let Some(acc) = accuracy {
            params["accuracy"] = serde_json::json!(acc);
        }
        self.client
            .send_command("Emulation.setGeolocationOverride", params)
            .await?;
        Ok(())
    }

    /// Clear geolocation override
    pub async fn clear_geolocation(&self) -> Result<()> {
        self.client
            .send_command("Emulation.clearGeolocationOverride", serde_json::json!({}))
            .await?;
        Ok(())
    }

    /// Set timezone override
    pub async fn set_timezone(&self, timezone_id: &str) -> Result<()> {
        self.client
            .send_command(
                "Emulation.setTimezoneOverride",
                serde_json::json!({ "timezoneId": timezone_id }),
            )
            .await?;
        Ok(())
    }

    /// Set emulated media features (prefers-color-scheme, prefers-reduced-motion, etc.)
    pub async fn set_media_features(&self, media_type: Option<&str>, features: Vec<crate::browser::views::MediaFeature>) -> Result<()> {
        let mut params = serde_json::json!({});
        if let Some(mt) = media_type {
            params["media"] = serde_json::json!(mt);
        }
        let features_json: Vec<serde_json::Value> = features
            .into_iter()
            .map(|f| serde_json::json!({ "name": f.name, "value": f.value }))
            .collect();
        params["features"] = serde_json::json!(features_json);

        self.client
            .send_command("Emulation.setEmulatedMedia", params)
            .await?;
        Ok(())
    }

    /// Clear emulated media (restore defaults)
    pub async fn clear_media_features(&self) -> Result<()> {
        self.client
            .send_command(
                "Emulation.setEmulatedMedia",
                serde_json::json!({}),
            )
            .await?;
        Ok(())
    }

    /// Set color scheme (convenience wrapper for prefers-color-scheme)
    pub async fn set_color_scheme(&self, scheme: crate::browser::views::ColorScheme) -> Result<()> {
        let value = match scheme {
            crate::browser::views::ColorScheme::Light => "light",
            crate::browser::views::ColorScheme::Dark => "dark",
            crate::browser::views::ColorScheme::NoPreference => "no-preference",
        };
        self.set_media_features(
            None,
            vec![crate::browser::views::MediaFeature {
                name: "prefers-color-scheme".to_string(),
                value: value.to_string(),
            }],
        )
        .await
    }

    /// Set vision deficiency emulation for accessibility testing
    pub async fn set_vision_deficiency(&self, deficiency: crate::browser::views::VisionDeficiency) -> Result<()> {
        let value = match deficiency {
            crate::browser::views::VisionDeficiency::None => "none",
            crate::browser::views::VisionDeficiency::Achromatopsia => "achromatopsia",
            crate::browser::views::VisionDeficiency::BlurredVision => "blurredVision",
            crate::browser::views::VisionDeficiency::Deuteranopia => "deuteranopia",
            crate::browser::views::VisionDeficiency::Protanopia => "protanopia",
            crate::browser::views::VisionDeficiency::Tritanopia => "tritanopia",
        };
        self.client
            .send_command(
                "Emulation.setEmulatedVisionDeficiency",
                serde_json::json!({ "type": value }),
            )
            .await?;
        Ok(())
    }

    /// Set locale override (affects `navigator.language` and date formatting)
    pub async fn set_locale(&self, locale: &str) -> Result<()> {
        self.client
            .send_command(
                "Emulation.setLocaleOverride",
                serde_json::json!({ "locale": locale }),
            )
            .await?;
        Ok(())
    }

    /// Clear locale override (restore system default)
    pub async fn clear_locale(&self) -> Result<()> {
        self.client
            .send_command(
                "Emulation.setLocaleOverride",
                serde_json::json!({}),
            )
            .await?;
        Ok(())
    }

    /// Override `navigator.languages` and optionally font-family lists via JS.
    /// This ensures font rendering consistency across environments.
    pub async fn override_languages_and_fonts(&self, languages: Vec<String>, font_families: Option<Vec<String>>) -> Result<()> {
        let langs = languages.join(",");
        let mut js = format!(
            "Object.defineProperty(navigator, 'languages', {{ get: () => ['{}'] }});",
            langs.replace(',', "', '")
        );
        if let Some(fonts) = font_families {
            let font_list = fonts.join(", ");
            js.push_str(&format!(
                "document.documentElement.style.fontFamily = '{}';",
                font_list.replace('\'', "\\'")
            ));
        }
        self.client
            .send_command(
                "Runtime.evaluate",
                serde_json::json!({
                    "expression": js,
                    "returnByValue": true,
                }),
            )
            .await?;
        Ok(())
    }

    /// Apply a device preset (viewport + user agent + touch in one call)
    pub async fn preset_device(&self, preset: &DevicePreset) -> Result<()> {
        self.set_device_metrics(&preset.viewport).await?;
        self.set_user_agent(
            &preset.user_agent,
            preset.accept_language.as_deref(),
        )
        .await?;
        if preset.viewport.touch {
            self.set_touch_emulation(true).await?;
        }
        Ok(())
    }

    /// Get current device metrics via JavaScript
    pub async fn get_metrics(&self) -> Result<DeviceMetrics> {
        let result = self
            .client
            .send_command(
                "Runtime.evaluate",
                serde_json::json!({
                    "expression": "({ width: window.innerWidth, height: window.innerHeight, deviceScaleFactor: window.devicePixelRatio, mobile: /Mobi|Android|iPhone/i.test(navigator.userAgent) })",
                    "returnByValue": true,
                }),
            )
            .await?;

        let value = result
            .get("result")
            .and_then(|r| r.get("value"))
            .ok_or_else(|| crate::error::BrowsingError::Browser("No metrics value".to_string()))?;

        Ok(DeviceMetrics {
            width: value.get("width").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
            height: value.get("height").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
            device_scale_factor: value.get("deviceScaleFactor").and_then(|v| v.as_f64()).unwrap_or(1.0),
            mobile: value.get("mobile").and_then(|v| v.as_bool()).unwrap_or(false),
        })
    }
}

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

    #[test]
    fn test_viewport_config_default() {
        let config = ViewportConfig::default();
        assert_eq!(config.width, 1280);
        assert_eq!(config.height, 720);
        assert_eq!(config.device_scale_factor, 1.0);
        assert!(!config.mobile);
        assert!(!config.touch);
    }

    #[test]
    fn test_viewport_config_custom() {
        let config = ViewportConfig {
            width: 375,
            height: 812,
            device_scale_factor: 3.0,
            mobile: true,
            touch: true,
            orientation: Some("portraitPrimary".to_string()),
            scale: None,
        };
        assert_eq!(config.width, 375);
        assert_eq!(config.height, 812);
        assert!(config.mobile);
        assert!(config.touch);
    }

    #[test]
    fn test_device_metrics_serialization() {
        let metrics = DeviceMetrics {
            width: 1920,
            height: 1080,
            device_scale_factor: 2.0,
            mobile: false,
        };
        let json = serde_json::to_string(&metrics).unwrap();
        assert!(json.contains("1920"));
        assert!(json.contains("2.0"));
    }

    #[test]
    fn test_device_preset_iphone_15() {
        let preset = DevicePreset::iphone_15();
        assert_eq!(preset.name, "iPhone 15");
        assert_eq!(preset.viewport.width, 393);
        assert_eq!(preset.viewport.height, 852);
        assert_eq!(preset.viewport.device_scale_factor, 3.0);
        assert!(preset.viewport.mobile);
        assert!(preset.viewport.touch);
        assert!(preset.user_agent.contains("iPhone"));
    }

    #[test]
    fn test_device_preset_by_name() {
        let preset = DevicePreset::by_name("iPhone 15").unwrap();
        assert_eq!(preset.name, "iPhone 15");

        let preset2 = DevicePreset::by_name("iphone15").unwrap();
        assert_eq!(preset2.name, "iPhone 15");

        assert!(DevicePreset::by_name("unknown device").is_none());
    }

    #[test]
    fn test_device_preset_list_names() {
        let names = DevicePreset::list_names();
        assert!(names.contains(&"iPhone 15"));
        assert!(names.contains(&"Desktop 1080p"));
    }

    #[test]
    fn test_device_preset_pixel_8() {
        let preset = DevicePreset::pixel_8();
        assert_eq!(preset.name, "Pixel 8");
        assert!(preset.user_agent.contains("Android"));
        assert!(preset.user_agent.contains("Pixel 8"));
    }

    #[test]
    fn test_color_scheme_serialization() {
        let dark = crate::browser::views::ColorScheme::Dark;
        let json = serde_json::to_string(&dark).unwrap();
        assert_eq!(json, "\"dark\"");

        let light = crate::browser::views::ColorScheme::Light;
        let json = serde_json::to_string(&light).unwrap();
        assert_eq!(json, "\"light\"");
    }

    #[test]
    fn test_media_feature_creation() {
        let feature = crate::browser::views::MediaFeature {
            name: "prefers-color-scheme".to_string(),
            value: "dark".to_string(),
        };
        assert_eq!(feature.name, "prefers-color-scheme");
        assert_eq!(feature.value, "dark");
    }

    #[test]
    fn test_vision_deficiency_serialization() {
        let vd = crate::browser::views::VisionDeficiency::Deuteranopia;
        let json = serde_json::to_string(&vd).unwrap();
        assert_eq!(json, "\"deuteranopia\"");
    }

    #[test]
    fn test_locale_methods_exist() {
        // Verify types compile — actual CDP calls need a browser
        // This test ensures the ViewportManager methods are callable in type-system terms
        let _ = std::any::type_name::<ViewportManager>();
    }
}