package main import ( "context" "flag" "fmt" "io" "os" "os/exec" "path/filepath" "regexp" "strconv" "time" "github.com/chromedp/chromedp" "github.com/priyanshujain/sanderling/internal/sidecar" ) type doctorCheck struct { Name string Run func(ctx context.Context) error } // doctorChecksFor returns the host-readiness checks for a target platform. // "all" returns the union (deduped by name) so the legacy zero-arg `doctor` // behaviour keeps surfacing every platform's prerequisites. func doctorChecksFor(platform string) []doctorCheck { switch platform { case "web": return webChecks() case "android": return androidChecks() case "ios": return iosChecks() case "all": return allChecks() default: return nil } } func webChecks() []doctorCheck { return []doctorCheck{ {Name: "headless chromium can launch", Run: checkChromiumLaunch}, } } func androidChecks() []doctorCheck { return []doctorCheck{ {Name: "adb on PATH", Run: checkExecutableOnPath("adb")}, {Name: "emulator on PATH or under ANDROID_HOME", Run: checkEmulator}, {Name: "java 17+ on PATH", Run: checkJavaVersion}, {Name: "sidecar JAR is real (not placeholder)", Run: checkSidecarJAR}, } } func iosChecks() []doctorCheck { return []doctorCheck{ {Name: "xcrun on PATH", Run: checkExecutableOnPath("xcrun")}, {Name: "simctl on PATH", Run: checkExecutableOnPath("simctl")}, {Name: "java 17+ on PATH", Run: checkJavaVersion}, {Name: "sidecar JAR is real (not placeholder)", Run: checkSidecarJAR}, } } func allChecks() []doctorCheck { seen := map[string]bool{} var combined []doctorCheck for _, group := range [][]doctorCheck{webChecks(), androidChecks(), iosChecks()} { for _, c := range group { if seen[c.Name] { continue } seen[c.Name] = true combined = append(combined, c) } } return combined } // checkChromiumLaunch boots a headless chromium under chromedp's default // allocator, opens a blank tab, and tears down. Confirms the bundled CDP // surface plus a working Chromium binary path. func checkChromiumLaunch(ctx context.Context) error { allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, append(chromedp.DefaultExecAllocatorOptions[:], chromedp.Flag("headless", true), chromedp.Flag("disable-gpu", true), )..., ) defer allocCancel() tabCtx, tabCancel := chromedp.NewContext(allocCtx) defer tabCancel() if err := chromedp.Run(tabCtx, chromedp.Navigate("about:blank")); err != nil { return fmt.Errorf("chromium launch: %w", err) } return nil } func checkSidecarJAR(_ context.Context) error { if sidecar.IsPlaceholder() { return fmt.Errorf("placeholder JAR embedded; run `make sidecar && make sanderling` to embed the real fat JAR") } if sidecar.EmbeddedSize() == 0 { return fmt.Errorf("embedded JAR is empty") } return nil } type doctorOptions struct { platform string } func parseDoctorArgs(args []string, stderr io.Writer) (doctorOptions, error) { flagSet := flag.NewFlagSet("doctor", flag.ContinueOnError) flagSet.SetOutput(stderr) var options doctorOptions flagSet.StringVar(&options.platform, "platform", "all", "target platform: web, android, ios, all") if err := flagSet.Parse(args); err != nil { return doctorOptions{}, err } switch options.platform { case "web", "android", "ios", "all": return options, nil default: return doctorOptions{}, fmt.Errorf("unsupported platform: %q (web, android, ios, all)", options.platform) } } // doctorCheckTimeout bounds a single host-readiness check. Most checks (exec // lookups, file stats, java -version) finish in milliseconds, but // checkChromiumLaunch boots a real browser and can exceed 5s on a cold CI // host - 15s leaves headroom without making real failures feel hung. const doctorCheckTimeout = 15 * time.Second func runDoctorChecks(ctx context.Context, checks []doctorCheck, stdout io.Writer) error { failures := 0 for _, check := range checks { callCtx, cancel := context.WithTimeout(ctx, doctorCheckTimeout) err := check.Run(callCtx) cancel() if err != nil { fmt.Fprintf(stdout, "FAIL %s: %v\n", check.Name, err) failures++ continue } fmt.Fprintf(stdout, "OK %s\n", check.Name) } if failures > 0 { return fmt.Errorf("%d check(s) failed", failures) } return nil } func checkExecutableOnPath(name string) func(context.Context) error { return func(_ context.Context) error { if _, err := exec.LookPath(name); err != nil { return fmt.Errorf("not found: %w", err) } return nil } } func checkEmulator(_ context.Context) error { if _, err := exec.LookPath("emulator"); err == nil { return nil } androidHome := os.Getenv("ANDROID_HOME") if androidHome == "" { androidHome = os.Getenv("ANDROID_SDK_ROOT") } if androidHome == "" { return fmt.Errorf("not on PATH and ANDROID_HOME is unset") } candidate := filepath.Join(androidHome, "emulator", "emulator") if _, err := os.Stat(candidate); err != nil { return fmt.Errorf("not found at %s", candidate) } return nil } var javaVersionPattern = regexp.MustCompile(`(?:java|openjdk)[^"]*"(\d+)(?:\.(\d+))?`) func checkJavaVersion(ctx context.Context) error { if _, err := exec.LookPath("java"); err != nil { return fmt.Errorf("java not found: %w", err) } output, err := exec.CommandContext(ctx, "java", "-version").CombinedOutput() if err != nil { return fmt.Errorf("java -version: %w", err) } major, err := parseJavaMajor(string(output)) if err != nil { return err } if major < 17 { return fmt.Errorf("java major version %d is less than 17", major) } return nil } func parseJavaMajor(versionOutput string) (int, error) { match := javaVersionPattern.FindStringSubmatch(versionOutput) if match == nil { return 0, fmt.Errorf("could not parse java version from %q", firstLine(versionOutput)) } major, err := strconv.Atoi(match[1]) if err != nil { return 0, fmt.Errorf("non-numeric major %q", match[1]) } if major == 1 && len(match) >= 3 && match[2] != "" { minor, err := strconv.Atoi(match[2]) if err == nil { return minor, nil } } return major, nil } func firstLine(text string) string { for index := 0; index < len(text); index++ { if text[index] == '\n' { return text[:index] } } return text }