From 24be8708968cca97ca890ffb5abbf66ad45912d7 Mon Sep 17 00:00:00 2001 From: PJ Date: Tue, 9 Jun 2026 19:52:11 +0530 Subject: [PATCH] feat(test): add --device flag to target a specific Android device by serial --- cmd/sanderling/main.go | 2 ++ cmd/sanderling/test_run.go | 1 + internal/android/android.go | 29 +++++++++++++++++++++++++---- internal/android/android_test.go | 31 +++++++++++++++++++++++++++++++ internal/testrun/driver.go | 3 +++ internal/testrun/testrun.go | 3 ++- 6 files changed, 64 insertions(+), 5 deletions(-) diff --git a/cmd/sanderling/main.go b/cmd/sanderling/main.go index 33d8589..92b4a17 100644 --- a/cmd/sanderling/main.go +++ b/cmd/sanderling/main.go @@ -22,6 +22,7 @@ type testOptions struct { bundleID string platform string avd string + device string iosDevice string iosAppPath string duration time.Duration @@ -52,6 +53,7 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) { flagSet.StringVar(&options.bundleID, "bundle-id", "", "target app bundle ID (required)") flagSet.StringVar(&options.platform, "platform", "android", "target platform: android, ios, web") flagSet.StringVar(&options.avd, "avd", "", "Android AVD name to boot if no device is connected") + flagSet.StringVar(&options.device, "device", "", "Android device serial (from `adb devices`) to target when several are connected") flagSet.StringVar(&options.iosDevice, "ios-device", "", "iOS target: a simulator name/UDID to boot, or a connected device's name, UDID, or CoreDevice id") flagSet.StringVar(&options.iosAppPath, "ios-app-path", "", "path to the .app bundle for iOS clear-state reinstall (simulator: simctl; device: devicectl)") flagSet.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration") diff --git a/cmd/sanderling/test_run.go b/cmd/sanderling/test_run.go index f8407d1..f8eb7f1 100644 --- a/cmd/sanderling/test_run.go +++ b/cmd/sanderling/test_run.go @@ -13,6 +13,7 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer) BundleID: options.bundleID, Platform: options.platform, AVD: options.avd, + Device: options.device, IosDevice: options.iosDevice, IosAppPath: options.iosAppPath, Duration: options.duration, diff --git a/internal/android/android.go b/internal/android/android.go index 784e2f7..a1820ea 100644 --- a/internal/android/android.go +++ b/internal/android/android.go @@ -17,17 +17,22 @@ import ( // EnsureDevice makes sure an Android device is ready for adb commands. // Resolution order: -// - if an adb device is already online, use it; +// - if serial is set, require that exact device to be online; +// - else if an adb device is already online, use it; // - else if avdName is set, validate and boot it; // - else if exactly one AVD exists locally, boot it; // - else fail with a helpful message listing the available AVDs. -func EnsureDevice(ctx context.Context, avdName string, stdout io.Writer) error { +func EnsureDevice(ctx context.Context, serial, avdName string, stdout io.Writer) error { devices, err := listAdbDevices(ctx) if err != nil { return fmt.Errorf("list adb devices: %w", err) } - if len(devices) > 0 { - fmt.Fprintf(stdout, "using connected device: %s\n", devices[0]) + chosen, found, err := pickDevice(serial, devices) + if err != nil { + return err + } + if found { + fmt.Fprintf(stdout, "using connected device: %s\n", chosen) return nil } avds, err := listAVDs(ctx) @@ -191,6 +196,22 @@ func parseAVDList(output string) []string { return avds } +// pickDevice resolves which connected device to drive. A requested serial must +// be online; with no request the first connected device is used, else found is +// false so the caller falls back to booting an AVD. +func pickDevice(requested string, connected []string) (serial string, found bool, err error) { + if requested != "" { + if !slices.Contains(connected, requested) { + return "", false, fmt.Errorf("device %q is not connected (online devices: %s)", requested, strings.Join(connected, ", ")) + } + return requested, true, nil + } + if len(connected) > 0 { + return connected[0], true, nil + } + return "", false, nil +} + func pickAVD(requested string, available []string) (string, error) { if requested != "" { if !slices.Contains(available, requested) { diff --git a/internal/android/android_test.go b/internal/android/android_test.go index 70f950d..3dc0e2d 100644 --- a/internal/android/android_test.go +++ b/internal/android/android_test.go @@ -28,6 +28,37 @@ func TestParseAdbDevices_Empty(t *testing.T) { } } +func TestPickDevice_RequestedOnline(t *testing.T) { + serial, found, err := pickDevice("physical-abc", []string{"emulator-5554", "physical-abc"}) + if err != nil || !found || serial != "physical-abc" { + t.Fatalf("got (%q, %v, %v), want (physical-abc, true, nil)", serial, found, err) + } +} + +func TestPickDevice_RequestedNotConnected(t *testing.T) { + _, found, err := pickDevice("physical-abc", []string{"emulator-5554"}) + if err == nil { + t.Fatal("expected error for a serial that is not connected") + } + if found { + t.Fatal("found must be false when the requested device is absent") + } +} + +func TestPickDevice_NoRequestUsesFirst(t *testing.T) { + serial, found, err := pickDevice("", []string{"emulator-5554", "physical-abc"}) + if err != nil || !found || serial != "emulator-5554" { + t.Fatalf("got (%q, %v, %v), want (emulator-5554, true, nil)", serial, found, err) + } +} + +func TestPickDevice_NoneConnectedFallsBackToAVD(t *testing.T) { + serial, found, err := pickDevice("", nil) + if err != nil || found || serial != "" { + t.Fatalf("got (%q, %v, %v), want (\"\", false, nil)", serial, found, err) + } +} + func TestParseAVDList_DropsInfoLines(t *testing.T) { output := `INFO | Storing crashdata in: /tmp/x Medium_Phone_API_36.0 diff --git a/internal/testrun/driver.go b/internal/testrun/driver.go index c155e22..cfb6f07 100644 --- a/internal/testrun/driver.go +++ b/internal/testrun/driver.go @@ -145,6 +145,9 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver "--port", strconv.Itoa(sidecarPort), "--platform", options.Platform, } + if options.Device != "" { + sidecarArgs = append(sidecarArgs, "--serial", options.Device) + } sidecarCommand := exec.CommandContext(ctx, "java", sidecarArgs...) sidecarCommand.Stdout = stdout sidecarCommand.Stderr = stdout diff --git a/internal/testrun/testrun.go b/internal/testrun/testrun.go index f5714e0..1ffbdba 100644 --- a/internal/testrun/testrun.go +++ b/internal/testrun/testrun.go @@ -26,6 +26,7 @@ type Options struct { BundleID string Platform string AVD string + Device string IosDevice string IosAppPath string Duration time.Duration @@ -46,7 +47,7 @@ type Options struct { func Execute(ctx context.Context, options Options, stdout io.Writer) error { switch options.Platform { case "android": - if err := android.EnsureDevice(ctx, options.AVD, stdout); err != nil { + if err := android.EnsureDevice(ctx, options.Device, options.AVD, stdout); err != nil { return err } case "ios":