package ioscompanion import ( "bytes" "context" "errors" "fmt" "image" "image/color" "image/png" "net" "os" "os/exec" "path/filepath" "slices" "strings" "sync" "testing" "time" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "github.com/priyanshujain/sanderling/internal/driver" "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/companionpb" "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport" ) // fakeCompanion is an in-package stand-in for transport.Companion. It records // the order of calls and returns scripted results, so the driver's decision // logic is testable without a live simulator. type fakeCompanion struct { // callsMutex guards calls: Snapshot captures the hierarchy and the // screenshot concurrently. callsMutex sync.Mutex calls []string accessibilityJSON string accessibilityErr error screenshotData []byte describe transport.ScreenDescription apps []transport.InstalledApp // hidErr fires on the next SendHID then clears, letting a test inject one // transient failure. hidErr error } func (f *fakeCompanion) record(name string) { f.callsMutex.Lock() defer f.callsMutex.Unlock() f.calls = append(f.calls, name) } func (f *fakeCompanion) recorded() []string { f.callsMutex.Lock() defer f.callsMutex.Unlock() out := make([]string, len(f.calls)) copy(out, f.calls) return out } func (f *fakeCompanion) AccessibilityInfo(context.Context) (string, error) { f.record("accessibility") return f.accessibilityJSON, f.accessibilityErr } func (f *fakeCompanion) Describe(context.Context) (transport.ScreenDescription, error) { f.record("describe") return f.describe, nil } func (f *fakeCompanion) SendHID(context.Context, ...transport.HIDEvent) error { f.record("hid") if f.hidErr != nil { err := f.hidErr f.hidErr = nil return err } return nil } func (f *fakeCompanion) Screenshot(context.Context) ([]byte, string, error) { f.record("screenshot") return f.screenshotData, "", nil } func (f *fakeCompanion) Launch(_ context.Context, _ string, _ bool) error { f.record("launch") return nil } func (f *fakeCompanion) Terminate(context.Context, string) error { f.record("terminate") return nil } func (f *fakeCompanion) ListApps(context.Context) ([]transport.InstalledApp, error) { f.record("listapps") return f.apps, nil } func (f *fakeCompanion) Install(context.Context, string) error { f.record("install") return nil } func (f *fakeCompanion) Uninstall(context.Context, string) error { f.record("uninstall") return nil } func (f *fakeCompanion) Close() error { f.record("close") return nil } var _ transport.Companion = (*fakeCompanion)(nil) // newTestDriver returns a Driver wired to companion with no child process and a // no-op container reset, so tests never touch the filesystem or spawn anything. func newTestDriver(companion transport.Companion) *Driver { d := &Driver{ companion: companion, bundleID: "com.example.app", output: &bytes.Buffer{}, doubleTapGapMilliseconds: DefaultDoubleTapGapMilliseconds, screenWidth: 390, screenHeight: 844, } d.resetContainer = func(context.Context) error { return nil } d.reinstallApp = func(context.Context) error { return nil } d.grantPaste = func(context.Context) error { return nil } return d } func samplePNG(t *testing.T, width, height int) []byte { t.Helper() img := image.NewRGBA(image.Rect(0, 0, width, height)) img.Set(0, 0, color.RGBA{R: 1, A: 255}) var buffer bytes.Buffer if err := png.Encode(&buffer, img); err != nil { t.Fatalf("encode png: %v", err) } return buffer.Bytes() } func TestLaunchTerminatesFirstThenLaunches(t *testing.T) { companion := &fakeCompanion{accessibilityJSON: "[]"} d := newTestDriver(companion) if err := d.Launch(context.Background(), "", false, nil); err != nil { t.Fatalf("Launch: %v", err) } if companion.calls[0] != "terminate" { t.Fatalf("first call = %q, want terminate", companion.calls[0]) } if indexOf(companion.calls, "launch") < indexOf(companion.calls, "terminate") { t.Fatalf("launch must follow terminate; got %v", companion.calls) } } func TestLaunchGrantsPasteboardBeforeLaunch(t *testing.T) { companion := &fakeCompanion{accessibilityJSON: "[]"} d := newTestDriver(companion) granted := false d.grantPaste = func(context.Context) error { granted = true; return nil } if err := d.Launch(context.Background(), "", false, nil); err != nil { t.Fatalf("Launch: %v", err) } if !granted { t.Fatal("Launch must grant pasteboard access so unicode input skips the permission prompt") } } func TestLaunchContinuesWhenGrantFails(t *testing.T) { companion := &fakeCompanion{accessibilityJSON: "[]"} d := newTestDriver(companion) d.grantPaste = func(context.Context) error { return errors.New("no privacy db") } if err := d.Launch(context.Background(), "", false, nil); err != nil { t.Fatalf("Launch must continue when the grant fails: %v", err) } if indexOf(companion.calls, "launch") < 0 { t.Fatalf("app must still launch; got %v", companion.calls) } } // 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 }, terminateApp: func(context.Context) error { probe.record("stop app"); 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{"stop app", "reinstall", "runner session"} if got := probe.recorded(); !slices.Equal(got, want) { t.Fatalf("calls = %v, want %v: the reinstall must run once, on a stopped app, 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{"stop app", "reset container", "runner session"} if got := probe.recorded(); !slices.Equal(got, want) { t.Fatalf("calls = %v, want %v: the fallback must wipe a stopped app's 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: "[]"} d := newTestDriver(companion) d.appPath = "/tmp/Sample.app" reinstalls := 0 d.reinstallApp = func(context.Context) error { reinstalls++; return nil } 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 != 0 { t.Fatalf("reinstalls = %d, want 0: a live session must never have the app reinstalled under it", reinstalls) } if indexOf(companion.recorded(), "launch") >= 0 { t.Fatalf("a refused launch must not reach the companion; got %v", companion.recorded()) } } // TestLaunchRefusesClearStateForABundleItDidNotClear holds the guard to the // fact it is guarding. A driver built to clear one bundle has cleared nothing // for another, so reporting that launch as a clear-state launch is a reset the // caller was told happened and did not. func TestLaunchRefusesClearStateForABundleItDidNotClear(t *testing.T) { companion := &fakeCompanion{accessibilityJSON: "[]"} d := newTestDriver(companion) d.clearedBundleID = "com.example.app" err := d.Launch(context.Background(), "com.other.app", true, nil) if err == nil || !strings.Contains(err.Error(), "clear-state") { t.Fatalf("Launch err = %v, want a refusal naming clear-state: com.other.app was never cleared", err) } if indexOf(companion.recorded(), "launch") >= 0 { t.Fatalf("a launch reporting a clear that never happened must not reach the companion; got %v", companion.recorded()) } } func TestNewRejectsClearStateWithoutBundleID(t *testing.T) { probe := &clearStateProbe{} options := clearStateOptions(t, probe, "NO-BUNDLE-UDID", true) options.BundleID = "" if _, err := New(context.Background(), options); err == nil || !strings.Contains(err.Error(), "BundleID") { t.Fatalf("New err = %v, want a refusal naming BundleID", err) } if got := probe.recorded(); len(got) != 0 { t.Fatalf("calls = %v, want none: clearing an unnamed bundle would reinstall without resetting anything", got) } } // scriptedXcrun puts an xcrun on PATH that logs each invocation's arguments and // answers from replies, a `case "$*" in` body, so a reinstall runs its real // command sequence and the log holds what reached the tool. func scriptedXcrun(t *testing.T, replies string) string { t.Helper() directory := t.TempDir() log := filepath.Join(directory, "xcrun.log") script := "#!/bin/sh\necho \"$*\" >> " + log + "\ncase \"$*\" in\n" + replies + "\nesac\n" if err := os.WriteFile(filepath.Join(directory, "xcrun"), []byte(script), 0o755); err != nil { t.Fatalf("write xcrun: %v", err) } t.Setenv("PATH", directory) return log } func xcrunCalls(t *testing.T, log string) []string { t.Helper() contents, err := os.ReadFile(log) if err != nil { if os.IsNotExist(err) { return nil } t.Fatalf("read %s: %v", log, err) } return strings.Split(strings.TrimSpace(string(contents)), "\n") } func TestSimctlReinstallStopsWhenTheUninstallFails(t *testing.T) { log := scriptedXcrun(t, `"simctl uninstall "*) echo "Simulator device failed to uninstall app.example."; echo "Uninstall prohibited."; exit 22;; "simctl install "*) :;;`) d := &Driver{udid: "SIM-UDID", bundleID: "app.example", appPath: "/tmp/Sample.app"} err := d.simctlReinstall(context.Background()) if err == nil { t.Fatal("simctlReinstall reported success while app.example kept the data clear-state was asked to remove") } for _, want := range []string{"app.example", "Uninstall prohibited."} { if !strings.Contains(err.Error(), want) { t.Errorf("error %q does not quote %q", err, want) } } if calls := xcrunCalls(t, log); slices.Contains(calls, "simctl install SIM-UDID /tmp/Sample.app") { t.Errorf("xcrun calls = %v: installing over the app carries its data into the run", calls) } } func TestSimctlReinstallProceedsWhenNothingIsInstalled(t *testing.T) { log := scriptedXcrun(t, `"simctl uninstall "*) :;; "simctl install "*) :;;`) d := &Driver{udid: "SIM-UDID", bundleID: "app.example", appPath: "/tmp/Sample.app"} if err := d.simctlReinstall(context.Background()); err != nil { t.Fatalf("simctlReinstall: %v", err) } want := []string{"simctl uninstall SIM-UDID app.example", "simctl install SIM-UDID /tmp/Sample.app"} if got := xcrunCalls(t, log); !slices.Equal(got, want) { t.Fatalf("xcrun calls = %v, want %v", got, want) } } // TestClearStateStopsTheAppBeforeWipingItsContainer covers the ordering Launch // used to hold. simctl uninstall copes with a running app; deleting the data // container out from under one does not, and the CI iOS leg passes no app path // so it is the wipe that runs. A run whose previous run was interrupted finds // the app still up. func TestClearStateStopsTheAppBeforeWipingItsContainer(t *testing.T) { container := t.TempDir() stale := filepath.Join(container, "Documents") if err := os.Mkdir(stale, 0o755); err != nil { t.Fatal(err) } log := scriptedXcrun(t, `"simctl terminate "*) :;; "simctl get_app_container "*) echo `+container+`;;`) d := &Driver{udid: "SIM-UDID", bundleID: "app.example", output: &bytes.Buffer{}} d.terminateApp = d.simctlTerminate d.resetContainer = d.resetDataContainer if err := d.clearAppState(context.Background()); err != nil { t.Fatalf("clearAppState: %v", err) } want := []string{ "simctl terminate SIM-UDID app.example", "simctl get_app_container SIM-UDID app.example data", } if got := xcrunCalls(t, log); !slices.Equal(got, want) { t.Fatalf("xcrun calls = %v, want %v: the app was still writing to the container being deleted", got, want) } if _, err := os.Stat(stale); !os.IsNotExist(err) { t.Fatalf("stat %s = %v, want the previous run's state gone", stale, err) } } // TestClearStateSurvivesAnAppThatIsNotRunning holds the terminate to best // effort. simctl exits non-zero when there is nothing to stop, and a first run // on a fresh simulator must not fail on it. func TestClearStateSurvivesAnAppThatIsNotRunning(t *testing.T) { container := t.TempDir() scriptedXcrun(t, `"simctl terminate "*) echo "No matching processes belonging to bundle identifier app.example"; exit 3;; "simctl get_app_container "*) echo `+container+`;;`) d := &Driver{udid: "SIM-UDID", bundleID: "app.example", output: &bytes.Buffer{}} d.terminateApp = d.simctlTerminate d.resetContainer = d.resetDataContainer if err := d.clearAppState(context.Background()); err != nil { t.Fatalf("clearAppState: %v: an app that is not running is not a failure to clear", err) } } func TestLaunchRejectsEnvironment(t *testing.T) { d := newTestDriver(&fakeCompanion{accessibilityJSON: "[]"}) err := d.Launch(context.Background(), "", false, map[string]string{"K": "V"}) if err == nil || !strings.Contains(err.Error(), "environment") { t.Fatalf("Launch with env: err = %v, want unsupported-environment error", err) } } func TestSnapshotPairsHierarchyAndScreenshot(t *testing.T) { companion := &fakeCompanion{ accessibilityJSON: "[]", screenshotData: samplePNG(t, 390, 844), } d := newTestDriver(companion) _, image, err := d.Snapshot(context.Background()) if err != nil { t.Fatalf("Snapshot: %v", err) } if image.Width != 390 || image.Height != 844 { t.Fatalf("image dims = %dx%d, want 390x844", image.Width, image.Height) } // The two captures run concurrently (they ride different transports on // the hybrid path), so both must happen but in no particular order. calls := companion.recorded() if indexOf(calls, "accessibility") < 0 || indexOf(calls, "screenshot") < 0 { t.Fatalf("snapshot must capture hierarchy and screenshot; got %v", calls) } } // blockingScreenshotCompanion holds its Screenshot until proceed closes, so a // test can keep the screenshot leg in flight while the hierarchy leg recovers. type blockingScreenshotCompanion struct { fakeCompanion proceed chan struct{} } func (b *blockingScreenshotCompanion) Screenshot(ctx context.Context) ([]byte, string, error) { <-b.proceed return b.fakeCompanion.Screenshot(ctx) } func TestSnapshotRestartDuringScreenshotDoesNotRace(t *testing.T) { // The hierarchy leg drops its connection, forcing withRecovery to restart // while the screenshot goroutine is still in flight. The restart reassigns // d.companion the way respawnAndRedial does; the goroutine must keep // working through the transport it captured rather than racing the field. first := &blockingScreenshotCompanion{ fakeCompanion: fakeCompanion{ accessibilityErr: status.Error(codes.Unavailable, "companion gone"), screenshotData: samplePNG(t, 390, 844), }, proceed: make(chan struct{}), } replacement := &fakeCompanion{ accessibilityJSON: "[]", screenshotData: samplePNG(t, 390, 844), } d := newTestDriver(first) d.restart = func(context.Context) error { d.companion = replacement close(first.proceed) return nil } _, image, err := d.Snapshot(context.Background()) if err != nil { t.Fatalf("Snapshot should recover: %v", err) } if image.Width != 390 || image.Height != 844 { t.Fatalf("image dims = %dx%d, want 390x844", image.Width, image.Height) } } func TestScreenshotRejectsNonPNG(t *testing.T) { d := newTestDriver(&fakeCompanion{screenshotData: []byte("not a png")}) if _, err := d.Screenshot(context.Background()); err == nil { t.Fatal("Screenshot of non-PNG should error") } } func TestInputTextFastPathSkipsFieldResolution(t *testing.T) { companion := &fakeCompanion{accessibilityJSON: "[]"} d := newTestDriver(companion) // "abc" is fully mappable and under the paste threshold, so the fast // keyboard path runs and never reads the accessibility dump for a field // target. if err := d.InputText(context.Background(), "abc"); err != nil { t.Fatalf("InputText: %v", err) } if indexOf(companion.calls, "hid") < 0 { t.Fatalf("fast path must send HID; got %v", companion.calls) } if indexOf(companion.calls, "accessibility") >= 0 { t.Fatalf("fast path must not resolve a field via accessibility; got %v", companion.calls) } } func TestInputTextPasteboardResolvesFieldFromLastTap(t *testing.T) { // The dump carries one editable field whose frame contains the last tap. dump := `[{"type":"TextField","AXUniqueId":"field-1","frame":{"x":10,"y":100,"width":200,"height":40}}]` companion := &fakeCompanion{accessibilityJSON: dump, screenshotData: samplePNG(t, 10, 10)} d := newTestDriver(companion) // Tap inside the field so lastTap lands on it. if err := d.Tap(context.Background(), 50, 120); err != nil { t.Fatalf("Tap: %v", err) } field := d.resolveInputField(context.Background()) if field.identifier != "field-1" { t.Fatalf("resolved field id = %q, want field-1", field.identifier) } if field.centerX != 110 || field.centerY != 120 { t.Fatalf("field center = (%v,%v), want (110,120)", field.centerX, field.centerY) } } func TestResolveInputFieldEmptyWhenTapOutsideAnyField(t *testing.T) { dump := `[{"type":"TextField","AXUniqueId":"field-1","frame":{"x":10,"y":100,"width":200,"height":40}}]` d := newTestDriver(&fakeCompanion{accessibilityJSON: dump}) if err := d.Tap(context.Background(), 5, 5); err != nil { t.Fatalf("Tap: %v", err) } if field := d.resolveInputField(context.Background()); field != (fieldTarget{}) { t.Fatalf("field = %+v, want empty", field) } } func TestSupervisionRestartsOnceThenSucceeds(t *testing.T) { companion := &fakeCompanion{ accessibilityJSON: "[]", hidErr: status.Error(codes.Unavailable, "connection refused"), } d := newTestDriver(companion) restarts := 0 d.restart = func(context.Context) error { restarts++ return nil } // First SendHID fails with Unavailable; the recovery restarts once and the // retry succeeds (hidErr cleared itself). if err := d.Tap(context.Background(), 10, 10); err != nil { t.Fatalf("Tap should recover: %v", err) } if restarts != 1 { t.Fatalf("restarts = %d, want exactly 1", restarts) } } func TestSupervisionDoesNotRestartOnNormalError(t *testing.T) { companion := &fakeCompanion{hidErr: errors.New("bad argument")} d := newTestDriver(companion) restarts := 0 d.restart = func(context.Context) error { restarts++; return nil } if err := d.Tap(context.Background(), 10, 10); err == nil { t.Fatal("non-connection error should surface") } if restarts != 0 { t.Fatalf("restarts = %d, want 0 on a normal error", restarts) } } func TestSupervisionBudgetResetsBetweenIncidents(t *testing.T) { companion := &fakeCompanion{accessibilityJSON: "[]"} d := newTestDriver(companion) restarts := 0 d.restart = func(context.Context) error { restarts++; return nil } companion.hidErr = status.Error(codes.Unavailable, "drop one") if err := d.Tap(context.Background(), 1, 1); err != nil { t.Fatalf("first incident: %v", err) } companion.hidErr = status.Error(codes.Unavailable, "drop two") if err := d.Tap(context.Background(), 2, 2); err != nil { t.Fatalf("second incident: %v", err) } if restarts != 2 { t.Fatalf("restarts = %d, want 2 (one per incident)", restarts) } } func TestPressKeyEnterSupportedOthersUnsupported(t *testing.T) { d := newTestDriver(&fakeCompanion{}) if err := d.PressKey(context.Background(), "enter"); err != nil { t.Fatalf("PressKey enter: %v", err) } if err := d.PressKey(context.Background(), "return"); err != nil { t.Fatalf("PressKey return: %v", err) } for _, key := range []string{"back", "home", "tab"} { if err := d.PressKey(context.Background(), key); err == nil { t.Fatalf("PressKey %q should be unsupported", key) } } } func TestWaitForIdleSettlesOnStableTree(t *testing.T) { companion := &fakeCompanion{accessibilityJSON: "[]"} d := newTestDriver(companion) // A fake clock advances time only on Sleep, so the settle poll runs to its // cap instantly instead of taking the real two seconds. d.idleClock = &fakeClock{} if err := d.WaitForIdle(context.Background(), time.Second); err != nil { t.Fatalf("WaitForIdle: %v", err) } if indexOf(companion.calls, "accessibility") < 0 { t.Fatalf("WaitForIdle must poll the hierarchy; got %v", companion.calls) } } func TestForegroundAppPrefersAppUnderTest(t *testing.T) { companion := &fakeCompanion{apps: []transport.InstalledApp{ {BundleID: "com.example.app", ProcessState: transport.ProcessStateRunning, InstallType: "user"}, {BundleID: "com.other.app", ProcessState: transport.ProcessStateRunning, InstallType: "user"}, }} d := newTestDriver(companion) got, err := d.ForegroundApp(context.Background()) if err != nil { t.Fatalf("ForegroundApp: %v", err) } if got != "com.example.app" { t.Fatalf("ForegroundApp = %q, want com.example.app", got) } } func TestForegroundAppFallsBackToOtherRunningUserApp(t *testing.T) { companion := &fakeCompanion{apps: []transport.InstalledApp{ {BundleID: "com.example.app", ProcessState: transport.ProcessStateNotRunning, InstallType: "user"}, {BundleID: "com.other.app", ProcessState: transport.ProcessStateRunning, InstallType: "user"}, }} d := newTestDriver(companion) got, err := d.ForegroundApp(context.Background()) if err != nil { t.Fatalf("ForegroundApp: %v", err) } if got != "com.other.app" { t.Fatalf("ForegroundApp = %q, want com.other.app", got) } } func TestHealthReportsIOS(t *testing.T) { d := newTestDriver(&fakeCompanion{}) health, err := d.Health(context.Background()) if err != nil { t.Fatalf("Health: %v", err) } if !health.Ready || health.Platform != "ios" { t.Fatalf("Health = %+v, want ready ios", health) } } // The driver must satisfy DeviceDriver, ForegroundChecker, and TextReplacer // (its InputText clears the field then types, so the runner skips its // blackout-fragile pre-erase), but must NOT satisfy FocusedWindowChecker. func TestCapabilityAssertions(t *testing.T) { var instance any = newTestDriver(&fakeCompanion{}) if _, ok := instance.(driver.DeviceDriver); !ok { t.Fatal("Driver must implement DeviceDriver") } if _, ok := instance.(driver.ForegroundChecker); !ok { t.Fatal("Driver must implement ForegroundChecker") } replacer, ok := instance.(driver.TextReplacer) if !ok { t.Fatal("Driver must implement TextReplacer") } if !replacer.ReplacesTextOnInput() { t.Fatal("ReplacesTextOnInput must report true") } if _, ok := instance.(driver.FocusedWindowChecker); ok { t.Fatal("Driver must NOT implement FocusedWindowChecker") } } func indexOf(slice []string, value string) int { for i, item := range slice { if item == value { return i } } return -1 } // TestNewChildOutlivesStartup proves the companion child is spawned under the // driver-lifetime context, not the startup-scoped one: a startup context that // is canceled when New returns would SIGTERM the child mid-run. func TestNewChildOutlivesStartup(t *testing.T) { // The legacy-only path keeps this test focused on the companion child; // the hybrid default would also demand runner assets. t.Setenv("SANDERLING_SIMULATOR_COMPANION", "legacy") listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer listener.Close() go func() { for { connection, acceptErr := listener.Accept() if acceptErr != nil { return } _ = connection.Close() } }() var spawnContext context.Context options := Options{ UniqueDeviceIdentifier: "FAKE-UDID", pickAddress: func() (string, error) { return listener.Addr().String(), nil }, spawnChild: func(ctx context.Context, _ string) (*exec.Cmd, error) { spawnContext = ctx return &exec.Cmd{}, nil }, dialCompanion: func(string) (transport.Companion, error) { return &fakeCompanion{accessibilityJSON: "[]"}, nil }, } d, err := New(context.Background(), options) if err != nil { t.Fatal(err) } select { case <-spawnContext.Done(): t.Fatal("spawn context canceled after New returned; child would receive SIGTERM mid-run") default: } d.Close() select { case <-spawnContext.Done(): default: t.Fatal("spawn context still alive after Close; child lifetime leaks") } } // fakeTextEditingCompanion extends fakeCompanion with the optional TextEditor // capability so routing through the native text path is testable. type fakeTextEditingCompanion struct { fakeCompanion inputTexts []string eraseCounts []int pressedKeys []string } func (f *fakeTextEditingCompanion) InputText(_ context.Context, text string) error { f.record("inputtext") f.inputTexts = append(f.inputTexts, text) return nil } func (f *fakeTextEditingCompanion) EraseText(_ context.Context, characterCount int) error { f.record("erasetext") f.eraseCounts = append(f.eraseCounts, characterCount) return nil } func (f *fakeTextEditingCompanion) PressKey(_ context.Context, key string) error { f.record("presskey") f.pressedKeys = append(f.pressedKeys, key) return nil } var _ transport.TextEditor = (*fakeTextEditingCompanion)(nil) func TestInputTextRoutesThroughTextEditor(t *testing.T) { companion := &fakeTextEditingCompanion{} d := newTestDriver(companion) if err := d.InputText(context.Background(), "héllo 🌟"); err != nil { t.Fatal(err) } if len(companion.inputTexts) != 1 || companion.inputTexts[0] != "héllo 🌟" { t.Fatalf("inputTexts = %v, want the typed text once", companion.inputTexts) } for _, call := range companion.calls { if call == "hid" { t.Fatal("text editor path must not compose HID streams") } } } func TestEraseTextRoutesThroughTextEditor(t *testing.T) { companion := &fakeTextEditingCompanion{} d := newTestDriver(companion) if err := d.EraseText(context.Background(), 7); err != nil { t.Fatal(err) } if len(companion.eraseCounts) != 1 || companion.eraseCounts[0] != 7 { t.Fatalf("eraseCounts = %v, want [7]", companion.eraseCounts) } } func TestPressKeyRoutesThroughTextEditor(t *testing.T) { companion := &fakeTextEditingCompanion{} d := newTestDriver(companion) if err := d.PressKey(context.Background(), "enter"); err != nil { t.Fatal(err) } if len(companion.pressedKeys) != 1 || companion.pressedKeys[0] != "enter" { t.Fatalf("pressedKeys = %v, want [enter]", companion.pressedKeys) } for _, call := range companion.calls { if call == "hid" { t.Fatal("text editor path must not compose HID streams") } } } func TestIsConnectionErrorRecognizesUnavailableSentinel(t *testing.T) { wrapped := errors.Join(errors.New("dial tcp: connection refused"), transport.ErrCompanionUnavailable) if !isConnectionError(wrapped) { t.Fatal("wrapped ErrCompanionUnavailable must count as a connection error") } if isConnectionError(errors.New("ordinary failure")) { t.Fatal("ordinary errors must not count as connection errors") } } // fakeRunnerCompanion stands in for the in-simulator runner half of the hybrid: // it serves snapshots and native typing. type fakeRunnerCompanion struct { fakeCompanion typed []struct { text string replace bool } } func (f *fakeRunnerCompanion) TypeText(_ context.Context, text string, replace bool) error { f.record("typetext") f.typed = append(f.typed, struct { text string replace bool }{text, replace}) return nil } var _ transport.TextTyper = (*fakeRunnerCompanion)(nil) func newHybridTestDriver(legacy *fakeCompanion, runner *fakeRunnerCompanion) *Driver { d := newTestDriver(legacy) d.hybrid = true d.runnerClient = runner return d } func TestHybridInputTextClearsViaHIDThenTypes(t *testing.T) { legacy := &fakeCompanion{accessibilityJSON: "[]"} runner := &fakeRunnerCompanion{} d := newHybridTestDriver(legacy, runner) if err := d.InputText(context.Background(), "héllo 🌟"); err != nil { t.Fatal(err) } if len(legacy.calls) != 1 || legacy.calls[0] != "hid" { t.Fatalf("legacy calls = %v, want exactly the clear chord", legacy.calls) } if len(runner.typed) != 1 || runner.typed[0].text != "héllo 🌟" || runner.typed[0].replace { t.Fatalf("typed = %+v, want the text appended without replace", runner.typed) } } func TestHybridSnapshotsComeFromRunner(t *testing.T) { legacy := &fakeCompanion{accessibilityJSON: `[{"type":"Application","frame":{"x":0,"y":0,"width":1,"height":1},"enabled":true}]`} runner := &fakeRunnerCompanion{} runner.accessibilityJSON = `[{"type":"Button","AXUniqueId":"FromRunner","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true}]` d := newHybridTestDriver(legacy, runner) hierarchy, err := d.Hierarchy(context.Background()) if err != nil { t.Fatal(err) } if !strings.Contains(hierarchy, "FromRunner") { t.Fatalf("hierarchy = %s, want runner content", hierarchy) } for _, call := range legacy.calls { if call == "accessibility" { t.Fatal("hybrid must not read snapshots from the legacy companion") } } } func TestHybridLaunchSkipsPasteGrant(t *testing.T) { legacy := &fakeCompanion{accessibilityJSON: "[]"} runner := &fakeRunnerCompanion{} d := newHybridTestDriver(legacy, runner) granted := 0 d.grantPaste = func(context.Context) error { granted++; return nil } if err := d.Launch(context.Background(), "", false, nil); err != nil { t.Fatal(err) } if granted != 0 { t.Fatalf("grantPaste called %d times, want 0 on the hybrid path", granted) } } func TestHybridGesturesStayOnLegacyHID(t *testing.T) { legacy := &fakeCompanion{accessibilityJSON: "[]"} runner := &fakeRunnerCompanion{} d := newHybridTestDriver(legacy, runner) if err := d.DoubleTap(context.Background(), 10, 20); err != nil { t.Fatal(err) } if len(legacy.calls) != 1 || legacy.calls[0] != "hid" { t.Fatalf("legacy calls = %v, want the double-tap HID stream", legacy.calls) } for _, call := range runner.calls { if call == "hid" { t.Fatal("runner must not receive gesture streams") } } } func TestHybridRunnerErrorTriggersRestart(t *testing.T) { legacy := &fakeCompanion{accessibilityJSON: "[]"} runner := &fakeRunnerCompanion{} runner.accessibilityErr = fmt.Errorf("read snapshot: %w", transport.ErrCompanionUnavailable) d := newHybridTestDriver(legacy, runner) restarts := 0 d.restart = func(context.Context) error { restarts++ runner.accessibilityErr = nil return nil } if _, err := d.Hierarchy(context.Background()); err != nil { t.Fatal(err) } if restarts != 1 { t.Fatalf("restarts = %d, want 1", restarts) } } func TestBindTestRunPortSubstitutesPlaceholder(t *testing.T) { directory := t.TempDir() source := filepath.Join(directory, "runner.xctestrun") if err := os.WriteFile(source, []byte("__COMPANION_PORT__"), 0o644); err != nil { t.Fatal(err) } bound, err := bindTestRunPort(source, "27999") if err != nil { t.Fatal(err) } if filepath.Dir(bound) != directory { t.Fatalf("bound copy %s must sit next to the original", bound) } content, err := os.ReadFile(bound) if err != nil { t.Fatal(err) } if string(content) != "27999" { t.Fatalf("bound content = %s", content) } } func TestBindTestRunPortRejectsMissingPlaceholder(t *testing.T) { directory := t.TempDir() source := filepath.Join(directory, "runner.xctestrun") if err := os.WriteFile(source, []byte("fixed"), 0o644); err != nil { t.Fatal(err) } if _, err := bindTestRunPort(source, "27999"); err == nil { t.Fatal("expected an error for a configuration without the placeholder") } } func TestHybridMappableTextRidesOneHIDStream(t *testing.T) { legacy := &fakeCompanion{accessibilityJSON: "[]"} runner := &fakeRunnerCompanion{} d := newHybridTestDriver(legacy, runner) if err := d.InputText(context.Background(), "Travel 42"); err != nil { t.Fatal(err) } if len(legacy.recorded()) != 1 || legacy.recorded()[0] != "hid" { t.Fatalf("legacy calls = %v, want one combined chord-and-keystrokes stream", legacy.recorded()) } if len(runner.typed) != 0 { t.Fatalf("typed = %+v, want no native typing for mappable text", runner.typed) } } func TestHybridUnicodeWaitsForClearedFieldBeforeTyping(t *testing.T) { legacy := &fakeCompanion{accessibilityJSON: "[]"} runner := &fakeRunnerCompanion{} // The focused field still shows old content on the first read and is // empty on the second; typing must come after the cleared read. first := `[{"type":"TextField","AXUniqueId":"F","AXValue":"old","frame":{"x":0,"y":0,"width":100,"height":40},"enabled":true}]` second := `[{"type":"TextField","AXUniqueId":"F","AXValue":"","frame":{"x":0,"y":0,"width":100,"height":40},"enabled":true}]` reads := 0 d := newHybridTestDriver(legacy, runner) d.mu.Lock() d.lastTap.x, d.lastTap.y, d.lastTap.set = 50, 20, true d.mu.Unlock() // Swap the dump after the first read through a wrapper companion. wrapped := &sequencedDumpCompanion{fakeRunnerCompanion: runner, dumps: []string{first, second}, reads: &reads} d.runnerClient = wrapped if err := d.InputText(context.Background(), "héllo 🌟"); err != nil { t.Fatal(err) } if reads < 2 { t.Fatalf("reads = %d, want at least 2 (poll until cleared)", reads) } if len(wrapped.typed) != 1 || wrapped.typed[0].text != "héllo 🌟" || wrapped.typed[0].replace { t.Fatalf("typed = %+v", wrapped.typed) } } // sequencedDumpCompanion serves scripted dumps in order, repeating the last. type sequencedDumpCompanion struct { *fakeRunnerCompanion dumps []string reads *int } func (s *sequencedDumpCompanion) AccessibilityInfo(context.Context) (string, error) { index := *s.reads if index >= len(s.dumps) { index = len(s.dumps) - 1 } *s.reads++ return s.dumps[index], nil } // wedgedLifecycleCompanion never answers a lifecycle RPC, standing in for a // runner whose XCTest session is stuck inside a rejected launch. type wedgedLifecycleCompanion struct { fakeCompanion release chan struct{} } func (w *wedgedLifecycleCompanion) block(ctx context.Context) error { select { case <-ctx.Done(): return ctx.Err() case <-w.release: return nil } } func (w *wedgedLifecycleCompanion) Launch(ctx context.Context, _ string, _ bool) error { return w.block(ctx) } func (w *wedgedLifecycleCompanion) Terminate(ctx context.Context, _ string) error { return w.block(ctx) } // TestLaunchBoundsWedgedLifecycleRPC proves the launch path carries its own // deadline. Callers hand Launch an undeadlined context, so without one a runner // that never answers hangs the run forever with nothing printed. func TestLaunchBoundsWedgedLifecycleRPC(t *testing.T) { previous := launchTimeout launchTimeout = 100 * time.Millisecond defer func() { launchTimeout = previous }() companion := &wedgedLifecycleCompanion{release: make(chan struct{})} defer close(companion.release) d := newTestDriver(companion) done := make(chan error, 1) go func() { done <- d.Launch(context.Background(), "com.example.app", false, nil) }() select { case err := <-done: if err == nil { t.Fatal("wedged launch returned nil; a stuck runner must surface an error") } if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("err = %v, want a deadline-exceeded error", err) } case <-time.After(10 * time.Second): t.Fatal("Launch never returned: the lifecycle RPC is unbounded, so a stuck runner hangs the run forever") } } // TestTerminateBoundsWedgedLifecycleRPC covers the same bound on the standalone // terminate, which the runner calls mid-run on an equally stuck session. func TestTerminateBoundsWedgedLifecycleRPC(t *testing.T) { previous := launchTimeout launchTimeout = 100 * time.Millisecond defer func() { launchTimeout = previous }() companion := &wedgedLifecycleCompanion{release: make(chan struct{})} defer close(companion.release) d := newTestDriver(companion) done := make(chan error, 1) go func() { done <- d.Terminate(context.Background()) }() select { case err := <-done: if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("err = %v, want a deadline-exceeded error", err) } case <-time.After(10 * time.Second): t.Fatal("Terminate never returned: the lifecycle RPC is unbounded") } } // TestLaunchLeavesATighterCallerDeadlineAlone confirms the bound narrows the // caller's context and never widens it, so a caller that wants to give up // sooner still does. func TestLaunchLeavesATighterCallerDeadlineAlone(t *testing.T) { previous := launchTimeout launchTimeout = 30 * time.Second defer func() { launchTimeout = previous }() companion := &wedgedLifecycleCompanion{release: make(chan struct{})} defer close(companion.release) d := newTestDriver(companion) ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() start := time.Now() if err := d.Launch(ctx, "com.example.app", false, nil); !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("err = %v, want a deadline-exceeded error", err) } if elapsed := time.Since(start); elapsed > 5*time.Second { t.Fatalf("Launch took %v; the driver's bound overrode the caller's tighter deadline", elapsed) } } // blownBudgetShape is one way a transport the driver launches through reports // a lifecycle call outliving its budget. type blownBudgetShape struct { name string err error } // blownBudgetShapes drives every such transport against a server that never // answers and returns the error each one really produces. The runner transport // has two: wrapTransport wraps the context's own error once cancellation has // landed, and the connection's i/o timeout when the deadline it armed from // that context fires first. The legacy transport reports the same expiry as a // gRPC status. Only the first satisfies errors.Is(err, context.DeadlineExceeded), // so a fake that returns ctx.Err() raw shows the driver a recovery that two // thirds of production can never reach. func blownBudgetShapes(t *testing.T) []blownBudgetShape { t.Helper() return []blownBudgetShape{ {"runner context deadline", runnerContextDeadlineError(t)}, {"runner connection deadline", silentRunnerLaunchError(t, connectionDeadlineOnly{time.Now().Add(100 * time.Millisecond)})}, {"legacy grpc deadline", silentGRPCLaunchError(t)}, } } // runnerContextDeadlineError is the shape the runner transport produces once // the context's own cancellation has landed. func runnerContextDeadlineError(t *testing.T) error { t.Helper() ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) defer cancel() return silentRunnerLaunchError(t, ctx) } // connectionDeadlineOnly carries a deadline the runner transport arms the // connection with, while its own cancellation never lands. That is the race // wrapTransport's second branch exists for: the connection's deadline fires // while ctx.Err() is still nil. type connectionDeadlineOnly struct{ deadline time.Time } func (c connectionDeadlineOnly) Deadline() (time.Time, bool) { return c.deadline, true } func (c connectionDeadlineOnly) Done() <-chan struct{} { return nil } func (c connectionDeadlineOnly) Err() error { return nil } func (c connectionDeadlineOnly) Value(any) any { return nil } // silentRunnerLaunchError returns what the real runner transport produces for a // launch nobody ever answers. func silentRunnerLaunchError(t *testing.T, ctx context.Context) error { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } closed := make(chan struct{}) go func() { conn, acceptErr := listener.Accept() if acceptErr != nil { return } defer conn.Close() <-closed }() companion, err := transport.DialRunner(listener.Addr().String(), "SIM-UDID", "com.example.app") if err != nil { listener.Close() t.Fatal(err) } t.Cleanup(func() { close(closed) _ = companion.Close() listener.Close() }) launchErr := companion.Launch(ctx, "com.example.app", true) if launchErr == nil { t.Fatal("the runner transport reported a launch no server ever answered") } return launchErr } // silentCompanionServer is the legacy companion with a launch that never // answers, so the caller's own deadline is what ends the call. type silentCompanionServer struct { companionpb.UnimplementedCompanionServiceServer } func (silentCompanionServer) Launch(stream grpc.BidiStreamingServer[companionpb.LaunchRequest, companionpb.LaunchResponse]) error { <-stream.Context().Done() return stream.Context().Err() } // silentGRPCLaunchError returns what the legacy transport produces for the same // launch, which SANDERLING_SIMULATOR_COMPANION=legacy still runs on. func silentGRPCLaunchError(t *testing.T) error { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } server := grpc.NewServer() companionpb.RegisterCompanionServiceServer(server, silentCompanionServer{}) go server.Serve(listener) t.Cleanup(server.Stop) companion, err := transport.Dial(listener.Addr().String()) if err != nil { t.Fatal(err) } t.Cleanup(func() { companion.Close() }) ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond) defer cancel() launchErr := companion.Launch(ctx, "com.example.app", true) if launchErr == nil { t.Fatal("the legacy transport reported a launch the companion never answered") } return launchErr } // wedgedUntilRestartCompanion models the session a refused launch leaves // behind: the refusal is never reported, no later launch is answered until the // session itself is replaced, and the expired bound reaches the driver in // whatever shape its transport gives it. type wedgedUntilRestartCompanion struct { fakeCompanion blownBudget error mutex sync.Mutex replaced bool attempted int } func (w *wedgedUntilRestartCompanion) replaceSession() { w.mutex.Lock() defer w.mutex.Unlock() w.replaced = true } func (w *wedgedUntilRestartCompanion) launchAttempts() int { w.mutex.Lock() defer w.mutex.Unlock() return w.attempted } func (w *wedgedUntilRestartCompanion) Launch(ctx context.Context, _ string, _ bool) error { w.mutex.Lock() w.attempted++ replaced := w.replaced w.mutex.Unlock() if replaced { return nil } <-ctx.Done() return w.blownBudget } // TestLaunchReplacesTheSessionAfterALaunchBlowsItsBound covers the FrontBoard // race: a clear-state reinstall the simulator has not finished registering // makes the session refuse the launch, and XCTest answers that refusal with // minutes of diagnostics instead of an error, so the bound expires and every // later call queues behind the same wedge. Calling launch again on that session // cannot work; the run only recovers if the session is replaced first. The // recovery has to fire on every shape the driver's transports report that // expiry in, because which one arrives is a race the driver does not control. func TestLaunchReplacesTheSessionAfterALaunchBlowsItsBound(t *testing.T) { for _, shape := range blownBudgetShapes(t) { t.Run(shape.name, func(t *testing.T) { previous := launchTimeout launchTimeout = 100 * time.Millisecond defer func() { launchTimeout = previous }() companion := &wedgedUntilRestartCompanion{blownBudget: shape.err} output := &bytes.Buffer{} d := newTestDriver(companion) d.output = output restarts := 0 d.restart = func(context.Context) error { restarts++ companion.replaceSession() return nil } if err := d.Launch(context.Background(), "", false, nil); err != nil { t.Fatalf("Launch: %v", err) } if restarts != 1 { t.Fatalf("session restarts = %d, want exactly 1 (the session reported %v)", restarts, shape.err) } if attempts := companion.launchAttempts(); attempts != 2 { t.Fatalf("launch attempts = %d, want 2: one that wedged and one on the replaced session", attempts) } if !strings.Contains(output.String(), "restarting the session") { t.Fatalf("the recovery was silent, so a run that needed it never says so; output was %q", output.String()) } }) } } // TestLaunchBoundsTheSessionRestartItTriggers keeps the recovery inside a // budget of its own. The restart deliberately runs on the driver's lifetime // context rather than the caller's, so without a deadline a session that never // comes back would hang the launch path exactly the way #73 stopped it hanging. func TestLaunchBoundsTheSessionRestartItTriggers(t *testing.T) { previousLaunch, previousRecovery := launchTimeout, launchRecoveryTimeout launchTimeout = 100 * time.Millisecond launchRecoveryTimeout = 200 * time.Millisecond defer func() { launchTimeout, launchRecoveryTimeout = previousLaunch, previousRecovery }() d := newTestDriver(&wedgedUntilRestartCompanion{blownBudget: runnerContextDeadlineError(t)}) d.restart = func(restartCtx context.Context) error { <-restartCtx.Done() return restartCtx.Err() } done := make(chan error, 1) go func() { done <- d.Launch(context.Background(), "", false, nil) }() select { case err := <-done: if err == nil || !strings.Contains(err.Error(), "session restart failed") { t.Fatalf("err = %v, want the failed restart named", err) } case <-time.After(10 * time.Second): t.Fatal("Launch never returned: a session that never comes back hangs the launch path") } } // TestLaunchKeepsTheSessionWhenTheCallersOwnDeadlineExpires holds the recovery // to the driver's own bound. Spending a session restart on a caller that has // already run out of budget cannot produce a launch, only a later failure. func TestLaunchKeepsTheSessionWhenTheCallersOwnDeadlineExpires(t *testing.T) { previous := launchTimeout launchTimeout = 30 * time.Second defer func() { launchTimeout = previous }() companion := &wedgedUntilRestartCompanion{blownBudget: runnerContextDeadlineError(t)} d := newTestDriver(companion) restarts := 0 d.restart = func(context.Context) error { restarts++ companion.replaceSession() return nil } ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() if err := d.Launch(ctx, "", false, nil); !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("err = %v, want a deadline-exceeded error", err) } if restarts != 0 { t.Fatalf("session restarts = %d, want 0", restarts) } } // refusedLaunchCompanion answers a launch the way the runner does once it // checks the app's state after activating it: promptly, naming the app and the // state it reached, over a session that is still serving. type refusedLaunchCompanion struct { fakeCompanion attempts int } func (r *refusedLaunchCompanion) Launch(context.Context, string, bool) error { r.attempts++ return errors.New(`runner launch: failed("com.example.app is not running after launch")`) } // TestLaunchKeepsTheSessionWhenTheRunnerNamesTheRefusal separates a launch that // answers from a launch that never does. The session restart is the only // recovery from a wedged session, and it costs a cold start; a runner that // reports the app's state has already said what a fresh session would say, so // restarting to hear it again only delays the error and hides the app under it. func TestLaunchKeepsTheSessionWhenTheRunnerNamesTheRefusal(t *testing.T) { companion := &refusedLaunchCompanion{} output := &bytes.Buffer{} d := newTestDriver(companion) d.output = output restarts := 0 d.restart = func(context.Context) error { restarts++ return nil } err := d.Launch(context.Background(), "", false, nil) if err == nil || !strings.Contains(err.Error(), "com.example.app is not running after launch") { t.Fatalf("err = %v, want the runner's refusal reaching the caller intact", err) } if restarts != 0 { t.Fatalf("session restarts = %d, want 0: a refusal the runner reported is not a wedged session", restarts) } if companion.attempts != 1 { t.Fatalf("launch attempts = %d, want 1: relaunching an app the runner just refused cannot launch it", companion.attempts) } if strings.Contains(output.String(), "restarting the session") { t.Fatalf("the driver announced a recovery it must not spend here; output was %q", output.String()) } } // newLockTestOptions builds New options that dial a seamed companion, so the // device-lock tests exercise New without spawning anything. func newLockTestOptions(t *testing.T, udid string) Options { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } t.Cleanup(func() { listener.Close() }) go func() { for { connection, acceptErr := listener.Accept() if acceptErr != nil { return } _ = connection.Close() } }() return Options{ UniqueDeviceIdentifier: udid, pickAddress: func() (string, error) { return listener.Addr().String(), nil }, spawnChild: func(context.Context, string) (*exec.Cmd, error) { return &exec.Cmd{}, nil }, dialCompanion: func(string) (transport.Companion, error) { return &fakeCompanion{accessibilityJSON: "[]"}, nil }, } } // TestNewRejectsConcurrentRunOnSameDevice proves a second run cannot claim a // device the first is driving. Two runs interleave app lifecycle on one // simulator: the second's reinstall lands under the first's automation session // and wedges it. Failing fast names the contended device; the claim is released // on Close so the next run is not locked out. func TestNewRejectsConcurrentRunOnSameDevice(t *testing.T) { t.Setenv("SANDERLING_SIMULATOR_COMPANION", "legacy") udid := "LOCK-TEST-" + t.Name() first, err := New(context.Background(), newLockTestOptions(t, udid)) if err != nil { t.Fatalf("first New: %v", err) } _, err = New(context.Background(), newLockTestOptions(t, udid)) if err == nil { t.Fatal("second run claimed a device the first still drives; concurrent runs corrupt each other's session") } if !strings.Contains(err.Error(), udid) { t.Fatalf("err = %v, want it to name the contended device %s", err, udid) } first.Close() third, err := New(context.Background(), newLockTestOptions(t, udid)) if err != nil { t.Fatalf("device stayed locked after Close: %v", err) } third.Close() } // TestAcquireDeviceLockKeepsDistinctDevicesIndependent guards against a lock // path that ignores the udid and serializes unrelated runs. func TestAcquireDeviceLockKeepsDistinctDevicesIndependent(t *testing.T) { first, err := acquireDeviceLock("LOCK-TEST-DEVICE-A") if err != nil { t.Fatal(err) } defer first.Close() second, err := acquireDeviceLock("LOCK-TEST-DEVICE-B") if err != nil { t.Fatalf("a second device was refused while another was locked: %v", err) } defer second.Close() } func TestSwipeReportsAGestureThatStartsOffScreen(t *testing.T) { companion := &fakeCompanion{accessibilityJSON: "[]"} d := newTestDriver(companion) // The hierarchy extends past the screen whenever a scroll container holds // content below the fold, so the runner's clamp to the tree's extent can // place a gesture where the device has no surface to receive it. err := d.Swipe( context.Background(), 200, 1200, 200, 800, 300*time.Millisecond, ) if !errors.Is(err, driver.ErrGestureUndelivered) { t.Fatalf( "Swipe starting below the screen: err = %v, want ErrGestureUndelivered", err, ) } if slices.Contains(companion.recorded(), "hid") { t.Fatal("an undeliverable swipe must not reach the companion") } } func TestSwipeOnScreenReachesTheCompanion(t *testing.T) { companion := &fakeCompanion{accessibilityJSON: "[]"} d := newTestDriver(companion) if err := d.Swipe(context.Background(), 200, 600, 200, 200, 300*time.Millisecond); err != nil { t.Fatalf("Swipe: %v", err) } if !slices.Contains(companion.recorded(), "hid") { t.Fatal("an on-screen swipe should reach the companion") } } // TestTapSelectorReportsASelectorThatMatchesNothing keeps a by-selector tap // that resolved to no element out of the steps that read as dispatched. func TestTapSelectorReportsASelectorThatMatchesNothing(t *testing.T) { companion := &fakeCompanion{accessibilityJSON: "[]"} d := newTestDriver(companion) err := d.TapSelector(context.Background(), "id:absent") if !errors.Is(err, driver.ErrSelectorMatchedNothing) { t.Fatalf("TapSelector on an absent element: err = %v, want ErrSelectorMatchedNothing", err) } if slices.Contains(companion.recorded(), "hid") { t.Fatal("a selector that matched nothing must not reach the companion") } } // TestGesturesRefuseTheFarEdgeOfTheScreen holds iOS to the extent the device // enforces. A tap at x == screenWidth is not delivered at that point on the // simulator, so admitting it dispatches a gesture the app receives somewhere // other than where the trace says it landed. func TestGesturesRefuseTheFarEdgeOfTheScreen(t *testing.T) { gestures := map[string]func(*Driver, int, int) error{ "Tap": func(d *Driver, x, y int) error { return d.Tap(context.Background(), x, y) }, "DoubleTap": func(d *Driver, x, y int) error { return d.DoubleTap(context.Background(), x, y) }, "LongPress": func(d *Driver, x, y int) error { return d.LongPress(context.Background(), x, y) }, "Swipe": func(d *Driver, x, y int) error { return d.Swipe(context.Background(), x, y, 200, 400, 300*time.Millisecond) }, } for name, gesture := range gestures { t.Run(name, func(t *testing.T) { for _, point := range []struct{ x, y int }{{390, 400}, {200, 844}} { companion := &fakeCompanion{accessibilityJSON: "[]"} d := newTestDriver(companion) err := gesture(d, point.x, point.y) if !errors.Is(err, driver.ErrGestureUndelivered) { t.Fatalf("%s at (%d,%d): err = %v, want ErrGestureUndelivered", name, point.x, point.y, err) } if slices.Contains(companion.recorded(), "hid") { t.Fatalf("%s at (%d,%d) reached the companion", name, point.x, point.y) } } companion := &fakeCompanion{accessibilityJSON: "[]"} d := newTestDriver(companion) if err := gesture(d, 389, 843); err != nil { t.Fatalf("%s at the last on-screen point: %v", name, err) } }) } } // TestTapReportsAPointOffScreen is the iOS half of letting the runner hand off // every point it resolved. The runner no longer refuses a point outside the // screen, because a driver that can scroll reaches it; this one cannot, and a // point with no surface under it has to be reported rather than synthesised // into nothing. func TestTapReportsAPointOffScreen(t *testing.T) { for _, point := range []struct { name string x, y int }{ {"above the screen", 200, -208}, {"below the screen", 200, 1200}, } { t.Run(point.name, func(t *testing.T) { companion := &fakeCompanion{accessibilityJSON: "[]"} d := newTestDriver(companion) err := d.Tap(context.Background(), point.x, point.y) if !errors.Is(err, driver.ErrGestureUndelivered) { t.Fatalf("Tap at (%d,%d): err = %v, want ErrGestureUndelivered", point.x, point.y, err) } if slices.Contains(companion.recorded(), "hid") { t.Fatal("an undeliverable tap must not reach the companion") } }) } }