diff --git a/internal/driver/ioscompanion/device.go b/internal/driver/ioscompanion/device.go index 1193636..de10374 100644 --- a/internal/driver/ioscompanion/device.go +++ b/internal/driver/ioscompanion/device.go @@ -111,13 +111,17 @@ func NewDevice(ctx context.Context, options DeviceOptions) (*Driver, error) { // 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. + // natively, so no paste prompt is ever hit. Stopping the app before the + // clear is a no-op too: devicectl addresses processes by pid rather than by + // bundle, and the uninstall that is the device's only clear takes the + // running app with it, which is what a terminate here would be for. 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.terminateApp = func(context.Context) error { return nil } d.restart = d.respawnDevice d.processContext, d.processCancel = context.WithCancel(ctx) diff --git a/internal/driver/ioscompanion/driver.go b/internal/driver/ioscompanion/driver.go index 5321346..c1abc1b 100644 --- a/internal/driver/ioscompanion/driver.go +++ b/internal/driver/ioscompanion/driver.go @@ -91,6 +91,7 @@ type Options struct { dialRunner func(address string) (transport.Companion, error) reinstallApp func(ctx context.Context) error resetContainer func(ctx context.Context) error + terminateApp func(ctx context.Context) error } // Driver implements driver.DeviceDriver against an iOS simulator companion. @@ -136,6 +137,10 @@ type Driver struct { // A seam so tests skip the simctl shell-outs. reinstallApp func(ctx context.Context) error + // terminateApp stops the app before its state is cleared. A seam so tests + // skip the simctl shell-out. + terminateApp func(ctx context.Context) error + // grantPaste pre-authorizes the app's pasteboard access. A seam so tests // skip the sqlite shell-out. grantPaste func(ctx context.Context) error @@ -237,6 +242,7 @@ func New(ctx context.Context, options Options) (*Driver, error) { dialRunner: options.dialRunner, reinstallApp: options.reinstallApp, resetContainer: options.resetContainer, + terminateApp: options.terminateApp, hybrid: hybridCompanionEnabled(), } if driverInstance.spawnChild == nil { @@ -271,6 +277,9 @@ func New(ctx context.Context, options Options) (*Driver, error) { if driverInstance.reinstallApp == nil { driverInstance.reinstallApp = driverInstance.simctlReinstall } + if driverInstance.terminateApp == nil { + driverInstance.terminateApp = driverInstance.simctlTerminate + } driverInstance.grantPaste = driverInstance.grantPasteboardAccess driverInstance.processContext, driverInstance.processCancel = context.WithCancel(ctx) @@ -619,6 +628,11 @@ func (d *Driver) lifecycleCompanion() transport.Companion { // container and warns once that a full reinstall needs the app path. Called // only from construction, before any automation session is attached to the app. func (d *Driver) clearAppState(ctx context.Context) error { + // Nothing may be writing to the state while it goes, which is the ordering + // Launch used to hold: uninstall copes with a running app, deleting the + // data container out from under one does not. Best effort, because an app + // that is not running reports a failure that means nothing here. + _ = d.terminateApp(ctx) if d.appPath != "" { if err := d.reinstallApp(ctx); err != nil { return fmt.Errorf("reinstall %s: %w", d.appPath, err) @@ -650,6 +664,18 @@ func (d *Driver) simctlReinstall(ctx context.Context) error { return nil } +// simctlTerminate stops the app under test. Launch used to terminate through +// the automation session before clearing; the clear now runs before any session +// exists, so simctl is what is left to stop the app with. An app that is not +// running reports a failure that means nothing to the caller, which is why +// clearAppState treats this as best effort. +func (d *Driver) simctlTerminate(ctx context.Context) error { + if output, err := exec.CommandContext(ctx, "xcrun", "simctl", "terminate", d.udid, d.bundleID).CombinedOutput(); err != nil { + return fmt.Errorf("simctl terminate %s: %w: %s", d.bundleID, err, strings.TrimSpace(string(output))) + } + return nil +} + // grantPasteboardAccess authorizes the app to read the pasteboard without the // iOS permission prompt, by writing an allow row into the simulator's privacy // (TCC) database. This is the simulator counterpart to `simctl privacy grant`,