|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "fmt" |
| 6 | + "io" |
| 7 | + "runtime" |
| 8 | + "strings" |
| 9 | + "time" |
| 10 | + |
| 11 | + "github.com/chromedp/chromedp" |
| 12 | + "github.com/firede/agent-fetch/internal/fetcher" |
| 13 | +) |
| 14 | + |
| 15 | +const ( |
| 16 | + doctorProbeTimeout = 8 * time.Second |
| 17 | + maxProbeOutputTail = 800 |
| 18 | +) |
| 19 | + |
| 20 | +type doctorStatus string |
| 21 | + |
| 22 | +const ( |
| 23 | + doctorStatusOK doctorStatus = "ok" |
| 24 | + doctorStatusWarn doctorStatus = "warn" |
| 25 | +) |
| 26 | + |
| 27 | +type doctorDeps struct { |
| 28 | + resolveBrowser func(string) (string, []string, error) |
| 29 | + runProbe func(context.Context, string) (string, error) |
| 30 | + goos string |
| 31 | + goarch string |
| 32 | +} |
| 33 | + |
| 34 | +type browserCheck struct { |
| 35 | + status doctorStatus |
| 36 | + candidates []string |
| 37 | + selected string |
| 38 | + probeOutput string |
| 39 | + err error |
| 40 | + guidance []string |
| 41 | +} |
| 42 | + |
| 43 | +func runDoctor(ctx context.Context, out io.Writer, browserPath string) (doctorStatus, error) { |
| 44 | + deps := doctorDeps{ |
| 45 | + resolveBrowser: fetcher.ResolveBrowserExecutablePath, |
| 46 | + runProbe: runBrowserProbe, |
| 47 | + goos: runtime.GOOS, |
| 48 | + goarch: runtime.GOARCH, |
| 49 | + } |
| 50 | + |
| 51 | + check := diagnoseBrowser(ctx, deps, browserPath) |
| 52 | + |
| 53 | + if _, err := fmt.Fprintf(out, "version: %s\n", versionString()); err != nil { |
| 54 | + return doctorStatusWarn, err |
| 55 | + } |
| 56 | + if _, err := fmt.Fprintf(out, "platform: %s/%s\n", deps.goos, deps.goarch); err != nil { |
| 57 | + return doctorStatusWarn, err |
| 58 | + } |
| 59 | + if strings.TrimSpace(browserPath) != "" { |
| 60 | + if _, err := fmt.Fprintf(out, "browser path override: %s\n", browserPath); err != nil { |
| 61 | + return doctorStatusWarn, err |
| 62 | + } |
| 63 | + } |
| 64 | + |
| 65 | + if check.status == doctorStatusOK { |
| 66 | + if _, err := fmt.Fprintln(out, "browser mode: ready"); err != nil { |
| 67 | + return doctorStatusWarn, err |
| 68 | + } |
| 69 | + if _, err := fmt.Fprintf(out, "browser binary: %s\n", check.selected); err != nil { |
| 70 | + return doctorStatusWarn, err |
| 71 | + } |
| 72 | + } else { |
| 73 | + if _, err := fmt.Fprintln(out, "browser mode: not ready"); err != nil { |
| 74 | + return doctorStatusWarn, err |
| 75 | + } |
| 76 | + if len(check.candidates) > 0 { |
| 77 | + if _, err := fmt.Fprintf(out, "browser candidates found: %s\n", strings.Join(check.candidates, ", ")); err != nil { |
| 78 | + return doctorStatusWarn, err |
| 79 | + } |
| 80 | + } else { |
| 81 | + if _, err := fmt.Fprintln(out, "browser candidates found: none"); err != nil { |
| 82 | + return doctorStatusWarn, err |
| 83 | + } |
| 84 | + } |
| 85 | + if check.err != nil { |
| 86 | + if _, err := fmt.Fprintf(out, "probe error: %v\n", check.err); err != nil { |
| 87 | + return doctorStatusWarn, err |
| 88 | + } |
| 89 | + } |
| 90 | + if check.probeOutput != "" { |
| 91 | + if _, err := fmt.Fprintf(out, "probe output (tail): %q\n", check.probeOutput); err != nil { |
| 92 | + return doctorStatusWarn, err |
| 93 | + } |
| 94 | + } |
| 95 | + if len(check.guidance) > 0 { |
| 96 | + if _, err := fmt.Fprintln(out, "recommended fixes:"); err != nil { |
| 97 | + return doctorStatusWarn, err |
| 98 | + } |
| 99 | + for i, line := range check.guidance { |
| 100 | + if _, err := fmt.Fprintf(out, "%d. %s\n", i+1, line); err != nil { |
| 101 | + return doctorStatusWarn, err |
| 102 | + } |
| 103 | + } |
| 104 | + } |
| 105 | + } |
| 106 | + return check.status, nil |
| 107 | +} |
| 108 | + |
| 109 | +func diagnoseBrowser(ctx context.Context, deps doctorDeps, browserPath string) browserCheck { |
| 110 | + selected, found, err := deps.resolveBrowser(browserPath) |
| 111 | + if err != nil { |
| 112 | + return browserCheck{ |
| 113 | + status: doctorStatusWarn, |
| 114 | + candidates: found, |
| 115 | + err: err, |
| 116 | + guidance: browserGuidance(deps.goos, browserPath), |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + probeCtx, cancel := context.WithTimeout(ctx, doctorProbeTimeout) |
| 121 | + out, err := deps.runProbe(probeCtx, selected) |
| 122 | + cancel() |
| 123 | + if err == nil { |
| 124 | + return browserCheck{ |
| 125 | + status: doctorStatusOK, |
| 126 | + candidates: found, |
| 127 | + selected: selected, |
| 128 | + } |
| 129 | + } |
| 130 | + |
| 131 | + lastOut := clampTail(out, maxProbeOutputTail) |
| 132 | + if lastOut == "" { |
| 133 | + lastOut = clampTail(err.Error(), maxProbeOutputTail) |
| 134 | + } |
| 135 | + return browserCheck{ |
| 136 | + status: doctorStatusWarn, |
| 137 | + candidates: found, |
| 138 | + selected: selected, |
| 139 | + probeOutput: lastOut, |
| 140 | + err: err, |
| 141 | + guidance: browserGuidance(deps.goos, browserPath), |
| 142 | + } |
| 143 | +} |
| 144 | + |
| 145 | +func runBrowserProbe(ctx context.Context, binary string) (string, error) { |
| 146 | + allocOpts := append(chromedp.DefaultExecAllocatorOptions[:], |
| 147 | + chromedp.ExecPath(binary), |
| 148 | + chromedp.NoDefaultBrowserCheck, |
| 149 | + chromedp.NoFirstRun, |
| 150 | + ) |
| 151 | + allocCtx, cancelAlloc := chromedp.NewExecAllocator(ctx, allocOpts...) |
| 152 | + defer cancelAlloc() |
| 153 | + |
| 154 | + tabCtx, cancelTab := chromedp.NewContext(allocCtx) |
| 155 | + defer cancelTab() |
| 156 | + |
| 157 | + var finalURL string |
| 158 | + if err := chromedp.Run(tabCtx, |
| 159 | + chromedp.Navigate("about:blank"), |
| 160 | + chromedp.Location(&finalURL), |
| 161 | + ); err != nil { |
| 162 | + return "", err |
| 163 | + } |
| 164 | + return finalURL, nil |
| 165 | +} |
| 166 | + |
| 167 | +func clampTail(s string, max int) string { |
| 168 | + trimmed := strings.TrimSpace(strings.ReplaceAll(s, "\x00", "")) |
| 169 | + if max <= 0 || len(trimmed) <= max { |
| 170 | + return trimmed |
| 171 | + } |
| 172 | + return trimmed[len(trimmed)-max:] |
| 173 | +} |
| 174 | + |
| 175 | +func browserGuidance(goos, browserPath string) []string { |
| 176 | + override := strings.TrimSpace(browserPath) |
| 177 | + if override != "" { |
| 178 | + return []string{ |
| 179 | + "Verify the override path is executable in the current environment: " + override, |
| 180 | + "In containers, ensure the browser binary and required shared libraries are installed in the same image layer.", |
| 181 | + "Run doctor with a known path if needed: agent-fetch --doctor --browser-path /path/to/chrome.", |
| 182 | + } |
| 183 | + } |
| 184 | + |
| 185 | + switch goos { |
| 186 | + case "linux": |
| 187 | + return []string{ |
| 188 | + "Install Chrome/Chromium with your distro package manager and ensure the executable is discoverable.", |
| 189 | + "For containerized workloads, set --browser-path explicitly to the browser binary in the image (for example /usr/bin/chromium).", |
| 190 | + "If startup fails with missing shared libraries, install runtime deps such as libnss3, libatk-bridge2.0-0, libgtk-3-0, libgbm1, and fonts.", |
| 191 | + } |
| 192 | + case "darwin": |
| 193 | + return []string{ |
| 194 | + "Install Google Chrome or Chromium in /Applications, or provide an explicit --browser-path override.", |
| 195 | + "If Chrome is installed in a custom location, run with --browser-path '<full path to Chrome binary>'.", |
| 196 | + } |
| 197 | + case "windows": |
| 198 | + return []string{ |
| 199 | + "Install Google Chrome or Chromium and ensure the executable is discoverable, or pass --browser-path.", |
| 200 | + "Verify the configured path from the same terminal session used to run agent-fetch.", |
| 201 | + } |
| 202 | + } |
| 203 | + return []string{ |
| 204 | + "Install Chrome/Chromium and ensure the executable is discoverable, or pass --browser-path.", |
| 205 | + } |
| 206 | +} |
0 commit comments