stygian_browser/
error.rs

1//! Error types for browser automation operations
2//!
3//! All error variants carry structured context so callers can select retry
4//! strategies or surface meaningful diagnostics without string parsing.
5
6use thiserror::Error;
7
8/// Result type alias for browser operations.
9pub type Result<T> = std::result::Result<T, BrowserError>;
10
11/// Errors that can occur during browser automation.
12///
13/// Every variant carries enough structured context to decide on a retry policy
14/// or surface a useful diagnostic message without string-parsing.
15#[derive(Error, Debug)]
16pub enum BrowserError {
17    /// Browser process failed to start.
18    #[error("Browser launch failed: {reason}")]
19    LaunchFailed {
20        /// Human-readable explanation of the failure.
21        reason: String,
22    },
23
24    /// Chrome `DevTools` Protocol (CDP) operation failed.
25    #[error("CDP error during '{operation}': {message}")]
26    CdpError {
27        /// The CDP method or operation that failed.
28        operation: String,
29        /// Error detail from the protocol layer.
30        message: String,
31    },
32
33    /// All pool slots are occupied and the wait timeout elapsed.
34    #[error("Browser pool exhausted (active={active}, max={max})")]
35    PoolExhausted {
36        /// Current number of active browser instances.
37        active: usize,
38        /// Pool capacity limit.
39        max: usize,
40    },
41
42    /// An operation exceeded its configured timeout.
43    #[error("Timeout after {duration_ms}ms during '{operation}'")]
44    Timeout {
45        /// The operation that timed out.
46        operation: String,
47        /// Elapsed time in milliseconds.
48        duration_ms: u64,
49    },
50
51    /// Page navigation failed.
52    #[error("Navigation to '{url}' failed: {reason}")]
53    NavigationFailed {
54        /// Target URL.
55        url: String,
56        /// Failure reason.
57        reason: String,
58    },
59
60    /// JavaScript evaluation failed.
61    #[error("Script execution failed: {reason}")]
62    ScriptExecutionFailed {
63        /// Abbreviated script text (first 120 chars).
64        script: String,
65        /// Error detail.
66        reason: String,
67    },
68
69    /// WebSocket / transport connection error.
70    #[error("Browser connection error: {reason}")]
71    ConnectionError {
72        /// Connection endpoint (ws:// URL or socket path).
73        url: String,
74        /// Failure reason.
75        reason: String,
76    },
77
78    /// Invalid configuration value.
79    #[error("Configuration error: {0}")]
80    ConfigError(String),
81
82    /// Underlying I/O error.
83    #[error("I/O error: {0}")]
84    Io(#[from] std::io::Error),
85}
86
87impl From<chromiumoxide::error::CdpError> for BrowserError {
88    fn from(err: chromiumoxide::error::CdpError) -> Self {
89        Self::CdpError {
90            operation: "unknown".to_string(),
91            message: err.to_string(),
92        }
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn launch_failed_display() {
102        let e = BrowserError::LaunchFailed {
103            reason: "binary not found".to_string(),
104        };
105        assert!(e.to_string().contains("binary not found"));
106    }
107
108    #[test]
109    fn pool_exhausted_display() {
110        let e = BrowserError::PoolExhausted {
111            active: 10,
112            max: 10,
113        };
114        assert!(e.to_string().contains("10"));
115    }
116
117    #[test]
118    fn navigation_failed_includes_url() {
119        let e = BrowserError::NavigationFailed {
120            url: "https://example.com".to_string(),
121            reason: "DNS failure".to_string(),
122        };
123        assert!(e.to_string().contains("example.com"));
124        assert!(e.to_string().contains("DNS failure"));
125    }
126
127    #[test]
128    fn timeout_display() {
129        let e = BrowserError::Timeout {
130            operation: "page.load".to_string(),
131            duration_ms: 30_000,
132        };
133        assert!(e.to_string().contains("30000"));
134    }
135
136    #[test]
137    fn cdp_error_display() {
138        let e = BrowserError::CdpError {
139            operation: "Page.navigate".to_string(),
140            message: "Target closed".to_string(),
141        };
142        let s = e.to_string();
143        assert!(s.contains("Page.navigate"));
144        assert!(s.contains("Target closed"));
145    }
146
147    #[test]
148    fn script_execution_failed_display() {
149        let e = BrowserError::ScriptExecutionFailed {
150            script: "document.title".to_string(),
151            reason: "Execution context destroyed".to_string(),
152        };
153        assert!(e.to_string().contains("Execution context destroyed"));
154    }
155
156    #[test]
157    fn connection_error_display() {
158        let e = BrowserError::ConnectionError {
159            url: "ws://127.0.0.1:9222/json/version".to_string(),
160            reason: "connection refused".to_string(),
161        };
162        let s = e.to_string();
163        assert!(s.contains("connection refused"));
164    }
165
166    #[test]
167    fn config_error_display() {
168        let e = BrowserError::ConfigError("pool.max_size must be >= 1".to_string());
169        assert!(e.to_string().contains("pool.max_size"));
170    }
171
172    #[test]
173    fn io_error_wraps_std() {
174        let io = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
175        let e = BrowserError::Io(io);
176        assert!(e.to_string().contains("file not found"));
177    }
178
179    #[test]
180    fn launch_failed_is_debug_printable() {
181        let e = BrowserError::LaunchFailed {
182            reason: "test".to_string(),
183        };
184        assert!(!format!("{e:?}").is_empty());
185    }
186
187    #[test]
188    fn pool_exhausted_reports_both_counts() {
189        let e = BrowserError::PoolExhausted { active: 5, max: 5 };
190        let s = e.to_string();
191        assert!(s.contains("active=5"));
192        assert!(s.contains("max=5"));
193    }
194}