mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
same hole as the simulator path: devicectl install over an app keeps its data, and the discarded uninstall error hid it. Uninstalling a bundle id that is not installed exits 0 with 'App uninstalled.' on a paired iPhone, so a failure here is always real.
252 lines
8.9 KiB
Go
252 lines
8.9 KiB
Go
// This file implements the physical-device mode of Driver. The device is driven
|
|
// runner-only: the in-device XCUITest runner serves every capability over a
|
|
// usbmux tunnel, with no legacy companion. The simulator hybrid path is left
|
|
// byte-identical; device mode swaps three sim-only seams (reinstall, container
|
|
// reset, paste grant) and brings the runner up over the tunnel instead of a
|
|
// local listener.
|
|
package ioscompanion
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
|
|
)
|
|
|
|
// DeviceOptions configures a device-mode Driver. Signing credentials are not
|
|
// carried here: realSpawnDeviceRunner reads them from the environment at the
|
|
// point of use so secrets never reach the Options struct or run artifacts.
|
|
type DeviceOptions struct {
|
|
// HardwareUDID feeds xcodebuild -destination and the usbmux device match.
|
|
HardwareUDID string
|
|
// CoreDeviceID feeds devicectl install/uninstall.
|
|
CoreDeviceID string
|
|
// BundleID is the app under test.
|
|
BundleID string
|
|
// AppPath is the .app bundle installed via devicectl for clear-state.
|
|
AppPath string
|
|
// ClearState reinstalls the app while NewDevice runs, before the runner's
|
|
// test session exists. Clear state is a property of the driver rather than
|
|
// of a launch: see Launch.
|
|
ClearState bool
|
|
// Output receives the runner session log path and driver warnings.
|
|
Output io.Writer
|
|
// DoubleTapGapMilliseconds overrides the synthesized double-tap gap.
|
|
DoubleTapGapMilliseconds float64
|
|
|
|
// Test seams. Production leaves them nil and NewDevice wires the real
|
|
// build/spawn/tunnel/dial/devicectl.
|
|
spawnRunner func(ctx context.Context, address string) (*exec.Cmd, error)
|
|
startTunnel func(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error)
|
|
dialRunner func(address string) (transport.Companion, error)
|
|
pickAddress func() (string, error)
|
|
reinstallApp func(ctx context.Context) error
|
|
}
|
|
|
|
// deviceStartupTimeout bounds the runner's startup once its hosting test
|
|
// session is spawned and the tunnel is up. The session's cold start on a
|
|
// physical device is slower than the simulator's, and the build that precedes
|
|
// it runs outside this window (under the process context, not the startup one).
|
|
const deviceStartupTimeout = 180 * time.Second
|
|
|
|
// NewDevice brings up a runner-only Driver against a physical device: it builds
|
|
// and spawns the in-device runner, opens a usbmux tunnel to it, dials the runner
|
|
// over the tunnel, health-probes it, and caches the screen dimensions. Call
|
|
// Close when done to stop the runner session and the tunnel.
|
|
func NewDevice(ctx context.Context, options DeviceOptions) (*Driver, error) {
|
|
if options.HardwareUDID == "" {
|
|
return nil, errors.New("ios device: HardwareUDID is required")
|
|
}
|
|
if options.CoreDeviceID == "" {
|
|
return nil, errors.New("ios device: CoreDeviceID is required")
|
|
}
|
|
if options.ClearState && options.BundleID == "" {
|
|
return nil, errors.New("ios device: clear-state needs BundleID: there is nothing to uninstall without it")
|
|
}
|
|
output := options.Output
|
|
if output == nil {
|
|
output = io.Discard
|
|
}
|
|
gap := options.DoubleTapGapMilliseconds
|
|
if gap <= 0 {
|
|
gap = DefaultDoubleTapGapMilliseconds
|
|
}
|
|
|
|
d := &Driver{
|
|
udid: options.HardwareUDID,
|
|
coreDeviceID: options.CoreDeviceID,
|
|
bundleID: options.BundleID,
|
|
appPath: options.AppPath,
|
|
clearStateAtStartup: options.ClearState,
|
|
output: output,
|
|
doubleTapGapMilliseconds: gap,
|
|
deviceMode: true,
|
|
hybrid: false,
|
|
spawnRunner: options.spawnRunner,
|
|
startTunnel: options.startTunnel,
|
|
}
|
|
if d.spawnRunner == nil {
|
|
d.spawnRunner = d.realSpawnDeviceRunner
|
|
}
|
|
if d.startTunnel == nil {
|
|
d.startTunnel = startUsbmuxTunnel
|
|
}
|
|
d.dialRunner = options.dialRunner
|
|
if d.dialRunner == nil {
|
|
d.dialRunner = func(address string) (transport.Companion, error) {
|
|
return transport.DialRunner(address, d.udid, d.bundleID)
|
|
}
|
|
}
|
|
if options.pickAddress != nil {
|
|
d.pickDeviceAddress = options.pickAddress
|
|
} else {
|
|
d.pickDeviceAddress = pickLoopbackAddress
|
|
}
|
|
|
|
// Device seams: clear-state reinstalls via devicectl; the container reset and
|
|
// paste grant are simulator-only and become no-ops. The runner types
|
|
// natively, so no paste prompt is ever hit.
|
|
d.reinstallApp = options.reinstallApp
|
|
if d.reinstallApp == nil {
|
|
d.reinstallApp = d.devicectlReinstall
|
|
}
|
|
d.resetContainer = d.deviceResetContainerUnsupported
|
|
d.grantPaste = func(context.Context) error { return nil }
|
|
d.restart = d.respawnDevice
|
|
d.processContext, d.processCancel = context.WithCancel(ctx)
|
|
|
|
lock, err := acquireDeviceLock(d.udid)
|
|
if err != nil {
|
|
d.processCancel()
|
|
return nil, err
|
|
}
|
|
d.deviceLock = lock
|
|
|
|
if options.ClearState {
|
|
if err := d.clearAppState(ctx); err != nil {
|
|
d.Close()
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if err := d.bringUpDevice(ctx); err != nil {
|
|
d.Close()
|
|
return nil, err
|
|
}
|
|
|
|
description, err := d.companion.Describe(ctx)
|
|
if err != nil {
|
|
d.Close()
|
|
return nil, fmt.Errorf("describe device: %w", err)
|
|
}
|
|
d.screenWidth = description.WidthPoints
|
|
d.screenHeight = description.HeightPoints
|
|
return d, nil
|
|
}
|
|
|
|
// bringUpDevice builds (if needed) and spawns the in-device runner, opens the
|
|
// tunnel, waits for the forwarded listener, dials the runner, and confirms
|
|
// health. The build runs inside spawnRunner under the process context, so the
|
|
// startup timeout only bounds the post-spawn wait, not the build.
|
|
func (d *Driver) bringUpDevice(ctx context.Context) error {
|
|
address, err := d.pickDeviceAddress()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, port, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
d.runnerAddress = address
|
|
|
|
// The runner listens on the device loopback at the same port number the host
|
|
// tunnel forwards from, so one picked free port covers both ends.
|
|
runnerChild, err := d.spawnRunner(d.processContext, address)
|
|
if err != nil {
|
|
return fmt.Errorf("spawn device runner: %w", err)
|
|
}
|
|
d.runnerChild = runnerChild
|
|
|
|
// The forwarder listens on the host loopback port and bridges to the same
|
|
// port number on the device, where the runner listens.
|
|
tunnel, err := d.startTunnel(d.processContext, d.udid, address, port)
|
|
if err != nil {
|
|
d.stopRunnerChild()
|
|
return fmt.Errorf("start tunnel: %w", err)
|
|
}
|
|
d.tunnel = tunnel
|
|
|
|
startupCtx, cancel := context.WithTimeout(ctx, deviceStartupTimeout)
|
|
defer cancel()
|
|
|
|
if err := waitForListener(startupCtx, address); err != nil {
|
|
d.stopTunnel()
|
|
d.stopRunnerChild()
|
|
return fmt.Errorf("device runner listener: %w", err)
|
|
}
|
|
|
|
companion, err := d.dialRunner(address)
|
|
if err != nil {
|
|
d.stopTunnel()
|
|
d.stopRunnerChild()
|
|
return fmt.Errorf("dial device runner: %w", err)
|
|
}
|
|
d.companion = companion
|
|
|
|
if err := d.waitForHealth(startupCtx); err != nil {
|
|
_ = companion.Close()
|
|
d.stopTunnel()
|
|
d.stopRunnerChild()
|
|
return fmt.Errorf("device runner health: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// respawnDevice is the device-path supervision restart: it tears down the runner
|
|
// transport, its hosting session, and the tunnel, then brings a fresh set up.
|
|
// Both the session and the tunnel restart together because a dropped usbmux
|
|
// connection can take either down.
|
|
func (d *Driver) respawnDevice(ctx context.Context) error {
|
|
if d.companion != nil {
|
|
_ = d.companion.Close()
|
|
}
|
|
d.stopRunnerChild()
|
|
d.stopTunnel()
|
|
return d.bringUpDevice(ctx)
|
|
}
|
|
|
|
// devicectlReinstall uninstalls then installs the app bundle via devicectl,
|
|
// keyed on the CoreDevice id. App lifecycle stays with devicectl: the runner's
|
|
// own install path is simulator-specific.
|
|
// A failed uninstall ends the reinstall: installing over an app keeps its data,
|
|
// so clear-state would be reported without happening. Uninstalling an app that
|
|
// is not installed exits 0 ("App uninstalled." on a paired iPhone running iOS
|
|
// 26.5), so there is no benign failure here to sort out from a real one.
|
|
func (d *Driver) devicectlReinstall(ctx context.Context) error {
|
|
if output, err := exec.CommandContext(ctx, "xcrun", "devicectl", "device", "uninstall", "app", "--device", d.coreDeviceID, d.bundleID).CombinedOutput(); err != nil {
|
|
return fmt.Errorf("devicectl uninstall %s: %w: %s", d.bundleID, err, strings.TrimSpace(string(output)))
|
|
}
|
|
output, err := exec.CommandContext(ctx, "xcrun", "devicectl", "device", "install", "app", "--device", d.coreDeviceID, d.appPath).CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("devicectl install: %w: %s", err, strings.TrimSpace(string(output)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// deviceResetContainerUnsupported warns once that device clear-state needs an
|
|
// app path for a devicectl reinstall: there is no simulator-style data-container
|
|
// wipe on a physical device.
|
|
func (d *Driver) deviceResetContainerUnsupported(context.Context) error {
|
|
if !d.clearStateWarned {
|
|
fmt.Fprintln(d.output, "clear-state on a physical device requires --ios-app-path for a reinstall; skipping (state not cleared)")
|
|
d.clearStateWarned = true
|
|
}
|
|
return nil
|
|
}
|