browsing 0.1.7

Browser automation: navigate, click, extract, screenshot. Standalone browser control via CDP.
Documentation
//! Console log capture via CDP Runtime and Log domains

use crate::browser::views::ConsoleLog;
use crate::error::Result;
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::Mutex;

/// Manages browser console log capture
#[derive(Debug)]
pub struct ConsoleManager {
    client: Arc<crate::browser::cdp::CdpClient>,
    logs: Arc<Mutex<Vec<ConsoleLog>>>,
}

impl ConsoleManager {
    /// Create a new console manager
    pub fn new(client: Arc<crate::browser::cdp::CdpClient>) -> Self {
        Self {
            client,
            logs: Arc::new(Mutex::new(Vec::new())),
        }
    }

    /// Start listening to console events. Call this once after browser starts.
    pub async fn start_listening(&self) -> Result<()> {
        // Enable Runtime and Log domains to receive console events
        let _ = self
            .client
            .send_command("Runtime.enable", Value::Null)
            .await;
        let _ = self
            .client
            .send_command("Log.enable", Value::Null)
            .await;

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

        tokio::spawn(async move {
            while let Ok(event) = rx.recv().await {
                if event.method == "Runtime.consoleAPICalled" {
                    if let Some(log) = parse_console_event(&event.params) {
                        let mut guard = logs.lock().await;
                        guard.push(log);
                        // Keep last 1000 logs
                        if guard.len() > 1000 {
                            guard.remove(0);
                        }
                    }
                } else if event.method == "Log.entryAdded"
                    && let Some(entry) = event.params.get("entry")
                    && let Some(log) = parse_log_entry(entry)
                {
                    let mut guard = logs.lock().await;
                    guard.push(log);
                    if guard.len() > 1000 {
                        guard.remove(0);
                    }
                }
            }
        });

        Ok(())
    }

    /// Get all captured logs (and optionally clear)
    pub async fn get_logs(&self, clear: bool) -> Vec<ConsoleLog> {
        let mut guard = self.logs.lock().await;
        let result = guard.clone();
        if clear {
            guard.clear();
        }
        result
    }

    /// Get logs filtered by level
    pub async fn get_logs_by_level(&self, level: &str) -> Vec<ConsoleLog> {
        let guard = self.logs.lock().await;
        guard
            .iter()
            .filter(|l| l.level.eq_ignore_ascii_case(level))
            .cloned()
            .collect()
    }

    /// Clear all captured logs
    pub async fn clear_logs(&self) {
        self.logs.lock().await.clear();
    }

    /// Get log count
    pub async fn log_count(&self) -> usize {
        self.logs.lock().await.len()
    }
}

fn parse_console_event(params: &Value) -> Option<ConsoleLog> {
    let level = params
        .get("type")
        .and_then(|v| v.as_str())
        .unwrap_or("log")
        .to_string();

    let args = params.get("args")?;
    let text = extract_console_text(args);

    let url = params
        .get("stackTrace")
        .and_then(|st| st.get("callFrames"))
        .and_then(|cf| cf.as_array())
        .and_then(|arr| arr.first())
        .and_then(|frame| frame.get("url"))
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());

    let line = params
        .get("stackTrace")
        .and_then(|st| st.get("callFrames"))
        .and_then(|cf| cf.as_array())
        .and_then(|arr| arr.first())
        .and_then(|frame| frame.get("lineNumber"))
        .and_then(|v| v.as_u64())
        .map(|n| n as u32);

    let column = params
        .get("stackTrace")
        .and_then(|st| st.get("callFrames"))
        .and_then(|cf| cf.as_array())
        .and_then(|arr| arr.first())
        .and_then(|frame| frame.get("columnNumber"))
        .and_then(|v| v.as_u64())
        .map(|n| n as u32);

    let timestamp = params
        .get("timestamp")
        .and_then(|v| v.as_f64())
        .or_else(|| Some(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_secs_f64()));

    Some(ConsoleLog {
        level,
        text,
        url,
        line,
        column,
        timestamp,
    })
}

fn parse_log_entry(entry: &Value) -> Option<ConsoleLog> {
    let level = entry
        .get("level")
        .and_then(|v| v.as_str())
        .unwrap_or("log")
        .to_string();

    let text = entry
        .get("text")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();

    let url = entry
        .get("source")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());

    let line = entry
        .get("lineNumber")
        .and_then(|v| v.as_u64())
        .map(|n| n as u32);

    let column = entry
        .get("columnNumber")
        .and_then(|v| v.as_u64())
        .map(|n| n as u32);

    let timestamp = entry
        .get("timestamp")
        .and_then(|v| v.as_f64())
        .or_else(|| Some(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_secs_f64()));

    Some(ConsoleLog {
        level,
        text,
        url,
        line,
        column,
        timestamp,
    })
}

fn extract_console_text(args: &Value) -> String {
    let arr = match args.as_array() {
        Some(a) => a,
        None => return String::new(),
    };

    let parts: Vec<String> = arr
        .iter()
        .map(|arg| {
            if let Some(s) = arg.get("value").and_then(|v| v.as_str()) {
                s.to_string()
            } else if let Some(s) = arg.as_str() {
                s.to_string()
            } else {
                arg.to_string()
            }
        })
        .collect();

    parts.join(" ")
}

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

    #[test]
    fn test_extract_console_text() {
        let args = serde_json::json!([
            { "type": "string", "value": "hello" },
            { "type": "string", "value": "world" }
        ]);
        assert_eq!(extract_console_text(&args), "hello world");
    }

    #[test]
    fn test_parse_console_event() {
        let params = serde_json::json!({
            "type": "error",
            "args": [
                { "type": "string", "value": "Something failed" }
            ],
            "stackTrace": {
                "callFrames": [
                    { "url": "https://example.com/app.js", "lineNumber": 42, "columnNumber": 10 }
                ]
            },
            "timestamp": 1234567890.0
        });

        let log = parse_console_event(&params).unwrap();
        assert_eq!(log.level, "error");
        assert_eq!(log.text, "Something failed");
        assert_eq!(log.url, Some("https://example.com/app.js".to_string()));
        assert_eq!(log.line, Some(42));
        assert_eq!(log.column, Some(10));
        assert_eq!(log.timestamp, Some(1234567890.0));
    }

    #[test]
    fn test_parse_log_entry() {
        let entry = serde_json::json!({
            "level": "warning",
            "text": "deprecated API",
            "source": "https://example.com/old.js",
            "lineNumber": 5,
            "columnNumber": 1,
            "timestamp": 1234567890.0
        });

        let log = parse_log_entry(&entry).unwrap();
        assert_eq!(log.level, "warning");
        assert_eq!(log.text, "deprecated API");
        assert_eq!(log.url, Some("https://example.com/old.js".to_string()));
        assert_eq!(log.line, Some(5));
        assert_eq!(log.column, Some(1));
    }
}