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.
This commit is contained in:
pj committed 2026-04-17 23:56:00 +07:00
1 parent 63796fbd42
commit b6b18c3681
4 files changed
+210 -7

No files matched your search

+119
View File
@@ -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
}
+83
View File
@@ -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")
}
}
+2 -2
View File
@@ -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 {
+6 -5
View File
@@ -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)
}
}