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