Skip to main content

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    /// The `RemoteObject` reference has been invalidated — the page navigated
87    /// or the DOM node was removed since the [`NodeHandle`][crate::page::NodeHandle]
88    /// was created.
89    #[error("Stale node handle (selector: {selector})")]
90    StaleNode {
91        /// CSS selector that produced the stale handle, for diagnostics.
92        selector: String,
93    },
94}
95
96impl From<chromiumoxide::error::CdpError> for BrowserError {
97    fn from(err: chromiumoxide::error::CdpError) -> Self {
98        Self::CdpError {
99            operation: "unknown".to_string(),
100            message: err.to_string(),
101        }
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn launch_failed_display() {
111        let e = BrowserError::LaunchFailed {
112            reason: "binary not found".to_string(),
113        };
114        assert!(e.to_string().contains("binary not found"));
115    }
116
117    #[test]
118    fn pool_exhausted_display() {
119        let e = BrowserError::PoolExhausted {
120            active: 10,
121            max: 10,
122        };
123        assert!(e.to_string().contains("10"));
124    }
125
126    #[test]
127    fn navigation_failed_includes_url() {
128        let e = BrowserError::NavigationFailed {
129            url: "https://example.com".to_string(),
130            reason: "DNS failure".to_string(),
131        };
132        assert!(e.to_string().contains("example.com"));
133        assert!(e.to_string().contains("DNS failure"));
134    }
135
136    #[test]
137    fn timeout_display() {
138        let e = BrowserError::Timeout {
139            operation: "page.load".to_string(),
140            duration_ms: 30_000,
141        };
142        assert!(e.to_string().contains("30000"));
143    }
144
145    #[test]
146    fn cdp_error_display() {
147        let e = BrowserError::CdpError {
148            operation: "Page.navigate".to_string(),
149            message: "Target closed".to_string(),
150        };
151        let s = e.to_string();
152        assert!(s.contains("Page.navigate"));
153        assert!(s.contains("Target closed"));
154    }
155
156    #[test]
157    fn script_execution_failed_display() {
158        let e = BrowserError::ScriptExecutionFailed {
159            script: "document.title".to_string(),
160            reason: "Execution context destroyed".to_string(),
161        };
162        assert!(e.to_string().contains("Execution context destroyed"));
163    }
164
165    #[test]
166    fn connection_error_display() {
167        let e = BrowserError::ConnectionError {
168            url: "ws://127.0.0.1:9222/json/version".to_string(),
169            reason: "connection refused".to_string(),
170        };
171        let s = e.to_string();
172        assert!(s.contains("connection refused"));
173    }
174
175    #[test]
176    fn config_error_display() {
177        let e = BrowserError::ConfigError("pool.max_size must be >= 1".to_string());
178        assert!(e.to_string().contains("pool.max_size"));
179    }
180
181    #[test]
182    fn io_error_wraps_std() {
183        let io = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
184        let e = BrowserError::Io(io);
185        assert!(e.to_string().contains("file not found"));
186    }
187
188    #[test]
189    fn launch_failed_is_debug_printable() {
190        let e = BrowserError::LaunchFailed {
191            reason: "test".to_string(),
192        };
193        assert!(!format!("{e:?}").is_empty());
194    }
195
196    #[test]
197    fn pool_exhausted_reports_both_counts() {
198        let e = BrowserError::PoolExhausted { active: 5, max: 5 };
199        let s = e.to_string();
200        assert!(s.contains("active=5"));
201        assert!(s.contains("max=5"));
202    }
203
204    #[test]
205    fn stale_node_display_contains_selector() {
206        let e = BrowserError::StaleNode {
207            selector: "[data-ux=\"Section\"]".to_string(),
208        };
209        let s = e.to_string();
210        assert!(s.contains("[data-ux=\"Section\"]"), "display: {s}");
211    }
212
213    #[test]
214    fn stale_node_is_debug_printable() {
215        let e = BrowserError::StaleNode {
216            selector: "div.foo".to_string(),
217        };
218        assert!(!format!("{e:?}").is_empty());
219    }
220}