use crate::error::Result;
use crate::observability::views::{
RecorderConfig, SessionTrace, StepRecord, StepResult, TraceMetadata,
};
use std::fs;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct SessionRecorder {
config: RecorderConfig,
steps: Vec<StepRecord>,
started_at: String,
}
impl SessionRecorder {
pub fn new(config: RecorderConfig) -> Self {
Self {
config,
steps: Vec::new(),
started_at: Self::now_iso(),
}
}
pub fn default_recorder() -> Self {
Self::new(RecorderConfig::default())
}
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);
}
}
pub fn steps(&self) -> &[StepRecord] {
&self.steps
}
pub fn len(&self) -> usize {
self.steps.len()
}
pub fn is_empty(&self) -> bool {
self.steps.is_empty()
}
pub fn clear(&mut self) {
self.steps.clear();
self.started_at = Self::now_iso();
}
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(),
}
}
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(())
}
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();
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);
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); }
#[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()));
}
}