From f5cfc4407e3e5cff7142baec09cb5b207d493da1 Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 8 Jun 2026 23:18:15 +0530 Subject: [PATCH] feat(testrun): route physical-device iOS runs to the device driver Execute resolves a non-simulator iOS target through ios.ResolveDevice into its hardware UDID and CoreDevice id; buildDriver constructs NewDevice via a seam instead of rejecting the device. Generalizes the --ios-device and --ios-app-path help to cover the device path; signing stays env-read, never a flag. --- cmd/sanderling/main.go | 4 +- internal/testrun/driver.go | 79 ++++++++++++++++++- internal/testrun/driver_test.go | 136 +++++++++++++++++++++++++++++--- internal/testrun/testrun.go | 37 +++------ 4 files changed, 211 insertions(+), 45 deletions(-) diff --git a/cmd/sanderling/main.go b/cmd/sanderling/main.go index 16b0088..33d8589 100644 --- a/cmd/sanderling/main.go +++ b/cmd/sanderling/main.go @@ -52,8 +52,8 @@ 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.iosDevice, "ios-device", "", "iOS simulator name or UDID to boot if none is running") - flagSet.StringVar(&options.iosAppPath, "ios-app-path", "", "path to the .app bundle for iOS simulator clear-state reinstall") + 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") flagSet.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)") flagSet.StringVar(&options.output, "output", "./runs", "output directory for traces") diff --git a/internal/testrun/driver.go b/internal/testrun/driver.go index 79860d3..2d31d0b 100644 --- a/internal/testrun/driver.go +++ b/internal/testrun/driver.go @@ -16,14 +16,75 @@ import ( "github.com/priyanshujain/sanderling/internal/driver/chrome" "github.com/priyanshujain/sanderling/internal/driver/ioscompanion" driverSidecar "github.com/priyanshujain/sanderling/internal/driver/sidecar" + "github.com/priyanshujain/sanderling/internal/ios" "github.com/priyanshujain/sanderling/internal/sidecarassets" ) +// iOS resolution seams: package-level so routing tests substitute canned +// resolvers instead of shelling out to xcrun. +var ( + iosResolveTarget = ios.ResolveTarget + iosResolveDevice = ios.ResolveDevice + iosEnsureSimulator = ios.EnsureSimulator +) + +// resolveIOSTarget decides whether a run drives a simulator or a physical +// device and fills the iOS fields on Options. A simulator target is booted if +// needed; a physical-device target is resolved to its hardware UDID and +// CoreDevice id via devicectl. +func resolveIOSTarget(ctx context.Context, options Options, stdout io.Writer) (Options, error) { + udid, isSimulator, err := iosResolveTarget(ctx, options.IosDevice) + if err != nil { + // No query and nothing booted: keep boot-first behavior, then resolve + // the simulator that EnsureSimulator just brought up. + if options.IosDevice == "" { + if bootErr := iosEnsureSimulator(ctx, "", stdout); bootErr != nil { + return options, bootErr + } + udid, isSimulator, err = iosResolveTarget(ctx, "") + } + if err != nil { + return options, err + } + } else if isSimulator { + if err := iosEnsureSimulator(ctx, options.IosDevice, stdout); err != nil { + return options, err + } + udid, isSimulator, err = iosResolveTarget(ctx, options.IosDevice) + if err != nil { + return options, err + } + } + options.iosUDID = udid + options.iosIsSimulator = isSimulator + if !isSimulator { + device, err := iosResolveDevice(ctx, options.IosDevice) + if err != nil { + return options, err + } + options.iosUDID = device.HardwareUDID + options.iosCoreDeviceID = device.CoreDeviceID + fmt.Fprintf(stdout, "using device: %s (udid %s, id %s)\n", device.Name, device.HardwareUDID, device.CoreDeviceID) + } + return options, nil +} + +// newDeviceDriver constructs the physical-device iOS driver and its cleanup. A +// seam so routing tests assert the resolved identifiers reach DeviceOptions +// without building or spawning a real runner. +var newDeviceDriver = func(ctx context.Context, options ioscompanion.DeviceOptions) (driver.DeviceDriver, func(), error) { + d, err := ioscompanion.NewDevice(ctx, options) + if err != nil { + return nil, nil, err + } + return d, d.Close, nil +} + // buildDriver creates the appropriate DeviceDriver for the platform and returns // a cleanup function. For web, ChromeDriver is used directly. An iOS simulator -// is driven by the native simulator companion (no JVM). Android uses the JVM -// sidecar, which is extracted, spawned, and dialed. Physical iOS devices are -// not yet supported. +// is driven by the native simulator companion (no JVM). A physical iOS device +// is driven runner-only over a usbmux tunnel. Android uses the JVM sidecar, +// which 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 @@ -47,7 +108,17 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver } if options.Platform == "ios" { - return nil, nil, fmt.Errorf("physical-device iOS is not yet supported; run against a simulator instead") + d, cleanup, err := newDeviceDriver(ctx, ioscompanion.DeviceOptions{ + HardwareUDID: options.iosUDID, + CoreDeviceID: options.iosCoreDeviceID, + BundleID: options.BundleID, + AppPath: options.IosAppPath, + Output: stdout, + }) + if err != nil { + return nil, nil, fmt.Errorf("ios device driver: %w", err) + } + return d, cleanup, nil } // Android uses the JVM sidecar, which requires java. diff --git a/internal/testrun/driver_test.go b/internal/testrun/driver_test.go index 658a18a..78d1510 100644 --- a/internal/testrun/driver_test.go +++ b/internal/testrun/driver_test.go @@ -2,27 +2,139 @@ package testrun import ( "context" + "errors" "io" - "os/exec" - "strings" "testing" + + "github.com/priyanshujain/sanderling/internal/driver" + "github.com/priyanshujain/sanderling/internal/driver/ioscompanion" + "github.com/priyanshujain/sanderling/internal/ios" ) -func TestBuildDriverRejectsPhysicalIOS(t *testing.T) { - if _, err := exec.LookPath("xcrun"); err != nil { - t.Skip("xcrun not on PATH; physical-iOS rejection is reached only after preflight") +// stubDeviceDriver is a no-op DeviceDriver so routing tests never build or spawn +// a real runner. Only construction wiring is under test. +type stubDeviceDriver struct{ driver.DeviceDriver } + +func TestBuildDriverRoutesPhysicalIOSToDeviceDriver(t *testing.T) { + original := newDeviceDriver + t.Cleanup(func() { newDeviceDriver = original }) + + var got ioscompanion.DeviceOptions + closed := false + newDeviceDriver = func(_ context.Context, options ioscompanion.DeviceOptions) (driver.DeviceDriver, func(), error) { + got = options + return stubDeviceDriver{}, func() { closed = true }, nil + } + + options := Options{Platform: "ios", BundleID: "app.folio", IosAppPath: "/tmp/iosApp.app"} + options.iosIsSimulator = false + options.iosUDID = "00008140-HW" + options.iosCoreDeviceID = "CORE-1" + + d, cleanup, err := buildDriver(context.Background(), options, io.Discard) + if err != nil { + t.Fatalf("buildDriver: %v", err) + } + if d == nil { + t.Fatal("expected a device driver") + } + if got.HardwareUDID != "00008140-HW" || got.CoreDeviceID != "CORE-1" { + t.Fatalf("DeviceOptions ids = %+v, want the resolved hardware/core ids", got) + } + if got.BundleID != "app.folio" || got.AppPath != "/tmp/iosApp.app" { + t.Fatalf("DeviceOptions = %+v, want bundle and app path threaded through", got) + } + cleanup() + if !closed { + t.Fatal("cleanup must close the device driver") + } +} + +func TestBuildDriverSurfacesDeviceConstructionError(t *testing.T) { + original := newDeviceDriver + t.Cleanup(func() { newDeviceDriver = original }) + newDeviceDriver = func(context.Context, ioscompanion.DeviceOptions) (driver.DeviceDriver, func(), error) { + return nil, nil, errors.New("no signing creds") } options := Options{Platform: "ios"} options.iosIsSimulator = false + if _, _, err := buildDriver(context.Background(), options, io.Discard); err == nil { + t.Fatal("expected the device construction error to surface") + } +} + +func swapIOSResolveSeams(t *testing.T) { + t.Helper() + origTarget, origDevice, origEnsure := iosResolveTarget, iosResolveDevice, iosEnsureSimulator + t.Cleanup(func() { + iosResolveTarget, iosResolveDevice, iosEnsureSimulator = origTarget, origDevice, origEnsure + }) +} - _, cleanup, err := buildDriver(context.Background(), options, io.Discard) - if cleanup != nil { - cleanup() +func TestResolveIOSTargetPhysicalDeviceFillsIDs(t *testing.T) { + swapIOSResolveSeams(t) + iosResolveTarget = func(_ context.Context, query string) (string, bool, error) { + return query, false, nil + } + resolveDeviceCalled := "" + iosResolveDevice = func(_ context.Context, query string) (ios.Device, error) { + resolveDeviceCalled = query + return ios.Device{Name: "iPhone", HardwareUDID: "00008140-HW", CoreDeviceID: "CORE-1"}, nil + } + iosEnsureSimulator = func(context.Context, string, io.Writer) error { + t.Fatal("must not boot a simulator on the device path") + return nil + } + + options := Options{Platform: "ios", IosDevice: "iPhone"} + resolved, err := resolveIOSTarget(context.Background(), options, io.Discard) + if err != nil { + t.Fatal(err) + } + if resolveDeviceCalled != "iPhone" { + t.Fatalf("ResolveDevice query = %q, want iPhone", resolveDeviceCalled) + } + if resolved.iosIsSimulator { + t.Fatal("device target must not be marked a simulator") + } + if resolved.iosUDID != "00008140-HW" || resolved.iosCoreDeviceID != "CORE-1" { + t.Fatalf("resolved ids = (%q, %q), want hardware/core ids", resolved.iosUDID, resolved.iosCoreDeviceID) } - if err == nil { - t.Fatal("expected physical-device iOS to be rejected") +} + +func TestResolveIOSTargetSimulatorSkipsDeviceResolution(t *testing.T) { + swapIOSResolveSeams(t) + iosResolveTarget = func(context.Context, string) (string, bool, error) { + return "sim-udid", true, nil + } + ensured := false + iosEnsureSimulator = func(context.Context, string, io.Writer) error { ensured = true; return nil } + iosResolveDevice = func(context.Context, string) (ios.Device, error) { + t.Fatal("simulator path must not resolve a physical device") + return ios.Device{}, nil + } + + options := Options{Platform: "ios", IosDevice: "iPhone 17 Pro"} + resolved, err := resolveIOSTarget(context.Background(), options, io.Discard) + if err != nil { + t.Fatal(err) + } + if !ensured { + t.Fatal("simulator path must ensure the simulator is booted") + } + if !resolved.iosIsSimulator || resolved.iosUDID != "sim-udid" { + t.Fatalf("resolved = %+v, want the booted simulator", resolved) + } +} + +func TestResolveIOSTargetDeviceResolutionErrorSurfaces(t *testing.T) { + swapIOSResolveSeams(t) + iosResolveTarget = func(_ context.Context, query string) (string, bool, error) { return query, false, nil } + iosResolveDevice = func(context.Context, string) (ios.Device, error) { + return ios.Device{}, errors.New("no device connected") } - if !strings.Contains(err.Error(), "not yet supported") { - t.Fatalf("expected an unsupported-physical-iOS error, got %v", err) + options := Options{Platform: "ios", IosDevice: "iPhone"} + if _, err := resolveIOSTarget(context.Background(), options, io.Discard); err == nil { + t.Fatal("expected the device-resolution error to surface") } } diff --git a/internal/testrun/testrun.go b/internal/testrun/testrun.go index 9d48dce..f5714e0 100644 --- a/internal/testrun/testrun.go +++ b/internal/testrun/testrun.go @@ -13,7 +13,6 @@ import ( "github.com/priyanshujain/sanderling/internal/android" "github.com/priyanshujain/sanderling/internal/bundler" "github.com/priyanshujain/sanderling/internal/driver" - "github.com/priyanshujain/sanderling/internal/ios" "github.com/priyanshujain/sanderling/internal/runner" "github.com/priyanshujain/sanderling/internal/trace" "github.com/priyanshujain/sanderling/internal/verifier" @@ -34,10 +33,13 @@ type Options struct { Output string ClearData bool - // iosUDID and iosIsSimulator are filled by Execute after resolving the iOS - // target, then read by buildDriver to choose the companion or sidecar path. - iosUDID string - iosIsSimulator bool + // iosUDID, iosIsSimulator, and iosCoreDeviceID are filled by Execute after + // resolving the iOS target, then read by buildDriver to choose the simulator + // companion or the physical-device driver. On the device path iosUDID is the + // hardware UDID and iosCoreDeviceID is the CoreDevice id. + iosUDID string + iosIsSimulator bool + iosCoreDeviceID string } // Execute runs the full test pipeline: bundle, launch app, verify properties. @@ -48,30 +50,11 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error { return err } case "ios": - udid, isSimulator, err := ios.ResolveTarget(ctx, options.IosDevice) + resolved, err := resolveIOSTarget(ctx, options, stdout) if err != nil { - // No query and nothing booted: keep boot-first behavior, then resolve - // the simulator that EnsureSimulator just brought up. - if options.IosDevice == "" { - if bootErr := ios.EnsureSimulator(ctx, "", stdout); bootErr != nil { - return bootErr - } - udid, isSimulator, err = ios.ResolveTarget(ctx, "") - } - if err != nil { - return err - } - } else if isSimulator { - if err := ios.EnsureSimulator(ctx, options.IosDevice, stdout); err != nil { - return err - } - udid, isSimulator, err = ios.ResolveTarget(ctx, options.IosDevice) - if err != nil { - return err - } + return err } - options.iosUDID = udid - options.iosIsSimulator = isSimulator + options = resolved } prep, err := prepareBundleInputs(options) if err != nil {