browsing 0.1.7

Browser automation: navigate, click, extract, screenshot. Standalone browser control via CDP.
Documentation
//! CSS/JS coverage tracking via CDP Profiler domain

use crate::browser::views::{CoverageRange, CoverageResult};
use crate::error::Result;
use std::sync::Arc;

/// Manages CSS and JavaScript code coverage
#[derive(Debug, Clone)]
pub struct CoverageManager {
    client: Arc<crate::browser::cdp::CdpClient>,
}

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

    /// Start collecting JS and CSS coverage.
    /// Enables `Profiler` domain and starts precise coverage.
    pub async fn start_js_coverage(&self) -> Result<()> {
        let _ = self
            .client
            .send_command("Profiler.enable", serde_json::json!({}))
            .await;
        self.client
            .send_command(
                "Profiler.startPreciseCoverage",
                serde_json::json!({
                    "callCount": false,
                    "detailed": true,
                }),
            )
            .await?;
        Ok(())
    }

    /// Start collecting CSS coverage.
    /// Enables `CSS` domain and starts tracking stylesheet usage.
    pub async fn start_css_coverage(&self) -> Result<()> {
        let _ = self
            .client
            .send_command("CSS.enable", serde_json::json!({}))
            .await;
        self.client
            .send_command("CSS.startRuleUsageTracking", serde_json::json!({}))
            .await?;
        Ok(())
    }

    /// Stop JS coverage and return results.
    pub async fn stop_js_coverage(&self) -> Result<Vec<CoverageResult>> {
        let result = self
            .client
            .send_command("Profiler.takePreciseCoverage", serde_json::json!({}))
            .await?;

        let entries = result
            .get("result")
            .and_then(|v| v.as_array())
            .ok_or_else(|| crate::error::BrowsingError::Browser("No coverage result".to_string()))?;

        let mut results = Vec::new();
        for entry in entries {
            let url = entry
                .get("url")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            let text_length = entry
                .get("text")
                .and_then(|v| v.as_str())
                .map(|s| s.len() as u32)
                .unwrap_or(0);

            let ranges: Vec<CoverageRange> = entry
                .get("functions")
                .and_then(|v| v.as_array())
                .map(|funcs| {
                    let mut all_ranges = Vec::new();
                    for func in funcs {
                        if let Some(ranges_arr) = func.get("ranges").and_then(|v| v.as_array()) {
                            for r in ranges_arr {
                                if let (Some(start), Some(end), Some(count)) = (
                                    r.get("startOffset").and_then(|v| v.as_u64()),
                                    r.get("endOffset").and_then(|v| v.as_u64()),
                                    r.get("count").and_then(|v| v.as_u64()),
                                ) {
                                    all_ranges.push(CoverageRange {
                                        start_offset: start as u32,
                                        end_offset: end as u32,
                                        count: count as u32,
                                    });
                                }
                            }
                        }
                    }
                    all_ranges
                })
                .unwrap_or_default();

            results.push(CoverageResult {
                url,
                content_type: "script".to_string(),
                text_length,
                ranges,
            });
        }

        let _ = self
            .client
            .send_command("Profiler.stopPreciseCoverage", serde_json::json!({}))
            .await;
        Ok(results)
    }

    /// Stop CSS coverage and return results.
    pub async fn stop_css_coverage(&self) -> Result<Vec<CoverageResult>> {
        let result = self
            .client
            .send_command("CSS.stopRuleUsageTracking", serde_json::json!({}))
            .await?;

        let usages = result
            .get("ruleUsage")
            .and_then(|v| v.as_array())
            .ok_or_else(|| crate::error::BrowsingError::Browser("No CSS rule usage".to_string()))?;

        let mut results: Vec<CoverageResult> = Vec::new();
        for usage in usages {
            let style_sheet_id = usage
                .get("styleSheetId")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            let start = usage
                .get("startOffset")
                .and_then(|v| v.as_u64())
                .unwrap_or(0) as u32;
            let end = usage
                .get("endOffset")
                .and_then(|v| v.as_u64())
                .unwrap_or(0) as u32;
            let used = usage
                .get("used")
                .and_then(|v| v.as_bool())
                .unwrap_or(false);

            // Find existing entry or create new one
            let entry = results.iter_mut().find(|r| r.url == style_sheet_id);
            if let Some(entry) = entry {
                if used {
                    entry.ranges.push(CoverageRange {
                        start_offset: start,
                        end_offset: end,
                        count: 1,
                    });
                }
            } else {
                let mut ranges = Vec::new();
                if used {
                    ranges.push(CoverageRange {
                        start_offset: start,
                        end_offset: end,
                        count: 1,
                    });
                }
                results.push(CoverageResult {
                    url: style_sheet_id,
                    content_type: "css".to_string(),
                    text_length: 0, // Unknown without additional lookup
                    ranges,
                });
            }
        }

        Ok(results)
    }

    /// Convenience: start both JS and CSS coverage
    pub async fn start_all(&self) -> Result<()> {
        self.start_js_coverage().await?;
        self.start_css_coverage().await?;
        Ok(())
    }

    /// Convenience: stop both and return combined results
    pub async fn stop_all(&self) -> Result<Vec<CoverageResult>> {
        let mut js = self.stop_js_coverage().await?;
        let mut css = self.stop_css_coverage().await?;
        js.append(&mut css);
        Ok(js)
    }
}

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

    #[test]
    fn test_coverage_range_creation() {
        let r = CoverageRange {
            start_offset: 10,
            end_offset: 50,
            count: 1,
        };
        assert_eq!(r.start_offset, 10);
        assert_eq!(r.end_offset, 50);
    }

    #[test]
    fn test_coverage_result_creation() {
        let result = CoverageResult {
            url: "https://example.com/app.js".to_string(),
            content_type: "script".to_string(),
            text_length: 1000,
            ranges: vec![CoverageRange {
                start_offset: 0,
                end_offset: 100,
                count: 1,
            }],
        };
        assert_eq!(result.url, "https://example.com/app.js");
        assert_eq!(result.ranges.len(), 1);
    }

    #[test]
    fn test_coverage_result_serialization() {
        let result = CoverageResult {
            url: "app.js".to_string(),
            content_type: "script".to_string(),
            text_length: 500,
            ranges: vec![],
        };
        let json = serde_json::to_string(&result).unwrap();
        assert!(json.contains("app.js"));
        assert!(json.contains("script"));
    }
}