Skip to main content

browser_automation_cli/doctor/
mod.rs

1//! Local diagnostics for one-shot installs (no multi-process daemon).
2
3use serde_json::json;
4
5use crate::envelope::print_success_json;
6use crate::install;
7use crate::native::cdp::chrome;
8
9/// Options for local install diagnostics.
10#[derive(Default, Clone, Copy)]
11pub struct DoctorOptions {
12    /// Skip network checks.
13    pub offline: bool,
14    /// Run a reduced check set.
15    pub quick: bool,
16    /// Attempt automatic remediations when supported.
17    pub fix: bool,
18    /// Emit JSON envelope on stdout.
19    pub json: bool,
20}
21
22/// Run doctor checks and return a process exit code (`0` = all pass).
23pub fn run_doctor(opts: DoctorOptions) -> i32 {
24    let mut checks = Vec::new();
25    let mut failed = false;
26
27    let chrome = chrome::find_chrome();
28    match &chrome {
29        Some(p) => checks.push(
30            json!({"id":"chrome","status":"pass","message": format!("found {}", p.display())}),
31        ),
32        None => {
33            failed = true;
34            checks.push(json!({
35                "id":"chrome",
36                "status":"fail",
37                "message":"Chrome/Chromium not found on PATH or cache",
38                "fix":"install Chromium or set executable path"
39            }));
40        }
41    }
42
43    let cache = install::get_browsers_dir();
44    checks.push(json!({
45        "id":"browsers_dir",
46        "status":"info",
47        "message": format!("cache dir {}", cache.display())
48    }));
49
50    if !opts.quick && chrome.is_some() {
51        match crate::browser::block_on_browser(async {
52            let mut s = crate::browser::OneShotSession::launch_headless().await?;
53            let _ = s
54                .goto("about:blank", crate::robots::RobotsPolicy::Ignore)
55                .await?;
56            let _ = s.shutdown().await;
57            Ok::<_, crate::error::CliError>(())
58        }) {
59            Ok(()) => checks
60                .push(json!({"id":"launch","status":"pass","message":"headless about:blank ok"})),
61            Err(e) => {
62                failed = true;
63                checks.push(json!({"id":"launch","status":"fail","message": e.message()}));
64            }
65        }
66    }
67
68    // Lighthouse: external binary only (PATH or XDG); never npm (GAP-A010 / LH-2).
69    let lh_resolved = resolve_lighthouse_for_doctor();
70    let lighthouse_present = lh_resolved.is_some();
71    match &lh_resolved {
72        Some((path, source)) => checks.push(json!({
73            "id": "lighthouse",
74            "status": "pass",
75            "message": format!("found {path} (source={source})"),
76            "lighthouse_present": true,
77            "lighthouse_resolved": path,
78            "lighthouse_source": source,
79        })),
80        None => {
81            let mut fix_msg = None;
82            if opts.fix {
83                fix_msg = Some(
84                    "browser-automation-cli config set lighthouse_path /absolute/path/to/lighthouse",
85                );
86            }
87            let xdg_path = crate::xdg::lighthouse_path_from_config().filter(|s| !s.is_empty());
88            let mut entry = json!({
89                "id": "lighthouse",
90                "status": "info",
91                "message": "lighthouse not on PATH or XDG (optional external binary; e2e may use mock-lighthouse.sh)",
92                "lighthouse_present": false,
93                "lighthouse_resolved": null,
94                "lighthouse_source": "missing",
95                "lighthouse_path_xdg": xdg_path,
96                "suggestion": "browser-automation-cli config set lighthouse_path /absolute/path/to/lighthouse",
97            });
98            if let Some(fix) = fix_msg {
99                entry
100                    .as_object_mut()
101                    .unwrap()
102                    .insert("fix".into(), json!(fix));
103            }
104            checks.push(entry);
105        }
106    }
107
108    // Cache backend health (R-LIVE-3): PING only when XDG cache_backend=redis.
109    checks.push(cache_redis_check());
110
111    let ffmpeg_present = which_bin("ffmpeg");
112    checks.push(json!({
113        "id": "ffmpeg",
114        "status": if ffmpeg_present.is_some() { "pass" } else { "info" },
115        "message": ffmpeg_present
116            .as_deref()
117            .map(|p| format!("found {p}"))
118            .unwrap_or_else(|| "ffmpeg not on PATH (optional for media pipelines)".into()),
119        "ffmpeg_present": ffmpeg_present.is_some(),
120    }));
121
122    // GAP-009: report Job Object capability (real on Windows, stub elsewhere).
123    checks.push(json!({
124        "id": "windows_job_object",
125        "status": "info",
126        "message": crate::win_job::capability_summary(),
127        "supported": crate::win_job::platform_supports_job_objects(),
128    }));
129
130    // Wire offline: skip any future network probes; record mode for agents.
131    if opts.offline {
132        checks.push(json!({
133            "id": "offline",
134            "status": "pass",
135            "message": "offline mode: network probes skipped",
136            "offline": true,
137        }));
138    }
139
140    // Wire fix: when lighthouse missing and --fix, config path is the remediation
141    // (already attached on the lighthouse check). When chrome missing, restate install.
142    if opts.fix && chrome.is_none() {
143        if let Some(c) = checks
144            .iter_mut()
145            .find(|c| c.get("id").and_then(|v| v.as_str()) == Some("chrome"))
146        {
147            if let Some(obj) = c.as_object_mut() {
148                obj.insert(
149                    "fix".into(),
150                    json!("install system Chrome/Chromium or: browser-automation-cli config set chrome_path /path/to/chrome"),
151                );
152            }
153        }
154    }
155
156    let data = json!({
157        "schema_version": 1,
158        "checks": checks,
159        "lighthouse_present": lighthouse_present,
160        "ffmpeg_present": ffmpeg_present.is_some(),
161        "offline": opts.offline,
162        "fix_requested": opts.fix,
163        "ok": !failed,
164    });
165
166    if opts.json {
167        let _ = print_success_json(data);
168    } else {
169        for c in checks {
170            println!(
171                "[{}] {} — {}",
172                c.get("status").and_then(|s| s.as_str()).unwrap_or("?"),
173                c.get("id").and_then(|s| s.as_str()).unwrap_or("?"),
174                c.get("message").and_then(|s| s.as_str()).unwrap_or("")
175            );
176        }
177    }
178
179    if failed {
180        1
181    } else {
182        0
183    }
184}
185
186/// Resolve lighthouse for doctor: XDG path if executable, else PATH.
187fn resolve_lighthouse_for_doctor() -> Option<(String, &'static str)> {
188    if let Some(xdg) = crate::xdg::lighthouse_path_from_config().filter(|s| !s.is_empty()) {
189        let p = std::path::Path::new(&xdg);
190        if p.is_file() {
191            let source = if xdg.contains("mock-lighthouse") {
192                "mock"
193            } else {
194                "xdg"
195            };
196            return Some((xdg, source));
197        }
198    }
199    which_bin("lighthouse").map(|p| (p, "path"))
200}
201
202/// Report redis cache health from XDG only (no product env).
203fn cache_redis_check() -> serde_json::Value {
204    let cfg = crate::xdg::load_config().unwrap_or_default();
205    let backend = cfg
206        .cache_backend
207        .as_deref()
208        .unwrap_or("sqlite")
209        .to_ascii_lowercase();
210    if backend != "redis" {
211        return json!({
212            "id": "cache_redis",
213            "status": "info",
214            "backend": backend,
215            "message": format!("redis not active (cache_backend={backend})"),
216        });
217    }
218    let url = cfg.cache_redis_url.as_deref().unwrap_or("");
219    match crate::cache::RedisCache::connect(url) {
220        Ok(_) => json!({
221            "id": "cache_redis",
222            "status": "pass",
223            "backend": "redis",
224            "message": "redis PING ok (XDG cache_redis_url)",
225        }),
226        Err(e) => json!({
227            "id": "cache_redis",
228            "status": "fail",
229            "backend": "redis",
230            "message": e.message(),
231            "suggestion": "Start redis-server or: browser-automation-cli config set cache_backend sqlite",
232        }),
233    }
234}
235
236fn which_bin(name: &str) -> Option<String> {
237    std::env::var_os("PATH").and_then(|paths| {
238        for dir in std::env::split_paths(&paths) {
239            let candidate = dir.join(name);
240            if candidate.is_file() {
241                return Some(candidate.display().to_string());
242            }
243            #[cfg(windows)]
244            {
245                let with_exe = dir.join(format!("{name}.exe"));
246                if with_exe.is_file() {
247                    return Some(with_exe.display().to_string());
248                }
249            }
250        }
251        None
252    })
253}