From b6b18c3681ca697b51871a19a03def6f70da60a9 Mon Sep 17 00:00:00 2001 From: PJ Date: Fri, 17 Apr 2026 23:56:00 +0700 Subject: [PATCH] feat(cli): real doctor subcommand with adb/emulator/java checks Each check is a value so tests can swap in fakes. Java check parses both legacy (1.8) and modern (17+) version strings. Emulator check falls back to ANDROID_HOME/emulator/emulator since the brew cask ships it without putting it on PATH. --- cmd/uatu/doctor.go | 119 ++++++++++++++++++++++++++++++++++++++++ cmd/uatu/doctor_test.go | 83 ++++++++++++++++++++++++++++ cmd/uatu/main.go | 4 +- cmd/uatu/main_test.go | 11 ++-- 4 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 cmd/uatu/doctor.go create mode 100644 cmd/uatu/doctor_test.go diff --git a/cmd/uatu/doctor.go b/cmd/uatu/doctor.go new file mode 100644 index 0000000..db626f8 --- /dev/null +++ b/cmd/uatu/doctor.go @@ -0,0 +1,119 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "time" +) + +type doctorCheck struct { + Name string + Run func(ctx context.Context) error +} + +func defaultDoctorChecks() []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}, + } +} + +func runDoctorChecks(ctx context.Context, checks []doctorCheck, stdout io.Writer) error { + failures := 0 + for _, check := range checks { + callCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + 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 +} diff --git a/cmd/uatu/doctor_test.go b/cmd/uatu/doctor_test.go new file mode 100644 index 0000000..43c157a --- /dev/null +++ b/cmd/uatu/doctor_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" +) + +func TestRunDoctorChecks_AllPass(t *testing.T) { + var stdout bytes.Buffer + checks := []doctorCheck{ + {Name: "always ok", Run: func(context.Context) error { return nil }}, + {Name: "also ok", Run: func(context.Context) error { return nil }}, + } + if err := runDoctorChecks(context.Background(), checks, &stdout); err != nil { + t.Fatalf("expected nil error, got %v", err) + } + output := stdout.String() + if !strings.Contains(output, "OK always ok") || !strings.Contains(output, "OK also ok") { + t.Errorf("expected OK lines, got: %s", output) + } +} + +func TestRunDoctorChecks_ReportsFailures(t *testing.T) { + var stdout bytes.Buffer + checks := []doctorCheck{ + {Name: "ok", Run: func(context.Context) error { return nil }}, + {Name: "broken", Run: func(context.Context) error { return errors.New("boom") }}, + } + err := runDoctorChecks(context.Background(), checks, &stdout) + if err == nil || !strings.Contains(err.Error(), "1 check(s) failed") { + t.Fatalf("expected failure summary, got %v", err) + } + output := stdout.String() + if !strings.Contains(output, "FAIL broken") { + t.Errorf("expected FAIL line, got: %s", output) + } +} + +func TestParseJavaMajor_AcceptsModernFormat(t *testing.T) { + cases := []struct { + input string + major int + }{ + {`openjdk version "17.0.10"` + "\n", 17}, + {`openjdk version "21" 2023-09-19` + "\n", 21}, + {`java version "25.0.2" 2026-01-20` + "\n", 25}, + {`openjdk version "1.8.0_402"` + "\n", 8}, + } + for _, testCase := range cases { + got, err := parseJavaMajor(testCase.input) + if err != nil { + t.Errorf("parseJavaMajor(%q): unexpected error %v", testCase.input, err) + continue + } + if got != testCase.major { + t.Errorf("parseJavaMajor(%q): got %d, want %d", testCase.input, got, testCase.major) + } + } +} + +func TestParseJavaMajor_RejectsUnrecognized(t *testing.T) { + _, err := parseJavaMajor("not java output\n") + if err == nil { + t.Errorf("expected error for unrecognized output") + } +} + +func TestCheckExecutableOnPath_FindsRealCommand(t *testing.T) { + check := checkExecutableOnPath("ls") + if err := check(context.Background()); err != nil { + t.Errorf("ls should be on PATH on macOS/linux, got %v", err) + } +} + +func TestCheckExecutableOnPath_MissingCommand(t *testing.T) { + check := checkExecutableOnPath("definitely-not-a-real-command-xyz-123") + if err := check(context.Background()); err == nil { + t.Errorf("expected error for missing command") + } +} diff --git a/cmd/uatu/main.go b/cmd/uatu/main.go index e5b42b4..7a78251 100644 --- a/cmd/uatu/main.go +++ b/cmd/uatu/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "errors" "flag" "fmt" @@ -66,8 +67,7 @@ func runTest(options testOptions, stdout io.Writer) error { } func runDoctor(stdout io.Writer) error { - fmt.Fprintln(stdout, "uatu doctor (stub): all checks pending implementation") - return nil + return runDoctorChecks(context.Background(), defaultDoctorChecks(), stdout) } func run(args []string, stdout, stderr io.Writer) error { diff --git a/cmd/uatu/main_test.go b/cmd/uatu/main_test.go index d3cc76f..7327ecf 100644 --- a/cmd/uatu/main_test.go +++ b/cmd/uatu/main_test.go @@ -104,11 +104,12 @@ func TestRun_UnknownCommand(t *testing.T) { func TestRun_Doctor(t *testing.T) { var stdout bytes.Buffer - if err := run([]string{"uatu", "doctor"}, &stdout, io.Discard); err != nil { - t.Fatal(err) - } - if !strings.Contains(stdout.String(), "doctor") { - t.Errorf("doctor output missing, got: %q", stdout.String()) + // Doctor may pass or fail depending on host environment; we just want to + // confirm it runs and emits per-check lines. + _ = run([]string{"uatu", "doctor"}, &stdout, io.Discard) + output := stdout.String() + if !strings.Contains(output, "OK") && !strings.Contains(output, "FAIL") { + t.Errorf("doctor output missing OK/FAIL lines: %q", output) } }