From f850e573f8e461be216f0c9003b574b713e9553d Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 27 Apr 2026 18:36:27 +0700 Subject: [PATCH] feat(testrun): add Preflight() before sidecar/driver setup Mobile platforms get a friendly install hint pointing at `sanderling doctor --platform=

` instead of `fork/exec java: not found`. Web is a no-op. --- internal/testrun/driver.go | 3 ++ internal/testrun/preflight.go | 61 ++++++++++++++++++++++ internal/testrun/preflight_test.go | 82 ++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 internal/testrun/preflight.go create mode 100644 internal/testrun/preflight_test.go diff --git a/internal/testrun/driver.go b/internal/testrun/driver.go index ed49219..935dc4f 100644 --- a/internal/testrun/driver.go +++ b/internal/testrun/driver.go @@ -21,6 +21,9 @@ import ( // a cleanup function. For web, ChromeDriver is used directly; for android/ios // the JVM sidecar is extracted, spawned, and dialed. func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver.DeviceDriver, func(), error) { + if err := Preflight(ctx, options.Platform); err != nil { + return nil, nil, err + } if options.Platform == "web" { d := chrome.New() return d, func() { _ = d.Terminate(context.Background()) }, nil diff --git a/internal/testrun/preflight.go b/internal/testrun/preflight.go new file mode 100644 index 0000000..558f8b4 --- /dev/null +++ b/internal/testrun/preflight.go @@ -0,0 +1,61 @@ +package testrun + +import ( + "context" + "fmt" + "os/exec" +) + +// Preflight runs platform-specific host checks before sidecar/driver setup. +// On failure it returns a wrapped error pointing the user at the matching +// `sanderling doctor --platform=

` command. Web returns nil (no host +// prerequisites beyond a working chromium, which the driver will surface +// itself if missing). +func Preflight(ctx context.Context, platform string) error { + check := preflightCheck + return runPreflight(ctx, platform, check) +} + +type preflightFunc func(name string) error + +func preflightCheck(name string) error { + if _, err := exec.LookPath(name); err != nil { + return fmt.Errorf("%s not found on PATH: %w", name, err) + } + return nil +} + +func runPreflight(ctx context.Context, platform string, check preflightFunc) error { + if err := ctx.Err(); err != nil { + return err + } + switch platform { + case "web": + return nil + case "android": + if err := check("adb"); err != nil { + return preflightFailure("android", err) + } + if err := check("java"); err != nil { + return preflightFailure("android", err) + } + return nil + case "ios": + if err := check("xcrun"); err != nil { + return preflightFailure("ios", err) + } + if err := check("java"); err != nil { + return preflightFailure("ios", err) + } + return nil + default: + return fmt.Errorf("preflight: unknown platform %q", platform) + } +} + +func preflightFailure(platform string, cause error) error { + return fmt.Errorf( + "preflight: %w\nrun `sanderling doctor --platform=%s` for full host-readiness checks", + cause, platform, + ) +} diff --git a/internal/testrun/preflight_test.go b/internal/testrun/preflight_test.go new file mode 100644 index 0000000..d84d033 --- /dev/null +++ b/internal/testrun/preflight_test.go @@ -0,0 +1,82 @@ +package testrun + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestPreflight_WebSkips(t *testing.T) { + called := 0 + check := func(name string) error { + called++ + return nil + } + if err := runPreflight(context.Background(), "web", check); err != nil { + t.Fatalf("web preflight should be no-op, got %v", err) + } + if called != 0 { + t.Errorf("web preflight ran %d binary checks; expected 0", called) + } +} + +func TestPreflight_AndroidNeedsAdbAndJava(t *testing.T) { + cases := []struct { + name string + missing string + wantInErr string + }{ + {name: "missing adb", missing: "adb", wantInErr: "adb"}, + {name: "missing java", missing: "java", wantInErr: "java"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + check := func(name string) error { + if name == testCase.missing { + return errors.New(name + " not found") + } + return nil + } + err := runPreflight(context.Background(), "android", check) + if err == nil || !strings.Contains(err.Error(), testCase.wantInErr) { + t.Fatalf("expected error mentioning %q, got %v", testCase.wantInErr, err) + } + if !strings.Contains(err.Error(), "sanderling doctor --platform=android") { + t.Errorf("error missing doctor hint: %v", err) + } + }) + } +} + +func TestPreflight_iOSNeedsXcrunAndJava(t *testing.T) { + check := func(name string) error { + if name == "xcrun" { + return errors.New("xcrun not found") + } + return nil + } + err := runPreflight(context.Background(), "ios", check) + if err == nil || !strings.Contains(err.Error(), "xcrun") { + t.Fatalf("expected xcrun error, got %v", err) + } + if !strings.Contains(err.Error(), "sanderling doctor --platform=ios") { + t.Errorf("error missing doctor hint: %v", err) + } +} + +func TestPreflight_AllOK(t *testing.T) { + check := func(name string) error { return nil } + for _, platform := range []string{"web", "android", "ios"} { + if err := runPreflight(context.Background(), platform, check); err != nil { + t.Errorf("%s: unexpected error %v", platform, err) + } + } +} + +func TestPreflight_UnknownPlatform(t *testing.T) { + check := func(string) error { return nil } + if err := runPreflight(context.Background(), "fuchsia", check); err == nil { + t.Error("expected error for unknown platform") + } +}