browsing 0.1.6

Browser automation: navigate, click, extract, screenshot. Standalone browser control via CDP.
Documentation
//! Session recorder for browser automation observability

use crate::error::Result;
use crate::observability::views::{
    RecorderConfig, SessionTrace, StepRecord, StepResult, TraceMetadata,
};
use std::fs;
use std::path::Path;

/// Records browser automation steps for debugging and replay analysis
#[derive(Debug, Clone)]
pub struct SessionRecorder {
    config: RecorderConfig,
    steps: Vec<StepRecord>,
    started_at: String,
}

impl SessionRecorder {
    /// Create a new session recorder with the given config
    pub fn new(config: RecorderConfig) -> Self {
        Self {
            config,
            steps: Vec::new(),
            started_at: Self::now_iso(),
        }
    }

    /// Create a new session recorder with default config
    pub fn default_recorder() -> Self {
        Self::new(RecorderConfig::default())
    }

    /// Record a step
    pub fn record(&mut self, step: StepRecord) {
        self.steps.push(step);
        if self.steps.len() > self.config.max_steps {
            let excess = self.steps.len() - self.config.max_steps;
            self.steps.drain(0..excess);
        }
    }

    /// Get all recorded steps
    pub fn steps(&self) -> &[StepRecord] {
        &self.steps
    }

    /// Get the number of recorded steps
    pub fn len(&self) -> usize {
        self.steps.len()
    }

    /// Check if any steps have been recorded
    pub fn is_empty(&self) -> bool {
        self.steps.is_empty()
    }

    /// Clear all recorded steps
    pub fn clear(&mut self) {
        self.steps.clear();
        self.started_at = Self::now_iso();
    }

    /// Build a session trace for export
    pub fn build_trace(&self) -> SessionTrace {
        let success_count = self.steps.iter().filter(|s| s.result.success).count();
        let failure_count = self.steps.len() - success_count;
        SessionTrace {
            metadata: TraceMetadata {
                started_at: self.started_at.clone(),
                ended_at: Some(Self::now_iso()),
                total_steps: self.steps.len(),
                success_count,
                failure_count,
                version: env!("CARGO_PKG_VERSION").to_string(),
            },
            steps: self.steps.clone(),
        }
    }

    /// Export the session trace to a JSON file
    pub fn export_to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
        let trace = self.build_trace();
        let json = serde_json::to_string_pretty(&trace)
            .map_err(|e| crate::error::BrowsingError::Tool(format!("JSON serialize: {e}")))?;
        fs::write(path, json)
            .map_err(|e| crate::error::BrowsingError::Tool(format!("Write trace file: {e}")))?;
        Ok(())
    }

    /// Convenience: record a step from action components
    pub fn record_action(
        &mut self,
        step_index: usize,
        action_type: &str,
        params: std::collections::HashMap<String, serde_json::Value>,
        url: Option<String>,
        dom_snapshot: Option<String>,
        result: StepResult,
        duration_ms: u64,
    ) {
        let dom = dom_snapshot.and_then(|d| {
            if self.config.capture_dom {
                if self.config.max_dom_chars > 0 && d.len() > self.config.max_dom_chars {
                    Some(format!("{}...(truncated)", &d[..self.config.max_dom_chars]))
                } else {
                    Some(d)
                }
            } else {
                None
            }
        });
        self.record(StepRecord {
            step_index,
            timestamp: Self::now_iso(),
            action_type: action_type.to_string(),
            params,
            url,
            dom_snapshot: dom,
            result,
            duration_ms,
        });
    }

    fn now_iso() -> String {
        let now = std::time::SystemTime::now();
        let since_epoch = now.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
        // Simple ISO 8601-ish format without external chrono dependency
        let secs = since_epoch.as_secs() as i64;
        let millis = since_epoch.subsec_millis();
        format!("{secs}.{millis:03}Z")
    }
}

impl Default for SessionRecorder {
    fn default() -> Self {
        Self::default_recorder()
    }
}

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

    #[test]
    fn test_recorder_new_empty() {
        let rec = SessionRecorder::default();
        assert!(rec.is_empty());
        assert_eq!(rec.len(), 0);
    }

    #[test]
    fn test_recorder_record_step() {
        let mut rec = SessionRecorder::default();
        let result = StepResult::success(Some("clicked button".to_string()));
        rec.record_action(0, "click", HashMap::new(), None, None, result, 42);
        assert_eq!(rec.len(), 1);
        let step = &rec.steps()[0];
        assert_eq!(step.action_type, "click");
        assert_eq!(step.duration_ms, 42);
        assert!(step.result.success);
    }

    #[test]
    fn test_recorder_max_steps() {
        let mut rec = SessionRecorder::new(RecorderConfig {
            max_steps: 3,
            capture_dom: false,
            max_dom_chars: 0,
        });
        for i in 0..5 {
            rec.record_action(
                i,
                "navigate",
                HashMap::new(),
                None,
                None,
                StepResult::success(None),
                10,
            );
        }
        assert_eq!(rec.len(), 3);
        // Oldest steps dropped; remaining should be steps 2, 3, 4
        assert_eq!(rec.steps()[0].step_index, 2);
    }

    #[test]
    fn test_recorder_clear() {
        let mut rec = SessionRecorder::default();
        rec.record_action(0, "click", HashMap::new(), None, None, StepResult::success(None), 1);
        assert_eq!(rec.len(), 1);
        rec.clear();
        assert!(rec.is_empty());
    }

    #[test]
    fn test_recorder_build_trace() {
        let mut rec = SessionRecorder::default();
        rec.record_action(0, "click", HashMap::new(), None, None, StepResult::success(None), 1);
        rec.record_action(
            1,
            "input",
            HashMap::new(),
            None,
            None,
            StepResult::failure("not found".to_string()),
            2,
        );
        let trace = rec.build_trace();
        assert_eq!(trace.metadata.total_steps, 2);
        assert_eq!(trace.metadata.success_count, 1);
        assert_eq!(trace.metadata.failure_count, 1);
        assert_eq!(trace.steps.len(), 2);
    }

    #[test]
    fn test_recorder_export_to_file() {
        let mut rec = SessionRecorder::default();
        rec.record_action(0, "click", HashMap::new(), None, None, StepResult::success(None), 1);
        let temp = tempfile::NamedTempFile::new().unwrap();
        rec.export_to_file(temp.path()).unwrap();
        let content = std::fs::read_to_string(temp.path()).unwrap();
        assert!(content.contains("click"));
        assert!(content.contains("metadata"));
    }

    #[test]
    fn test_recorder_dom_truncation() {
        let mut rec = SessionRecorder::new(RecorderConfig {
            max_steps: 100,
            capture_dom: true,
            max_dom_chars: 10,
        });
        rec.record_action(
            0,
            "click",
            HashMap::new(),
            None,
            Some("this is a very long dom snapshot".to_string()),
            StepResult::success(None),
            1,
        );
        let dom = rec.steps()[0].dom_snapshot.as_ref().unwrap();
        assert!(dom.contains("...(truncated)"));
        assert!(dom.len() <= 30); // "this is a " + "...(truncated)"
    }

    #[test]
    fn test_recorder_no_dom_when_disabled() {
        let mut rec = SessionRecorder::new(RecorderConfig {
            max_steps: 100,
            capture_dom: false,
            max_dom_chars: 0,
        });
        rec.record_action(
            0,
            "click",
            HashMap::new(),
            None,
            Some("dom".to_string()),
            StepResult::success(None),
            1,
        );
        assert!(rec.steps()[0].dom_snapshot.is_none());
    }

    #[test]
    fn test_step_result_success() {
        let r = StepResult::success(Some("done".to_string()));
        assert!(r.success);
        assert_eq!(r.extracted_content, Some("done".to_string()));
        assert!(r.error.is_none());
    }

    #[test]
    fn test_step_result_failure() {
        let r = StepResult::failure("timeout".to_string());
        assert!(!r.success);
        assert!(r.extracted_content.is_none());
        assert_eq!(r.error, Some("timeout".to_string()));
    }
}