roboticus_browser/
manager.rs1use std::path::Path;
2use std::process::Stdio;
3
4use tokio::process::{Child, Command};
5use tracing::{debug, info};
6
7use roboticus_core::config::BrowserConfig;
8use roboticus_core::{Result, RoboticusError};
9
10pub struct BrowserManager {
11 config: BrowserConfig,
12 process: Option<Child>,
13}
14
15impl BrowserManager {
16 pub fn new(config: BrowserConfig) -> Self {
17 Self {
18 config,
19 process: None,
20 }
21 }
22
23 fn find_chrome_executable(&self) -> Option<String> {
27 if let Some(ref path) = self.config.executable_path
28 && Path::new(path).exists()
29 {
30 return Some(path.clone());
31 }
32
33 let candidates = [
34 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
35 "/usr/bin/google-chrome",
36 "/usr/bin/google-chrome-stable",
37 "/usr/bin/chromium",
38 "/usr/bin/chromium-browser",
39 "/snap/bin/chromium",
40 ];
41
42 for candidate in &candidates {
43 if Path::new(candidate).exists() {
44 return Some(candidate.to_string());
45 }
46 }
47
48 None
49 }
50
51 pub async fn start(&mut self) -> Result<()> {
52 if self.process.is_some() {
53 return Ok(());
54 }
55
56 let executable = self
57 .find_chrome_executable()
58 .ok_or_else(|| RoboticusError::Tool {
59 tool: "browser".into(),
60 message: "Chrome/Chromium not found".into(),
61 })?;
62
63 let profile = self.config.profile_dir.display().to_string();
64 let mut args = vec![
65 format!("--remote-debugging-port={}", self.config.cdp_port),
66 format!("--user-data-dir={profile}"),
67 "--no-first-run".to_string(),
68 "--no-default-browser-check".to_string(),
69 "--disable-background-networking".to_string(),
70 "--disable-extensions".to_string(),
71 "--disable-plugins".to_string(),
72 "--disable-popup-blocking".to_string(),
73 "--disable-component-update".to_string(),
74 ];
75
76 if self.config.headless {
77 args.push("--headless=new".to_string());
78 }
79
80 info!(
81 executable_path = %executable,
82 port = self.config.cdp_port,
83 headless = self.config.headless,
84 "starting browser"
85 );
86
87 let child = Command::new(&executable)
88 .args(&args)
89 .stdout(Stdio::null())
90 .stderr(Stdio::null())
91 .spawn()
92 .map_err(|e| RoboticusError::Tool {
93 tool: "browser".into(),
94 message: format!("failed to start Chrome: {e}"),
95 })?;
96
97 self.process = Some(child);
98
99 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
104
105 debug!("browser process spawned, CDP listener may still be initializing");
106 Ok(())
107 }
108
109 pub async fn stop(&mut self) -> Result<()> {
110 if let Some(mut child) = self.process.take() {
111 debug!("stopping browser");
112 child.kill().await.map_err(|e| RoboticusError::Tool {
113 tool: "browser".into(),
114 message: format!("failed to stop Chrome: {e}"),
115 })?;
116 }
117 Ok(())
118 }
119
120 pub fn is_running(&self) -> bool {
121 self.process.is_some()
122 }
123
124 pub fn cdp_port(&self) -> u16 {
125 self.config.cdp_port
126 }
127}
128
129impl Drop for BrowserManager {
130 fn drop(&mut self) {
131 if let Some(mut child) = self.process.take() {
133 let _ = child.start_kill();
134 }
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 #[test]
143 fn manager_defaults() {
144 let mgr = BrowserManager::new(BrowserConfig::default());
145 assert!(!mgr.is_running());
146 assert_eq!(mgr.cdp_port(), 9222);
147 }
148
149 #[test]
150 fn find_chrome_with_explicit_path() {
151 let config = BrowserConfig {
152 executable_path: Some("/usr/bin/false".into()),
153 ..Default::default()
154 };
155 let mgr = BrowserManager::new(config);
156 let found = mgr.find_chrome_executable();
157 assert!(found.is_some());
158 assert_eq!(found.unwrap(), "/usr/bin/false");
159 }
160
161 #[test]
162 fn find_chrome_explicit_nonexistent() {
163 let config = BrowserConfig {
164 executable_path: Some("/nonexistent/chrome".into()),
165 ..Default::default()
166 };
167 let mgr = BrowserManager::new(config);
168 let found = mgr.find_chrome_executable();
169 if let Some(path) = found {
170 assert!(Path::new(&path).exists());
171 }
172 }
173
174 #[test]
175 fn custom_cdp_port() {
176 let config = BrowserConfig {
177 cdp_port: 9333,
178 ..Default::default()
179 };
180 let mgr = BrowserManager::new(config);
181 assert_eq!(mgr.cdp_port(), 9333);
182 }
183
184 #[test]
185 fn is_running_false_initially() {
186 let mgr = BrowserManager::new(BrowserConfig::default());
187 assert!(!mgr.is_running());
188 }
189
190 #[tokio::test]
191 async fn stop_when_not_started_is_ok() {
192 let mut mgr = BrowserManager::new(BrowserConfig::default());
193 let result = mgr.stop().await;
194 assert!(result.is_ok());
195 }
196
197 #[tokio::test]
198 async fn start_with_nonexistent_executable_and_no_system_chrome() {
199 let config = BrowserConfig {
204 executable_path: Some("/nonexistent/path/to/chrome_12345".into()),
205 ..Default::default()
206 };
207 let mut mgr = BrowserManager::new(config);
208 let result = mgr.start().await;
209
210 if let Err(e) = result {
213 let err_str = e.to_string();
214 assert!(
215 err_str.contains("Chrome") || err_str.contains("not found"),
216 "unexpected error: {err_str}"
217 );
218 }
219 }
220
221 #[tokio::test]
222 async fn start_already_running_returns_ok() {
223 let config = BrowserConfig {
227 executable_path: Some("/bin/sleep".into()),
228 ..Default::default()
229 };
230 let mut mgr = BrowserManager::new(config);
231
232 let child = Command::new("/bin/sleep")
234 .arg("10")
235 .stdout(Stdio::null())
236 .stderr(Stdio::null())
237 .spawn()
238 .unwrap();
239 mgr.process = Some(child);
240
241 assert!(mgr.is_running());
242
243 let result = mgr.start().await;
245 assert!(result.is_ok());
246 assert!(mgr.is_running());
247
248 mgr.stop().await.unwrap();
250 }
251
252 #[tokio::test]
253 async fn stop_kills_process() {
254 let child = Command::new("/bin/sleep")
255 .arg("60")
256 .stdout(Stdio::null())
257 .stderr(Stdio::null())
258 .spawn()
259 .unwrap();
260
261 let mut mgr = BrowserManager::new(BrowserConfig::default());
262 mgr.process = Some(child);
263 assert!(mgr.is_running());
264
265 let result = mgr.stop().await;
266 assert!(result.is_ok());
267 assert!(!mgr.is_running());
268 }
269
270 #[test]
271 fn drop_kills_process() {
272 let mut child = std::process::Command::new("/bin/sleep")
273 .arg("60")
274 .stdout(Stdio::null())
275 .stderr(Stdio::null())
276 .spawn()
277 .unwrap();
278 let pid = child.id();
279 let _ = child.kill();
281 let _ = child.wait();
282
283 let rt = tokio::runtime::Runtime::new().unwrap();
286 rt.block_on(async {
287 let tokio_child = Command::new("/bin/sleep")
288 .arg("60")
289 .stdout(Stdio::null())
290 .stderr(Stdio::null())
291 .spawn()
292 .unwrap();
293
294 let mut mgr = BrowserManager::new(BrowserConfig::default());
295 mgr.process = Some(tokio_child);
296 assert!(mgr.is_running());
297 drop(mgr);
299 });
300
301 let _ = std::process::Command::new("kill")
303 .arg(pid.to_string())
304 .status();
305 }
306
307 #[test]
308 fn find_chrome_with_no_explicit_path_and_no_candidates() {
309 let config = BrowserConfig {
314 executable_path: None,
315 ..Default::default()
316 };
317 let mgr = BrowserManager::new(config);
318 let found = mgr.find_chrome_executable();
319 if let Some(ref path) = found {
321 assert!(Path::new(path).exists());
322 }
323 }
324
325 #[tokio::test]
326 async fn start_with_invalid_executable() {
327 let config = BrowserConfig {
330 executable_path: Some("/dev/null".into()),
331 ..Default::default()
332 };
333 let mut mgr = BrowserManager::new(config);
334 let result = mgr.start().await;
335 assert!(result.is_err());
336 let err_str = result.unwrap_err().to_string();
337 assert!(
338 err_str.contains("failed to start Chrome") || err_str.contains("browser"),
339 "unexpected error: {err_str}"
340 );
341 }
342}