browsing 0.1.7

Browser automation: navigate, click, extract, screenshot. Standalone browser control via CDP.
Documentation
//! HAR (HTTP Archive) export via CDP Network domain events

use crate::browser::views::{HarCreator, HarEntry, HarHeader, HarLog, HarRequest, HarResponse};
use crate::error::Result;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;

/// Manages network traffic capture and HAR export
#[derive(Debug, Clone)]
pub struct HarManager {
    client: Arc<crate::browser::cdp::CdpClient>,
    entries: Arc<Mutex<Vec<HarEntry>>>,
    pending: Arc<Mutex<HashMap<String, HarEntry>>>,
    capture_body: bool,
}

impl HarManager {
    /// Create a new HAR manager (no body capture)
    pub fn new(client: Arc<crate::browser::cdp::CdpClient>) -> Self {
        Self {
            client,
            entries: Arc::new(Mutex::new(Vec::new())),
            pending: Arc::new(Mutex::new(HashMap::new())),
            capture_body: false,
        }
    }

    /// Create a new HAR manager with body capture enabled
    pub fn new_with_body(client: Arc<crate::browser::cdp::CdpClient>) -> Self {
        Self {
            client,
            entries: Arc::new(Mutex::new(Vec::new())),
            pending: Arc::new(Mutex::new(HashMap::new())),
            capture_body: true,
        }
    }

    /// Start capturing network traffic.
    /// Enables CDP Network domain and subscribes to request/response events.
    /// If body capture is enabled, fetches response bodies via Network.getResponseBody.
    pub async fn start_capture(&self) -> Result<()> {
        let _ = self
            .client
            .send_command("Network.enable", serde_json::json!({}))
            .await;

        let entries = Arc::clone(&self.entries);
        let pending = Arc::clone(&self.pending);
        let capture_body = self.capture_body;
        let client = Arc::clone(&self.client);
        let mut rx = self.client.subscribe_events().await?;

        tokio::spawn(async move {
            while let Ok(event) = rx.recv().await {
                match event.method.as_str() {
                    "Network.requestWillBeSent" => {
                        if let Some(req_id) = event.params.get("requestId").and_then(|v| v.as_str())
                            && let Some(req) = event.params.get("request") {
                                let method = req.get("method").and_then(|v| v.as_str()).unwrap_or("GET").to_string();
                                let url = req.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
                                let headers = parse_headers(req.get("headers"));
                                let post_data = req.get("postData").and_then(|v| v.as_str()).map(|s| s.to_string());
                                let timestamp = event.params.get("timestamp").and_then(|v| v.as_f64()).unwrap_or(0.0);

                                let entry = HarEntry {
                                    started_date_time: timestamp_to_iso(timestamp),
                                    time: 0.0,
                                    request: HarRequest {
                                        method,
                                        url,
                                        headers,
                                        body_size: -1,
                                        post_data,
                                    },
                                    response: None,
                                    request_id: req_id.to_string(),
                                };
                                pending.lock().await.insert(req_id.to_string(), entry);
                            }
                    }
                    "Network.responseReceived" => {
                        if let Some(req_id) = event.params.get("requestId").and_then(|v| v.as_str())
                            && let Some(resp) = event.params.get("response") {
                                let status = resp.get("status").and_then(|v| v.as_u64()).unwrap_or(0) as u16;
                                let status_text = resp.get("statusText").and_then(|v| v.as_str()).unwrap_or("").to_string();
                                let headers = parse_headers(resp.get("headers"));
                                let encoded_len = resp.get("encodedDataLength").and_then(|v| v.as_i64()).unwrap_or(-1);

                                let mut guard = pending.lock().await;
                                if let Some(entry) = guard.get_mut(req_id) {
                                    entry.response = Some(HarResponse {
                                        status,
                                        status_text,
                                        headers,
                                        body_size: encoded_len,
                                        body: None,
                                    });
                                }
                            }
                    }
                    "Network.loadingFinished" => {
                        if let Some(req_id) = event.params.get("requestId").and_then(|v| v.as_str()) {
                            let timestamp = event.params.get("timestamp").and_then(|v| v.as_f64()).unwrap_or(0.0);

                            let body = if capture_body {
                                match client.send_command(
                                    "Network.getResponseBody",
                                    serde_json::json!({ "requestId": req_id }),
                                ).await {
                                    Ok(result) => {
                                        let body_text = result.get("body").and_then(|v| v.as_str()).map(|s| s.to_string());
                                        let base64 = result.get("base64Encoded").and_then(|v| v.as_bool()).unwrap_or(false);
                                        if base64 {
                                            body_text.and_then(|b| {
                                                base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &b).ok().map(|d| String::from_utf8_lossy(&d).to_string())
                                            })
                                        } else {
                                            body_text
                                        }
                                    }
                                    Err(_) => None,
                                }
                            } else {
                                None
                            };

                            let mut guard = pending.lock().await;
                            if let Some(entry) = guard.remove(req_id) {
                                let mut finished = entry;
                                if let Some(ref mut resp) = finished.response {
                                    resp.body = body;
                                }
                                let start_ts = iso_to_timestamp(&finished.started_date_time).unwrap_or(0.0);
                                if timestamp > start_ts {
                                    finished.time = (timestamp - start_ts) * 1000.0;
                                }
                                entries.lock().await.push(finished);
                            }
                        }
                    }
                    "Network.loadingFailed" => {
                        if let Some(req_id) = event.params.get("requestId").and_then(|v| v.as_str()) {
                            let mut guard = pending.lock().await;
                            if let Some(entry) = guard.remove(req_id) {
                                let mut finished = entry;
                                finished.time = 0.0;
                                entries.lock().await.push(finished);
                            }
                        }
                    }
                    _ => {}
                }
            }
        });

        Ok(())
    }

    /// Export captured traffic as a HAR log object
    pub async fn export_har(&self) -> HarLog {
        let entries = self.entries.lock().await.clone();
        HarLog {
            version: "1.2".to_string(),
            creator: HarCreator {
                name: "browsing".to_string(),
                version: env!("CARGO_PKG_VERSION").to_string(),
            },
            entries,
        }
    }

    /// Export captured traffic as a JSON string
    pub async fn export_json(&self) -> Result<String> {
        let har = self.export_har().await;
        serde_json::to_string_pretty(&har)
            .map_err(|e| crate::error::BrowsingError::Browser(format!("HAR serialize: {e}")))
    }

    /// Get the number of captured entries
    pub async fn entry_count(&self) -> usize {
        self.entries.lock().await.len()
    }

    /// Clear captured entries
    pub async fn clear(&self) {
        self.entries.lock().await.clear();
        self.pending.lock().await.clear();
    }
}

fn parse_headers(headers_val: Option<&serde_json::Value>) -> Vec<HarHeader> {
    let mut result = Vec::new();
    if let Some(obj) = headers_val.and_then(|v| v.as_object()) {
        for (name, value) in obj {
            result.push(HarHeader {
                name: name.clone(),
                value: value.as_str().unwrap_or("").to_string(),
            });
        }
    }
    result
}

fn timestamp_to_iso(ts: f64) -> String {
    // CDP timestamps are seconds since epoch
    let secs = ts as i64;
    let nanos = ((ts - secs as f64) * 1_000_000_000.0) as u32;
    match chrono::DateTime::from_timestamp(secs, nanos) {
        Some(dt) => dt.to_rfc3339(),
        None => "1970-01-01T00:00:00Z".to_string(),
    }
}

fn iso_to_timestamp(iso: &str) -> Option<f64> {
    chrono::DateTime::parse_from_rfc3339(iso)
        .ok()
        .map(|dt| dt.timestamp() as f64 + dt.timestamp_subsec_nanos() as f64 / 1_000_000_000.0)
}

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

    #[test]
    fn test_har_header_creation() {
        let h = HarHeader {
            name: "Content-Type".to_string(),
            value: "text/html".to_string(),
        };
        assert_eq!(h.name, "Content-Type");
        assert_eq!(h.value, "text/html");
    }

    #[test]
    fn test_har_entry_creation() {
        let entry = HarEntry {
            started_date_time: "2024-01-01T00:00:00Z".to_string(),
            time: 123.0,
            request: HarRequest {
                method: "GET".to_string(),
                url: "https://example.com".to_string(),
                headers: vec![],
                body_size: -1,
                post_data: None,
            },
            response: Some(HarResponse {
                status: 200,
                status_text: "OK".to_string(),
                headers: vec![],
                body_size: 1024,
                body: Some("<!DOCTYPE html><html></html>".to_string()),
            }),
            request_id: "req-1".to_string(),
        };
        assert_eq!(entry.request.method, "GET");
        assert_eq!(entry.response.as_ref().unwrap().status, 200);
        assert_eq!(entry.response.as_ref().unwrap().body.as_ref().unwrap(), "<!DOCTYPE html><html></html>");
    }

    #[test]
    fn test_har_log_serialization() {
        let log = HarLog {
            version: "1.2".to_string(),
            creator: HarCreator {
                name: "test".to_string(),
                version: "1.0".to_string(),
            },
            entries: vec![],
        };
        let json = serde_json::to_string(&log).unwrap();
        assert!(json.contains("1.2"));
        assert!(json.contains("test"));
    }

    #[test]
    fn test_timestamp_to_iso_roundtrip() {
        let ts = 1700000000.5;
        let iso = timestamp_to_iso(ts);
        let back = iso_to_timestamp(&iso).unwrap();
        assert!((back - ts).abs() < 0.001);
    }

    #[test]
    fn test_parse_headers_from_json() {
        let json = serde_json::json!({"Content-Type": "text/html", "Content-Length": "42"});
        let headers = parse_headers(Some(&json));
        assert_eq!(headers.len(), 2);
    }
}