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.
This commit is contained in:
pj committed 2026-06-08 23:18:15 +05:30
1 parent 06ea651c71
commit f5cfc4407e
4 files changed
+208 -42

No files matched your search

+2 -2
View File
@@ -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")
+75 -4
View File
@@ -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.
+124 -12
View File
@@ -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")
}
}
_, cleanup, err := buildDriver(context.Background(), options, io.Discard)
if cleanup != nil {
cleanup()
func swapIOSResolveSeams(t *testing.T) {
t.Helper()
origTarget, origDevice, origEnsure := iosResolveTarget, iosResolveDevice, iosEnsureSimulator
t.Cleanup(func() {
iosResolveTarget, iosResolveDevice, iosEnsureSimulator = origTarget, origDevice, origEnsure
})
}
if err == nil {
t.Fatal("expected physical-device iOS to be rejected")
func TestResolveIOSTargetPhysicalDeviceFillsIDs(t *testing.T) {
swapIOSResolveSeams(t)
iosResolveTarget = func(_ context.Context, query string) (string, bool, error) {
return query, false, nil
}
if !strings.Contains(err.Error(), "not yet supported") {
t.Fatalf("expected an unsupported-physical-iOS error, got %v", err)
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)
}
}
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")
}
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")
}
}
+7 -24
View File
@@ -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, 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)
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, "")
}
resolved, err := resolveIOSTarget(ctx, options, stdout)
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
}
}
options.iosUDID = udid
options.iosIsSimulator = isSimulator
options = resolved
}
prep, err := prepareBundleInputs(options)
if err != nil {