fix(ios): clear app state before the automation session attaches

New performs the clear-state reset, so the uninstall and reinstall no
longer land underneath a live XCTest session that is already bound to
the app. Launch refuses a clear-state request the driver was not built
for rather than reinstalling under its own session.
This commit is contained in:
pj committed 2026-08-15 21:31:48 +05:30
1 parent a7e94c85df
commit aed65976e3
2 files changed
+190 -50

No files matched your search

+58 -21
View File
@@ -73,16 +73,24 @@ type Options struct {
// AppPath is the .app bundle directory. Required for clear-state reinstall; // AppPath is the .app bundle directory. Required for clear-state reinstall;
// when empty, clear state falls back to resetting the data container. // when empty, clear state falls back to resetting the data container.
AppPath string AppPath string
// ClearState resets the app to first-launch state while New runs, before
// any automation session attaches. Clear state is a property of the driver
// rather than of a launch: see Launch.
ClearState bool
// Output receives companion stdout and stderr plus driver warnings. // Output receives companion stdout and stderr plus driver warnings.
Output io.Writer Output io.Writer
// DoubleTapGapMilliseconds overrides the synthesized double-tap gap. // DoubleTapGapMilliseconds overrides the synthesized double-tap gap.
DoubleTapGapMilliseconds float64 DoubleTapGapMilliseconds float64
// spawnChild, dialCompanion, and pickAddress are test seams. Production // These are test seams. Production leaves them nil and New wires the real
// leaves them nil and New wires the real extraction, spawn, and dial. // extraction, spawn, dial and simctl calls.
spawnChild func(ctx context.Context, address string) (*exec.Cmd, error) spawnChild func(ctx context.Context, address string) (*exec.Cmd, error)
dialCompanion func(address string) (transport.Companion, error) dialCompanion func(address string) (transport.Companion, error)
pickAddress func() (string, error) pickAddress func() (string, error)
spawnRunner func(ctx context.Context, address string) (*exec.Cmd, error)
dialRunner func(address string) (transport.Companion, error)
reinstallApp func(ctx context.Context) error
resetContainer func(ctx context.Context) error
} }
// Driver implements driver.DeviceDriver against an iOS simulator companion. // Driver implements driver.DeviceDriver against an iOS simulator companion.
@@ -93,6 +101,12 @@ type Driver struct {
appPath string appPath string
output io.Writer output io.Writer
// clearStateAtStartup records that New (or NewDevice) reset the app to
// first-launch state before attaching, which is the only point in a run
// where clearing is safe. Launch refuses a clear-state request the driver
// was not built for rather than reinstalling under a live session.
clearStateAtStartup bool
screenWidth int screenWidth int
screenHeight int screenHeight int
@@ -137,12 +151,13 @@ type Driver struct {
// lifecycle, screenshot) with an in-simulator runner that serves // lifecycle, screenshot) with an in-simulator runner that serves
// collapse-free accessibility snapshots and native unicode typing. // collapse-free accessibility snapshots and native unicode typing.
// runnerClient is nil on the legacy-only path. // runnerClient is nil on the legacy-only path.
runnerClient transport.Companion runnerClient transport.Companion
runnerChild *exec.Cmd runnerChild *exec.Cmd
runnerAddress string runnerAddress string
spawnRunner func(ctx context.Context, address string) (*exec.Cmd, error) spawnRunner func(ctx context.Context, address string) (*exec.Cmd, error)
dialRunner func(address string) (transport.Companion, error) dialRunner func(address string) (transport.Companion, error)
hybrid bool pickRunnerAddress func() (string, error)
hybrid bool
// Device-mode fields. On the physical-device path d.companion is the runner // Device-mode fields. On the physical-device path d.companion is the runner
// dialed over a usbmux tunnel, hybrid is false, and runnerClient is nil. // dialed over a usbmux tunnel, hybrid is false, and runnerClient is nil.
@@ -197,6 +212,9 @@ func New(ctx context.Context, options Options) (*Driver, error) {
if options.UniqueDeviceIdentifier == "" { if options.UniqueDeviceIdentifier == "" {
return nil, errors.New("ios companion: UniqueDeviceIdentifier is required") return nil, errors.New("ios companion: UniqueDeviceIdentifier is required")
} }
if options.ClearState && options.BundleID == "" {
return nil, errors.New("ios companion: clear-state needs BundleID: there is nothing to uninstall or wipe without it")
}
output := options.Output output := options.Output
if output == nil { if output == nil {
output = io.Discard output = io.Discard
@@ -210,10 +228,15 @@ func New(ctx context.Context, options Options) (*Driver, error) {
udid: options.UniqueDeviceIdentifier, udid: options.UniqueDeviceIdentifier,
bundleID: options.BundleID, bundleID: options.BundleID,
appPath: options.AppPath, appPath: options.AppPath,
clearStateAtStartup: options.ClearState,
output: output, output: output,
doubleTapGapMilliseconds: gap, doubleTapGapMilliseconds: gap,
spawnChild: options.spawnChild, spawnChild: options.spawnChild,
dial: options.dialCompanion, dial: options.dialCompanion,
spawnRunner: options.spawnRunner,
dialRunner: options.dialRunner,
reinstallApp: options.reinstallApp,
resetContainer: options.resetContainer,
hybrid: hybridCompanionEnabled(), hybrid: hybridCompanionEnabled(),
} }
if driverInstance.spawnChild == nil { if driverInstance.spawnChild == nil {
@@ -240,9 +263,14 @@ func New(ctx context.Context, options Options) (*Driver, error) {
return nil, err return nil, err
} }
driverInstance.address = address driverInstance.address = address
driverInstance.pickRunnerAddress = pickAddress
driverInstance.restart = driverInstance.respawnAndRedial driverInstance.restart = driverInstance.respawnAndRedial
driverInstance.resetContainer = driverInstance.resetDataContainer if driverInstance.resetContainer == nil {
driverInstance.reinstallApp = driverInstance.simctlReinstall driverInstance.resetContainer = driverInstance.resetDataContainer
}
if driverInstance.reinstallApp == nil {
driverInstance.reinstallApp = driverInstance.simctlReinstall
}
driverInstance.grantPaste = driverInstance.grantPasteboardAccess driverInstance.grantPaste = driverInstance.grantPasteboardAccess
driverInstance.processContext, driverInstance.processCancel = context.WithCancel(ctx) driverInstance.processContext, driverInstance.processCancel = context.WithCancel(ctx)
@@ -253,6 +281,13 @@ func New(ctx context.Context, options Options) (*Driver, error) {
} }
driverInstance.deviceLock = lock driverInstance.deviceLock = lock
if options.ClearState {
if err := driverInstance.clearAppState(ctx); err != nil {
driverInstance.Close()
return nil, err
}
}
if err := driverInstance.bringUp(ctx); err != nil { if err := driverInstance.bringUp(ctx); err != nil {
driverInstance.Close() driverInstance.Close()
return nil, err return nil, err
@@ -366,7 +401,7 @@ func (d *Driver) bringUpRunner(ctx context.Context) error {
// A fresh port every bring-up: after a restart the dying session's // A fresh port every bring-up: after a restart the dying session's
// listener may still answer on the old port and would satisfy the wait // listener may still answer on the old port and would satisfy the wait
// below with a dead server. // below with a dead server.
address, err := pickLoopbackAddress() address, err := d.pickRunnerAddress()
if err != nil { if err != nil {
return err return err
} }
@@ -460,6 +495,13 @@ func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool, e
// loudly rather than silently dropping the request. // loudly rather than silently dropping the request.
return errors.New("ios companion: launch with environment variables is unsupported on this backend") return errors.New("ios companion: launch with environment variables is unsupported on this backend")
} }
if clearState && !d.clearStateAtStartup {
// Clearing here would uninstall and reinstall the app underneath a live
// automation session, which is what races FrontBoard's registration and
// leaves the session launching a bundle FrontBoard has not registered.
return errors.New("ios companion: clear-state must be requested when the driver is created (Options.ClearState); " +
"this backend clears the app before its automation session exists")
}
// Terminate first so the launch is a clean cold start regardless of the // Terminate first so the launch is a clean cold start regardless of the
// app's prior state. A not-running app is not an error here. // app's prior state. A not-running app is not an error here.
@@ -467,12 +509,6 @@ func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool, e
return companion.Terminate(callCtx, d.bundleID) return companion.Terminate(callCtx, d.bundleID)
}) })
if clearState {
if err := d.clearAppState(ctx); err != nil {
return err
}
}
// Grant the app pasteboard access before it runs so unicode input (which // Grant the app pasteboard access before it runs so unicode input (which
// must go through the pasteboard, since HID cannot express it) never trips // must go through the pasteboard, since HID cannot express it) never trips
// the iOS paste-permission prompt. clearState reinstall resets the grant, // the iOS paste-permission prompt. clearState reinstall resets the grant,
@@ -560,7 +596,8 @@ func (d *Driver) lifecycleCompanion() transport.Companion {
// clearAppState resets the app to a first-launch state. With an app path it // clearAppState resets the app to a first-launch state. With an app path it
// uninstalls and reinstalls; without one it falls back to wiping the app's data // uninstalls and reinstalls; without one it falls back to wiping the app's data
// container and warns once that a full reinstall needs the app path. // 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 { func (d *Driver) clearAppState(ctx context.Context) error {
if d.appPath != "" { if d.appPath != "" {
if err := d.reinstallApp(ctx); err != nil { if err := d.reinstallApp(ctx); err != nil {
+132 -29
View File
@@ -12,6 +12,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -182,47 +183,149 @@ func TestLaunchContinuesWhenGrantFails(t *testing.T) {
} }
} }
func TestLaunchClearStateReinstallsWithAppPath(t *testing.T) { // clearStateProbe records, in order, the calls a run makes to reset the app and
// to bring the runner's automation session up. A reinstall recorded after the
// session is the ordering that races FrontBoard.
type clearStateProbe struct {
mutex sync.Mutex
events []string
}
func (p *clearStateProbe) record(event string) {
p.mutex.Lock()
defer p.mutex.Unlock()
p.events = append(p.events, event)
}
func (p *clearStateProbe) recorded() []string {
p.mutex.Lock()
defer p.mutex.Unlock()
out := make([]string, len(p.events))
copy(out, p.events)
return out
}
// clearStateOptions wires every seam a hybrid bring-up needs, so New runs its
// real sequence against fakes: no simulator, no simctl, no XCTest session.
func clearStateOptions(t *testing.T, probe *clearStateProbe, udid string, clearState bool) Options {
t.Helper()
t.Setenv("SANDERLING_SIMULATOR_COMPANION", "")
address := startLoopbackListener(t)
return Options{
UniqueDeviceIdentifier: udid,
BundleID: "com.example.app",
ClearState: clearState,
Output: &bytes.Buffer{},
pickAddress: func() (string, error) { return address, nil },
spawnChild: func(context.Context, string) (*exec.Cmd, error) { return &exec.Cmd{}, nil },
dialCompanion: func(string) (transport.Companion, error) {
return &fakeCompanion{accessibilityJSON: "[]"}, nil
},
spawnRunner: func(context.Context, string) (*exec.Cmd, error) {
probe.record("runner session")
return &exec.Cmd{}, nil
},
dialRunner: func(string) (transport.Companion, error) {
return &fakeCompanion{accessibilityJSON: "[]"}, nil
},
reinstallApp: func(context.Context) error { probe.record("reinstall"); return nil },
resetContainer: func(context.Context) error { probe.record("reset container"); return nil },
}
}
func TestClearStateReinstallsOnceBeforeTheRunnerSession(t *testing.T) {
probe := &clearStateProbe{}
options := clearStateOptions(t, probe, "CLEAR-REINSTALL-UDID", true)
options.AppPath = "/tmp/Sample.app"
d, err := New(context.Background(), options)
if err != nil {
t.Fatalf("New: %v", err)
}
defer d.Close()
if err := d.Launch(context.Background(), "", true, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
want := []string{"reinstall", "runner session"}
if got := probe.recorded(); !slices.Equal(got, want) {
t.Fatalf("calls = %v, want %v: the reinstall must run once, before the automation session attaches", got, want)
}
}
func TestClearStateWithoutAppPathWipesContainerBeforeTheRunnerSession(t *testing.T) {
probe := &clearStateProbe{}
output := &bytes.Buffer{}
options := clearStateOptions(t, probe, "CLEAR-CONTAINER-UDID", true)
options.Output = output
d, err := New(context.Background(), options)
if err != nil {
t.Fatalf("New: %v", err)
}
defer d.Close()
if err := d.Launch(context.Background(), "", true, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
want := []string{"reset container", "runner session"}
if got := probe.recorded(); !slices.Equal(got, want) {
t.Fatalf("calls = %v, want %v: the fallback must wipe the container once, before the session, and never reinstall", got, want)
}
if warnings := strings.Count(output.String(), "resetting the data container only"); warnings != 1 {
t.Fatalf("warning emitted %d times, want once", warnings)
}
}
func TestWithoutClearStateTheAppIsLeftAlone(t *testing.T) {
probe := &clearStateProbe{}
options := clearStateOptions(t, probe, "NO-CLEAR-UDID", false)
options.AppPath = "/tmp/Sample.app"
d, err := New(context.Background(), options)
if err != nil {
t.Fatalf("New: %v", err)
}
defer d.Close()
if err := d.Launch(context.Background(), "", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
want := []string{"runner session"}
if got := probe.recorded(); !slices.Equal(got, want) {
t.Fatalf("calls = %v, want %v: a run that did not ask for clear state must not touch the install", got, want)
}
}
func TestLaunchRefusesClearStateTheDriverWasNotBuiltFor(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"} companion := &fakeCompanion{accessibilityJSON: "[]"}
d := newTestDriver(companion) d := newTestDriver(companion)
d.appPath = "/tmp/Sample.app" d.appPath = "/tmp/Sample.app"
reinstalls := 0 reinstalls := 0
d.reinstallApp = func(context.Context) error { reinstalls++; return nil } d.reinstallApp = func(context.Context) error { reinstalls++; return nil }
if err := d.Launch(context.Background(), "", true, nil); err != nil {
t.Fatalf("Launch: %v", err) err := d.Launch(context.Background(), "", true, nil)
if err == nil || !strings.Contains(err.Error(), "clear-state") {
t.Fatalf("Launch err = %v, want a refusal naming clear-state", err)
} }
if reinstalls != 1 { if reinstalls != 0 {
t.Fatalf("clear-state with app path must reinstall exactly once; got %d", reinstalls) t.Fatalf("reinstalls = %d, want 0: a live session must never have the app reinstalled under it", reinstalls)
} }
if indexOf(companion.calls, "launch") < indexOf(companion.calls, "terminate") { if indexOf(companion.recorded(), "launch") >= 0 {
t.Fatalf("launch must still follow terminate; got %v", companion.calls) t.Fatalf("a refused launch must not reach the companion; got %v", companion.recorded())
} }
} }
func TestLaunchClearStateFallbackWarnsOnce(t *testing.T) { func TestNewRejectsClearStateWithoutBundleID(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"} probe := &clearStateProbe{}
output := &bytes.Buffer{} options := clearStateOptions(t, probe, "NO-BUNDLE-UDID", true)
d := newTestDriver(companion) options.BundleID = ""
d.output = output
resets := 0
d.resetContainer = func(context.Context) error { resets++; return nil }
for i := 0; i < 2; i++ { if _, err := New(context.Background(), options); err == nil || !strings.Contains(err.Error(), "BundleID") {
if err := d.Launch(context.Background(), "", true, nil); err != nil { t.Fatalf("New err = %v, want a refusal naming BundleID", err)
t.Fatalf("Launch %d: %v", i, err)
}
} }
if resets != 2 { if got := probe.recorded(); len(got) != 0 {
t.Fatalf("resetContainer called %d times, want 2", resets) t.Fatalf("calls = %v, want none: clearing an unnamed bundle would reinstall without resetting anything", got)
}
warnings := strings.Count(output.String(), "resetting the data container only")
if warnings != 1 {
t.Fatalf("warning emitted %d times, want once", warnings)
}
for _, call := range companion.calls {
if call == "install" || call == "uninstall" {
t.Fatalf("fallback path must not install/uninstall; got %v", companion.calls)
}
} }
} }