cdp_browser_lite/
error.rs1use std::io;
2use std::path::PathBuf;
3use std::time::Duration;
4
5use cdp_lite::error::CdpError;
6use thiserror::Error;
7
8#[derive(Error, Debug)]
9pub enum BrowserError {
10 #[error(
11 "Chrome executable not found; searched: {searched:?}. Set CHROME_PATH or use chrome_path()"
12 )]
13 ExecutableNotFound { searched: Vec<PathBuf> },
14
15 #[error("failed to spawn '{path}': {source}")]
16 SpawnFailed {
17 path: PathBuf,
18 #[source]
19 source: io::Error,
20 },
21
22 #[error("Chrome exited before opening the debugging port{hint}")]
23 EarlyExit { hint: String },
24
25 #[error("Chrome did not open the DevTools port within {timeout:?}")]
26 StartupTimeout { timeout: Duration },
27
28 #[error("port {port} is in use by a non-Chrome process")]
29 PortConflict { port: u16 },
30
31 #[error("no Chrome CDP endpoint reachable at {host}:{port}")]
32 RemoteUnavailable { host: String, port: u16 },
33
34 #[error("invalid configuration: {0}")]
35 InvalidConfig(String),
36
37 #[error("browser was stopped")]
38 Stopped,
39
40 #[error("profile error: {0}")]
41 Profile(String),
42
43 #[error(transparent)]
44 Cdp(#[from] CdpError),
45
46 #[error(transparent)]
47 Io(#[from] io::Error),
48}
49
50#[cfg(test)]
51mod tests {
52 use super::*;
53
54 fn display(err: &BrowserError) -> String {
55 format!("{err}")
56 }
57
58 #[test]
59 fn executable_not_found_hint_mentions_chrome_path() {
60 let err = BrowserError::ExecutableNotFound {
61 searched: vec![PathBuf::from("/usr/bin/chrome")],
62 };
63 let msg = display(&err);
64 assert!(
65 msg.contains("CHROME_PATH"),
66 "missing CHROME_PATH hint in: {msg}"
67 );
68 assert!(
69 msg.contains("/usr/bin/chrome"),
70 "missing searched path in: {msg}"
71 );
72 }
73
74 #[test]
75 fn spawn_failed_shows_path_and_underlying_io() {
76 let err = BrowserError::SpawnFailed {
77 path: PathBuf::from("/no/such/bin"),
78 source: io::Error::new(io::ErrorKind::NotFound, "nope"),
79 };
80 let msg = display(&err);
81 assert!(
82 msg.contains("/no/such/bin"),
83 "missing binary path in: {msg}"
84 );
85 let source = std::error::Error::source(&err)
86 .map(|s| format!("{s}"))
87 .unwrap_or_default();
88 assert!(
89 source.contains("nope"),
90 "missing underlying IO message in chain: {source}"
91 );
92 }
93
94 #[test]
95 fn early_exit_message_has_hole_for_hint() {
96 let err = BrowserError::EarlyExit {
97 hint: " (close existing Chrome windows)".to_string(),
98 };
99 let msg = display(&err);
100 assert!(msg.contains("Chrome"), "missing Chrome context: {msg}");
101 assert!(
102 msg.contains("debugging port"),
103 "missing port context: {msg}"
104 );
105 assert!(
106 msg.contains("close existing Chrome windows"),
107 "missing hint text: {msg}"
108 );
109 }
110
111 #[test]
112 fn early_exit_with_empty_hint_omits_blank_parentheses() {
113 let err = BrowserError::EarlyExit {
114 hint: String::new(),
115 };
116 let msg = display(&err);
117 assert!(!msg.contains("()"), "unexpected empty parens in: {msg}");
118 }
119
120 #[test]
121 fn startup_timeout_mentions_devtools_port() {
122 let err = BrowserError::StartupTimeout {
123 timeout: Duration::from_secs(10),
124 };
125 let msg = display(&err);
126 assert!(
127 msg.contains("DevTools port"),
128 "missing DevTools context: {msg}"
129 );
130 }
131
132 #[test]
133 fn port_conflict_mentions_port_number() {
134 let err = BrowserError::PortConflict { port: 9222 };
135 let msg = display(&err);
136 assert!(msg.contains("9222"), "missing port: {msg}");
137 }
138
139 #[test]
140 fn remote_unavailable_mentions_host_and_port() {
141 let err = BrowserError::RemoteUnavailable {
142 host: "10.0.0.5".to_string(),
143 port: 9222,
144 };
145 let msg = display(&err);
146 assert!(msg.contains("10.0.0.5"), "missing host: {msg}");
147 assert!(msg.contains("9222"), "missing port: {msg}");
148 }
149
150 #[test]
151 fn invalid_config_carries_message() {
152 let err = BrowserError::InvalidConfig("bad".to_string());
153 let msg = display(&err);
154 assert!(msg.contains("invalid"), "missing 'invalid' word: {msg}");
155 assert!(msg.contains("bad"), "missing nested message: {msg}");
156 }
157
158 #[test]
159 fn stopped_is_self_describing() {
160 let msg = display(&BrowserError::Stopped);
161 assert!(
162 msg.to_lowercase().contains("stopped"),
163 "missing 'stopped' word: {msg}"
164 );
165 }
166
167 #[test]
168 fn profile_carries_message() {
169 let err = BrowserError::Profile("cannot create dir".to_string());
170 let msg = display(&err);
171 assert!(msg.contains("profile"), "missing 'profile' word: {msg}");
172 assert!(msg.contains("cannot create dir"), "missing detail: {msg}");
173 }
174
175 #[test]
176 fn test_error_display_messages_are_actionable() {
177 let cases = [
178 BrowserError::ExecutableNotFound { searched: vec![] },
179 BrowserError::EarlyExit {
180 hint: String::new(),
181 },
182 BrowserError::InvalidConfig("test".into()),
183 BrowserError::Stopped,
184 BrowserError::Profile("test".into()),
185 ];
186 for err in cases {
187 let msg = display(&err);
188 assert!(!msg.trim().is_empty(), "{err:?} produced empty display");
189 }
190 }
191
192 #[test]
193 fn from_io_error() {
194 let io_err = io::Error::other("boom");
195 let err: BrowserError = io_err.into();
196 assert!(matches!(err, BrowserError::Io(_)));
197 }
198
199 #[test]
200 fn from_cdp_error() {
201 let cdp_err = CdpError::Disconnected;
202 let err: BrowserError = cdp_err.into();
203 assert!(matches!(err, BrowserError::Cdp(_)));
204 }
205}