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    /// One or more fields failed during `#[derive(Extract)]`-driven extraction.
96    ///
97    /// Wraps an [`crate::extract::ExtractionError`] produced by the generated
98    /// `Extractable` implementation.
99    #[cfg(feature = "extract")]
100    #[error("extraction failed: {0}")]
101    ExtractionFailed(#[from] crate::extract::ExtractionError),
102}
103
104impl From<chromiumoxide::error::CdpError> for BrowserError {
105    fn from(err: chromiumoxide::error::CdpError) -> Self {
106        Self::CdpError {
107            operation: "unknown".to_string(),
108            message: err.to_string(),
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn launch_failed_display() {
119        let e = BrowserError::LaunchFailed {
120            reason: "binary not found".to_string(),
121        };
122        assert!(e.to_string().contains("binary not found"));
123    }
124
125    #[test]
126    fn pool_exhausted_display() {
127        let e = BrowserError::PoolExhausted {
128            active: 10,
129            max: 10,
130        };
131        assert!(e.to_string().contains("10"));
132    }
133
134    #[test]
135    fn navigation_failed_includes_url() {
136        let e = BrowserError::NavigationFailed {
137            url: "https://example.com".to_string(),
138            reason: "DNS failure".to_string(),
139        };
140        assert!(e.to_string().contains("example.com"));
141        assert!(e.to_string().contains("DNS failure"));
142    }
143
144    #[test]
145    fn timeout_display() {
146        let e = BrowserError::Timeout {
147            operation: "page.load".to_string(),
148            duration_ms: 30_000,
149        };
150        assert!(e.to_string().contains("30000"));
151    }
152
153    #[test]
154    fn cdp_error_display() {
155        let e = BrowserError::CdpError {
156            operation: "Page.navigate".to_string(),
157            message: "Target closed".to_string(),
158        };
159        let s = e.to_string();
160        assert!(s.contains("Page.navigate"));
161        assert!(s.contains("Target closed"));
162    }
163
164    #[test]
165    fn script_execution_failed_display() {
166        let e = BrowserError::ScriptExecutionFailed {
167            script: "document.title".to_string(),
168            reason: "Execution context destroyed".to_string(),
169        };
170        assert!(e.to_string().contains("Execution context destroyed"));
171    }
172
173    #[test]
174    fn connection_error_display() {
175        let e = BrowserError::ConnectionError {
176            url: "ws://127.0.0.1:9222/json/version".to_string(),
177            reason: "connection refused".to_string(),
178        };
179        let s = e.to_string();
180        assert!(s.contains("connection refused"));
181    }
182
183    #[test]
184    fn config_error_display() {
185        let e = BrowserError::ConfigError("pool.max_size must be >= 1".to_string());
186        assert!(e.to_string().contains("pool.max_size"));
187    }
188
189    #[test]
190    fn io_error_wraps_std() {
191        let io = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
192        let e = BrowserError::Io(io);
193        assert!(e.to_string().contains("file not found"));
194    }
195
196    #[test]
197    fn launch_failed_is_debug_printable() {
198        let e = BrowserError::LaunchFailed {
199            reason: "test".to_string(),
200        };
201        assert!(!format!("{e:?}").is_empty());
202    }
203
204    #[test]
205    fn pool_exhausted_reports_both_counts() {
206        let e = BrowserError::PoolExhausted { active: 5, max: 5 };
207        let s = e.to_string();
208        assert!(s.contains("active=5"));
209        assert!(s.contains("max=5"));
210    }
211
212    #[test]
213    fn stale_node_display_contains_selector() {
214        let e = BrowserError::StaleNode {
215            selector: "[data-ux=\"Section\"]".to_string(),
216        };
217        let s = e.to_string();
218        assert!(s.contains("[data-ux=\"Section\"]"), "display: {s}");
219    }
220
221    #[test]
222    fn stale_node_is_debug_printable() {
223        let e = BrowserError::StaleNode {
224            selector: "div.foo".to_string(),
225        };
226        assert!(!format!("{e:?}").is_empty());
227    }
228
229    #[test]
230    fn node_handle_stale_error_display() {
231        let e = BrowserError::StaleNode {
232            selector: "div.foo".to_string(),
233        };
234        let s = e.to_string().to_lowercase();
235        assert!(
236            s.contains("div.foo"),
237            "display should contain selector: {s}"
238        );
239        assert!(s.contains("stale"), "display should contain 'stale': {s}");
240    }
241}