diff --git a/internal/driver/ioscompanion/device.go b/internal/driver/ioscompanion/device.go index 52903ab..1193636 100644 --- a/internal/driver/ioscompanion/device.go +++ b/internal/driver/ioscompanion/device.go @@ -224,8 +224,14 @@ func (d *Driver) respawnDevice(ctx context.Context) error { // 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 { - _ = exec.CommandContext(ctx, "xcrun", "devicectl", "device", "uninstall", "app", "--device", d.coreDeviceID, d.bundleID).Run() + 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))) diff --git a/internal/driver/ioscompanion/device_test.go b/internal/driver/ioscompanion/device_test.go index 1ffd11d..abc5ced 100644 --- a/internal/driver/ioscompanion/device_test.go +++ b/internal/driver/ioscompanion/device_test.go @@ -7,6 +7,7 @@ import ( "net" "os/exec" "slices" + "strings" "testing" "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport" @@ -182,6 +183,45 @@ func TestNewDeviceReinstallsOnceBeforeTheRunnerSession(t *testing.T) { } } +func TestDevicectlReinstallStopsWhenTheUninstallFails(t *testing.T) { + log := scriptedXcrun(t, `"devicectl device uninstall "*) echo "ERROR: Internal logic error: Connection was invalidated"; exit 1;; +"devicectl device install "*) :;;`) + d := &Driver{coreDeviceID: "CORE-DEVICE", bundleID: "app.example", appPath: "/tmp/Sample.app"} + + err := d.devicectlReinstall(context.Background()) + + if err == nil { + t.Fatal("devicectlReinstall reported success while app.example kept the data clear-state was asked to remove") + } + for _, want := range []string{"app.example", "Connection was invalidated"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not quote %q", err, want) + } + } + calls := xcrunCalls(t, log) + if slices.ContainsFunc(calls, func(call string) bool { return strings.HasPrefix(call, "devicectl device install") }) { + t.Errorf("xcrun calls = %v: installing over the app carries its data into the run", calls) + } +} + +func TestDevicectlReinstallProceedsWhenNothingIsInstalled(t *testing.T) { + log := scriptedXcrun(t, `"devicectl device uninstall "*) echo "App uninstalled.";; +"devicectl device install "*) :;;`) + d := &Driver{coreDeviceID: "CORE-DEVICE", bundleID: "app.example", appPath: "/tmp/Sample.app"} + + if err := d.devicectlReinstall(context.Background()); err != nil { + t.Fatalf("devicectlReinstall: %v", err) + } + + want := []string{ + "devicectl device uninstall app --device CORE-DEVICE app.example", + "devicectl device install app --device CORE-DEVICE /tmp/Sample.app", + } + if got := xcrunCalls(t, log); !slices.Equal(got, want) { + t.Fatalf("xcrun calls = %v, want %v", got, want) + } +} + func TestDeviceClearStateWithoutAppPathWarnsOnce(t *testing.T) { output := &bytes.Buffer{} d := &Driver{output: output, deviceMode: true}