browser_automation_cli/doctor/
mod.rs1use serde_json::json;
13
14use crate::envelope::print_success_json;
15use crate::install;
16use crate::native::cdp::chrome;
17
18#[derive(Default, Clone, Copy)]
20pub struct DoctorOptions {
21 pub offline: bool,
23 pub quick: bool,
25 pub fix: bool,
27 pub json: bool,
29}
30
31pub fn run_doctor(opts: DoctorOptions) -> i32 {
33 let mut checks = Vec::new();
34 let mut failed = false;
35
36 let host = crate::platform::HostEnvironment::detect();
37 checks.push(json!({
38 "id": "host_environment",
39 "status": "info",
40 "message": host.summary(),
41 "wsl": host.wsl,
42 "container": host.container,
43 "ci": host.ci,
44 "termux": host.termux,
45 "flatpak": host.flatpak,
46 "snap": host.snap,
47 "os": std::env::consts::OS,
48 "arch": std::env::consts::ARCH,
49 }));
50
51 let chrome = chrome::find_chrome();
52 match &chrome {
53 Some(p) => {
54 let sandbox = crate::platform::detect_browser_sandbox(p);
55 let executable = crate::platform::is_executable_file(p);
56 let mut status = "pass";
57 let mut message = format!("found {}", p.display());
58 if sandbox.is_restricted() {
59 status = "warn";
60 message = format!(
61 "found {} (sandbox={}; CDP may fail — prefer system package or config set chrome_path)",
62 p.display(),
63 sandbox.as_str()
64 );
65 } else if !executable {
66 status = "fail";
67 failed = true;
68 message = format!("found {} but not executable", p.display());
69 }
70 checks.push(json!({
71 "id": "chrome",
72 "status": status,
73 "message": message,
74 "path": p.display().to_string(),
75 "sandbox": sandbox.as_str(),
76 "executable": executable,
77 }));
78 }
79 None => {
80 failed = true;
81 checks.push(json!({
82 "id":"chrome",
83 "status":"fail",
84 "message":"Chrome/Chromium not found on PATH, known install paths, or product cache",
85 "fix":"install Chromium/Chrome or: browser-automation-cli config set chrome_path /path/to/chrome",
86 "sandbox": "none",
87 "executable": false,
88 }));
89 }
90 }
91
92 let cache = install::get_browsers_dir();
93 checks.push(json!({
94 "id":"browsers_dir",
95 "status":"info",
96 "message": format!("cache dir {}", cache.display())
97 }));
98
99 if !opts.quick && chrome.is_some() {
100 match crate::browser::block_on_browser(async {
101 let mut s = crate::browser::OneShotSession::launch_headless().await?;
102 let _ = s
103 .goto("about:blank", crate::robots::RobotsPolicy::Ignore)
104 .await?;
105 let _ = s.shutdown().await;
106 Ok::<_, crate::error::CliError>(())
107 }) {
108 Ok(()) => checks
109 .push(json!({"id":"launch","status":"pass","message":"headless about:blank ok"})),
110 Err(e) => {
111 failed = true;
112 checks.push(json!({"id":"launch","status":"fail","message": e.message()}));
113 }
114 }
115 }
116
117 let lh_resolved = resolve_lighthouse_for_doctor();
119 let lighthouse_present = lh_resolved.is_some();
120 match &lh_resolved {
121 Some((path, source)) => checks.push(json!({
122 "id": "lighthouse",
123 "status": "pass",
124 "message": format!("found {path} (source={source})"),
125 "lighthouse_present": true,
126 "lighthouse_resolved": path,
127 "lighthouse_source": source,
128 })),
129 None => {
130 let mut fix_msg = None;
131 if opts.fix {
132 fix_msg = Some(
133 "browser-automation-cli config set lighthouse_path /absolute/path/to/lighthouse",
134 );
135 }
136 let xdg_path = crate::xdg::lighthouse_path_from_config().filter(|s| !s.is_empty());
137 let mut entry = json!({
138 "id": "lighthouse",
139 "status": "info",
140 "message": "lighthouse not on PATH or XDG (optional external binary; e2e may use mock-lighthouse.sh)",
141 "lighthouse_present": false,
142 "lighthouse_resolved": null,
143 "lighthouse_source": "missing",
144 "lighthouse_path_xdg": xdg_path,
145 "suggestion": "browser-automation-cli config set lighthouse_path /absolute/path/to/lighthouse",
146 });
147 if let Some(fix) = fix_msg {
148 entry
149 .as_object_mut()
150 .unwrap()
151 .insert("fix".into(), json!(fix));
152 }
153 checks.push(entry);
154 }
155 }
156
157 checks.push(cache_redis_check());
159
160 let ffmpeg_present = which_bin("ffmpeg");
161 checks.push(json!({
162 "id": "ffmpeg",
163 "status": if ffmpeg_present.is_some() { "pass" } else { "info" },
164 "message": ffmpeg_present
165 .as_deref()
166 .map(|p| format!("found {p}"))
167 .unwrap_or_else(|| "ffmpeg not on PATH (optional for media pipelines)".into()),
168 "ffmpeg_present": ffmpeg_present.is_some(),
169 }));
170
171 checks.push(json!({
173 "id": "windows_job_object",
174 "status": "info",
175 "message": crate::win_job::capability_summary(),
176 "supported": crate::win_job::platform_supports_job_objects(),
177 }));
178
179 if opts.offline {
181 checks.push(json!({
182 "id": "offline",
183 "status": "pass",
184 "message": "offline mode: network probes skipped",
185 "offline": true,
186 }));
187 }
188
189 if opts.fix && chrome.is_none() {
192 if let Some(c) = checks
193 .iter_mut()
194 .find(|c| c.get("id").and_then(|v| v.as_str()) == Some("chrome"))
195 {
196 if let Some(obj) = c.as_object_mut() {
197 obj.insert(
198 "fix".into(),
199 json!("install system Chrome/Chromium or: browser-automation-cli config set chrome_path /path/to/chrome"),
200 );
201 }
202 }
203 }
204
205 checks.push(json!({
207 "id": "concurrency_budget",
208 "status": "info",
209 "message": "bounded fan-out budget (CPU × free RAM × 50% / 64MiB, hard cap 64)",
210 "budget": crate::concurrency::budget_report(),
211 }));
212
213 let residual = crate::residual::residual_disk_report();
217 let residual_status = if residual.live_cli_marker_processes > 0 {
218 failed = true;
219 "fail"
220 } else if residual.cli_marker_dirs > 0 || residual.chromium_tmp_singleton_orphans > 0 {
221 "warn"
222 } else {
223 "pass"
224 };
225 checks.push(json!({
226 "id": "residual_disk",
227 "status": residual_status,
228 "message": format!(
229 "cli_markers={} chromium_singleton_orphans={} scavenge_safe={} live_cli_marker_procs={}",
230 residual.cli_marker_dirs,
231 residual.chromium_tmp_singleton_orphans,
232 residual.scavenge_safe_candidates,
233 residual.live_cli_marker_processes
234 ),
235 "cli_marker_dirs": residual.cli_marker_dirs,
236 "chromium_tmp_singleton_orphans": residual.chromium_tmp_singleton_orphans,
237 "scavenge_safe_candidates": residual.scavenge_safe_candidates,
238 "live_cli_marker_processes": residual.live_cli_marker_processes,
239 }));
240
241 let data = json!({
242 "schema_version": 1,
243 "checks": checks,
244 "lighthouse_present": lighthouse_present,
245 "ffmpeg_present": ffmpeg_present.is_some(),
246 "offline": opts.offline,
247 "fix_requested": opts.fix,
248 "ok": !failed,
249 "concurrency": crate::concurrency::budget_report(),
250 "residual": residual,
251 });
252
253 if opts.json {
254 match print_success_json(data) {
255 Ok(()) => {}
256 Err(e) if e.kind() == crate::error::ErrorKind::BrokenPipe => return 141,
257 Err(_) => {}
258 }
259 } else {
260 for c in checks {
261 let line = format!(
262 "[{}] {} — {}",
263 c.get("status").and_then(|s| s.as_str()).unwrap_or("?"),
264 c.get("id").and_then(|s| s.as_str()).unwrap_or("?"),
265 c.get("message").and_then(|s| s.as_str()).unwrap_or("")
266 );
267 match crate::output::writeln_stdout(line) {
268 Ok(()) => {}
269 Err(e) if e.kind() == crate::error::ErrorKind::BrokenPipe => return 141,
270 Err(_) => {}
271 }
272 }
273 let _ = crate::output::flush_stdout();
274 }
275
276 if failed {
277 1
278 } else {
279 0
280 }
281}
282
283fn resolve_lighthouse_for_doctor() -> Option<(String, &'static str)> {
285 if let Some(xdg) = crate::xdg::lighthouse_path_from_config().filter(|s| !s.is_empty()) {
286 let p = std::path::Path::new(&xdg);
287 if p.is_file() {
288 let source = if xdg.contains("mock-lighthouse") {
289 "mock"
290 } else {
291 "xdg"
292 };
293 return Some((xdg, source));
294 }
295 }
296 which_bin("lighthouse").map(|p| (p, "path"))
297}
298
299fn cache_redis_check() -> serde_json::Value {
301 let cfg = crate::xdg::load_config().unwrap_or_default();
302 let backend = cfg
303 .cache_backend
304 .as_deref()
305 .unwrap_or("sqlite")
306 .to_ascii_lowercase();
307 if backend != "redis" {
308 return json!({
309 "id": "cache_redis",
310 "status": "info",
311 "backend": backend,
312 "message": format!("redis not active (cache_backend={backend})"),
313 });
314 }
315 let url = cfg.cache_redis_url.as_deref().unwrap_or("");
316 match crate::cache::RedisCache::connect(url) {
317 Ok(_) => json!({
318 "id": "cache_redis",
319 "status": "pass",
320 "backend": "redis",
321 "message": "redis PING ok (XDG cache_redis_url)",
322 }),
323 Err(e) => json!({
324 "id": "cache_redis",
325 "status": "fail",
326 "backend": "redis",
327 "message": e.message(),
328 "suggestion": "Start redis-server or: browser-automation-cli config set cache_backend sqlite",
329 }),
330 }
331}
332
333fn which_bin(name: &str) -> Option<String> {
334 crate::platform::which_bin(name).map(|p| p.display().to_string())
335}