1use thiserror::Error;
7
8pub type Result<T> = std::result::Result<T, BrowserError>;
10
11#[derive(Error, Debug)]
16pub enum BrowserError {
17 #[error("Browser launch failed: {reason}")]
19 LaunchFailed {
20 reason: String,
22 },
23
24 #[error("CDP error during '{operation}': {message}")]
26 CdpError {
27 operation: String,
29 message: String,
31 },
32
33 #[error("Browser pool exhausted (active={active}, max={max})")]
35 PoolExhausted {
36 active: usize,
38 max: usize,
40 },
41
42 #[error("Timeout after {duration_ms}ms during '{operation}'")]
44 Timeout {
45 operation: String,
47 duration_ms: u64,
49 },
50
51 #[error("Navigation to '{url}' failed: {reason}")]
53 NavigationFailed {
54 url: String,
56 reason: String,
58 },
59
60 #[error("Script execution failed: {reason}")]
62 ScriptExecutionFailed {
63 script: String,
65 reason: String,
67 },
68
69 #[error("Browser connection error: {reason}")]
71 ConnectionError {
72 url: String,
74 reason: String,
76 },
77
78 #[error("Configuration error: {0}")]
80 ConfigError(String),
81
82 #[error("I/O error: {0}")]
84 Io(#[from] std::io::Error),
85
86 #[error("Stale node handle (selector: {selector})")]
90 StaleNode {
91 selector: String,
93 },
94
95 #[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}