diff --git a/cmd/sanderling/doctor.go b/cmd/sanderling/doctor.go index 2eb93a7..fa0c183 100644 --- a/cmd/sanderling/doctor.go +++ b/cmd/sanderling/doctor.go @@ -11,6 +11,8 @@ import ( "strconv" "time" + "github.com/chromedp/chromedp" + "github.com/priyanshujain/sanderling/internal/sidecar" ) @@ -19,7 +21,31 @@ type doctorCheck struct { Run func(ctx context.Context) error } -func defaultDoctorChecks() []doctorCheck { +// 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}, @@ -28,6 +54,49 @@ func defaultDoctorChecks() []doctorCheck { } } +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") @@ -38,6 +107,37 @@ func checkSidecarJAR(_ context.Context) error { return nil } +type doctorOptions struct { + platform string +} + +func parseDoctorArgs(args []string) (doctorOptions, error) { + options := doctorOptions{platform: "all"} + for index := 0; index < len(args); index++ { + argument := args[index] + switch { + case argument == "--platform": + index++ + if index >= len(args) { + return doctorOptions{}, fmt.Errorf("--platform requires a value") + } + options.platform = args[index] + case len(argument) > len("--platform=") && argument[:len("--platform=")] == "--platform=": + options.platform = argument[len("--platform="):] + case argument == "-h" || argument == "--help": + return doctorOptions{}, fmt.Errorf("doctor [--platform=web|android|ios|all]") + default: + return doctorOptions{}, fmt.Errorf("unknown doctor argument: %q", argument) + } + } + 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) + } +} + func runDoctorChecks(ctx context.Context, checks []doctorCheck, stdout io.Writer) error { failures := 0 for _, check := range checks { diff --git a/cmd/sanderling/doctor_test.go b/cmd/sanderling/doctor_test.go index 43c157a..d7e70ce 100644 --- a/cmd/sanderling/doctor_test.go +++ b/cmd/sanderling/doctor_test.go @@ -81,3 +81,93 @@ func TestCheckExecutableOnPath_MissingCommand(t *testing.T) { t.Errorf("expected error for missing command") } } + +func TestDoctorChecksFor_Web_OmitsJava(t *testing.T) { + for _, c := range doctorChecksFor("web") { + if strings.Contains(c.Name, "java") || strings.Contains(c.Name, "sidecar") || strings.Contains(c.Name, "adb") { + t.Errorf("web checks should not include %q", c.Name) + } + } + if len(doctorChecksFor("web")) == 0 { + t.Error("web checks empty") + } +} + +func TestDoctorChecksFor_Android_IncludesADB(t *testing.T) { + checks := doctorChecksFor("android") + found := false + for _, c := range checks { + if strings.Contains(c.Name, "adb") { + found = true + } + } + if !found { + t.Errorf("android checks missing adb: %+v", checks) + } +} + +func TestDoctorChecksFor_iOS_IncludesXcrun(t *testing.T) { + checks := doctorChecksFor("ios") + found := false + for _, c := range checks { + if strings.Contains(c.Name, "xcrun") { + found = true + } + } + if !found { + t.Errorf("ios checks missing xcrun: %+v", checks) + } +} + +func TestDoctorChecksFor_All_IsUnion(t *testing.T) { + all := doctorChecksFor("all") + names := map[string]int{} + for _, c := range all { + names[c.Name]++ + } + for _, name := range []string{"adb on PATH", "xcrun on PATH", "headless chromium can launch"} { + if names[name] != 1 { + t.Errorf("expected %q in 'all' exactly once, got %d", name, names[name]) + } + } +} + +func TestDoctorChecksFor_UnknownPlatform(t *testing.T) { + if got := doctorChecksFor("fuchsia"); got != nil { + t.Errorf("expected nil for unknown platform, got %+v", got) + } +} + +func TestParseDoctorArgs_DefaultAll(t *testing.T) { + options, err := parseDoctorArgs(nil) + if err != nil { + t.Fatal(err) + } + if options.platform != "all" { + t.Errorf("default platform: got %q, want all", options.platform) + } +} + +func TestParseDoctorArgs_ExplicitPlatform(t *testing.T) { + for _, form := range [][]string{ + {"--platform", "web"}, + {"--platform=web"}, + } { + options, err := parseDoctorArgs(form) + if err != nil { + t.Fatalf("%v: %v", form, err) + } + if options.platform != "web" { + t.Errorf("%v: got platform=%q, want web", form, options.platform) + } + } +} + +func TestParseDoctorArgs_RejectsUnknown(t *testing.T) { + if _, err := parseDoctorArgs([]string{"--platform=fuchsia"}); err == nil { + t.Error("expected error for unsupported platform") + } + if _, err := parseDoctorArgs([]string{"--bogus"}); err == nil { + t.Error("expected error for unknown argument") + } +} diff --git a/cmd/sanderling/main.go b/cmd/sanderling/main.go index 53b49fa..aac5085 100644 --- a/cmd/sanderling/main.go +++ b/cmd/sanderling/main.go @@ -76,8 +76,16 @@ func runTest(options testOptions, stdout io.Writer) error { return runTestPipeline(ctx, options, stdout) } -func runDoctor(stdout io.Writer) error { - return runDoctorChecks(context.Background(), defaultDoctorChecks(), stdout) +func runDoctor(args []string, stdout io.Writer) error { + options, err := parseDoctorArgs(args) + if err != nil { + return err + } + checks := doctorChecksFor(options.platform) + if checks == nil { + return fmt.Errorf("no checks for platform %q", options.platform) + } + return runDoctorChecks(context.Background(), checks, stdout) } func run(args []string, stdout, stderr io.Writer) error { @@ -99,7 +107,7 @@ func run(args []string, stdout, stderr io.Writer) error { } return runInspect(options, stdout) case "doctor": - return runDoctor(stdout) + return runDoctor(args[2:], stdout) case "version", "-v", "--version": fmt.Fprintln(stdout, Version) return nil