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