package testrun import ( "context" "errors" "io" "os" "os/exec" "path/filepath" "slices" "strings" "testing" "time" "github.com/priyanshujain/sanderling/internal/driver" "github.com/priyanshujain/sanderling/internal/driver/ioscompanion" "github.com/priyanshujain/sanderling/internal/ios" ) // stubDeviceDriver is a no-op DeviceDriver so routing tests never build or spawn // a real runner. Only construction wiring is under test. type stubDeviceDriver struct{ driver.DeviceDriver } func TestBuildDriverRoutesPhysicalIOSToDeviceDriver(t *testing.T) { stubPreflight(t) original := newDeviceDriver t.Cleanup(func() { newDeviceDriver = original }) var got ioscompanion.DeviceOptions closed := false newDeviceDriver = func(_ context.Context, options ioscompanion.DeviceOptions) (driver.DeviceDriver, func(), error) { got = options return stubDeviceDriver{}, func() { closed = true }, nil } options := Options{Platform: "ios", BundleID: "app.folio", IosAppPath: "/tmp/iosApp.app", ClearData: true} options.iosIsSimulator = false options.iosUDID = "00008140-HW" options.iosCoreDeviceID = "CORE-1" d, cleanup, err := buildDriver(context.Background(), options, io.Discard) if err != nil { t.Fatalf("buildDriver: %v", err) } if d == nil { t.Fatal("expected a device driver") } if got.HardwareUDID != "00008140-HW" || got.CoreDeviceID != "CORE-1" { t.Fatalf("DeviceOptions ids = %+v, want the resolved hardware/core ids", got) } if got.BundleID != "app.folio" || got.AppPath != "/tmp/iosApp.app" { t.Fatalf("DeviceOptions = %+v, want bundle and app path threaded through", got) } if !got.ClearState { t.Fatalf("DeviceOptions = %+v, want clear-data threaded through: the driver clears before its session, so a launch cannot", got) } cleanup() if !closed { t.Fatal("cleanup must close the device driver") } } func TestBuildDriverThreadsClearStateToTheSimulatorDriver(t *testing.T) { stubPreflight(t) original := newSimulatorDriver t.Cleanup(func() { newSimulatorDriver = original }) var got ioscompanion.Options newSimulatorDriver = func(_ context.Context, options ioscompanion.Options) (driver.DeviceDriver, func(), error) { got = options return stubDeviceDriver{}, func() {}, nil } options := Options{Platform: "ios", BundleID: "app.folio", IosAppPath: "/tmp/iosApp.app", ClearData: true} options.iosIsSimulator = true options.iosUDID = "SIM-UDID" if _, _, err := buildDriver(context.Background(), options, io.Discard); err != nil { t.Fatalf("buildDriver: %v", err) } if got.UniqueDeviceIdentifier != "SIM-UDID" || got.BundleID != "app.folio" || got.AppPath != "/tmp/iosApp.app" { t.Fatalf("Options = %+v, want the resolved target, bundle and app path", got) } if !got.ClearState { t.Fatalf("Options = %+v, want clear-data threaded through: the driver clears before its session, so a launch cannot", got) } } func TestBuildDriverSurfacesDeviceConstructionError(t *testing.T) { stubPreflight(t) original := newDeviceDriver t.Cleanup(func() { newDeviceDriver = original }) newDeviceDriver = func(context.Context, ioscompanion.DeviceOptions) (driver.DeviceDriver, func(), error) { return nil, nil, errors.New("no signing creds") } options := Options{Platform: "ios"} options.iosIsSimulator = false if _, _, err := buildDriver(context.Background(), options, io.Discard); err == nil { t.Fatal("expected the device construction error to surface") } } // A sidecar that dies during startup leaves the health poll with nothing to // talk to, and reporting that as a deadline sends the reader after a gRPC // timeout instead of the exit that already happened. func TestAwaitSidecarReportsTheExitItSaw(t *testing.T) { exited := make(chan error, 1) exited <- errors.New("exit status 1") close(exited) err := awaitSidecar( context.Background(), "127.0.0.1:54321", 30*time.Second, func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() }, exited, ) if err == nil { t.Fatal("expected an error when the sidecar exits before it is healthy") } for _, want := range []string{ "sidecar exited before it answered a health check on 127.0.0.1:54321: exit status 1", "check the sidecar output above", "sanderling doctor --platform=android", } { if !strings.Contains(err.Error(), want) { t.Errorf("error %q missing %q", err, want) } } } func TestAwaitSidecarTimeoutSaysOnlyWhatItObserved(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() err := awaitSidecar( ctx, "127.0.0.1:54321", 50*time.Millisecond, func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() }, make(chan error, 1), ) if err == nil { t.Fatal("expected an error when the sidecar never answers") } for _, want := range []string{ "sidecar did not answer a health check on 127.0.0.1:54321 within 50ms and is still running", "check the sidecar output above", "sanderling doctor --platform=android", } { if !strings.Contains(err.Error(), want) { t.Errorf("error %q missing %q", err, want) } } if strings.Contains(err.Error(), "context deadline exceeded") { t.Errorf("error %q must not hand the reader a bare gRPC deadline", err) } } func TestAwaitSidecarHealthyReturnsNil(t *testing.T) { err := awaitSidecar( context.Background(), "127.0.0.1:54321", 30*time.Second, func(context.Context) error { return nil }, make(chan error, 1), ) if err != nil { t.Fatalf("expected a healthy sidecar to pass, got %v", err) } } func TestStopSidecarTerminatesARunningSidecar(t *testing.T) { command := exec.Command("sleep", "60") if err := command.Start(); err != nil { t.Fatalf("start: %v", err) } exited := watchSidecar(command) stopped := make(chan struct{}) go func() { stopSidecar(command, exited) close(stopped) }() select { case <-stopped: case <-time.After(10 * time.Second): t.Fatal("stopSidecar never returned for a running sidecar") } if got := command.ProcessState.String(); got != "signal: terminated" { t.Errorf("sidecar ended as %q, want the SIGTERM its shutdown hook needs", got) } } // The startup path takes the exit status to report it, so the shutdown path // that follows must not sit waiting for a status nobody will send again. func TestStopSidecarAfterTheStartupPathTookTheExitStatus(t *testing.T) { command := exec.Command("sh", "-c", "exit 3") if err := command.Start(); err != nil { t.Fatalf("start: %v", err) } exited := watchSidecar(command) err := awaitSidecar( context.Background(), "127.0.0.1:54321", 30*time.Second, func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() }, exited, ) if err == nil || !strings.Contains(err.Error(), "exit status 3") { t.Fatalf("expected the sidecar's real exit status, got %v", err) } stopped := make(chan struct{}) go func() { stopSidecar(command, exited) close(stopped) }() select { case <-stopped: case <-time.After(10 * time.Second): t.Fatal("stopSidecar blocked on an exit status the startup path had already taken") } } // stubPreflight bypasses the host-readiness checks so routing tests exercise // driver construction on a Linux CI runner that lacks xcrun/java. func stubPreflight(t *testing.T) { t.Helper() original := preflight t.Cleanup(func() { preflight = original }) preflight = func(context.Context, string) error { return nil } } func swapIOSResolveSeams(t *testing.T) { t.Helper() origTarget, origDevice, origEnsure := iosResolveTarget, iosResolveDevice, iosEnsureSimulator t.Cleanup(func() { iosResolveTarget, iosResolveDevice, iosEnsureSimulator = origTarget, origDevice, origEnsure }) } func TestResolveIOSTargetPhysicalDeviceFillsIDs(t *testing.T) { swapIOSResolveSeams(t) iosResolveTarget = func(_ context.Context, query string) (string, bool, error) { return query, false, nil } resolveDeviceCalled := "" iosResolveDevice = func(_ context.Context, query string) (ios.Device, error) { resolveDeviceCalled = query return ios.Device{Name: "iPhone", HardwareUDID: "00008140-HW", CoreDeviceID: "CORE-1"}, nil } iosEnsureSimulator = func(context.Context, string, io.Writer) error { t.Fatal("must not boot a simulator on the device path") return nil } options := Options{Platform: "ios", IosDevice: "iPhone"} resolved, err := resolveIOSTarget(context.Background(), options, io.Discard) if err != nil { t.Fatal(err) } if resolveDeviceCalled != "iPhone" { t.Fatalf("ResolveDevice query = %q, want iPhone", resolveDeviceCalled) } if resolved.iosIsSimulator { t.Fatal("device target must not be marked a simulator") } if resolved.iosUDID != "00008140-HW" || resolved.iosCoreDeviceID != "CORE-1" { t.Fatalf("resolved ids = (%q, %q), want hardware/core ids", resolved.iosUDID, resolved.iosCoreDeviceID) } } func TestResolveIOSTargetSimulatorSkipsDeviceResolution(t *testing.T) { swapIOSResolveSeams(t) iosResolveTarget = func(context.Context, string) (string, bool, error) { return "sim-udid", true, nil } ensured := false iosEnsureSimulator = func(context.Context, string, io.Writer) error { ensured = true; return nil } iosResolveDevice = func(context.Context, string) (ios.Device, error) { t.Fatal("simulator path must not resolve a physical device") return ios.Device{}, nil } options := Options{Platform: "ios", IosDevice: "iPhone 17 Pro"} resolved, err := resolveIOSTarget(context.Background(), options, io.Discard) if err != nil { t.Fatal(err) } if !ensured { t.Fatal("simulator path must ensure the simulator is booted") } if !resolved.iosIsSimulator || resolved.iosUDID != "sim-udid" { t.Fatalf("resolved = %+v, want the booted simulator", resolved) } } func TestResolveIOSTargetDeviceResolutionErrorSurfaces(t *testing.T) { swapIOSResolveSeams(t) iosResolveTarget = func(_ context.Context, query string) (string, bool, error) { return query, false, nil } iosResolveDevice = func(context.Context, string) (ios.Device, error) { return ios.Device{}, errors.New("no device connected") } options := Options{Platform: "ios", IosDevice: "iPhone"} if _, err := resolveIOSTarget(context.Background(), options, io.Discard); err == nil { t.Fatal("expected the device-resolution error to surface") } } func fakeJava(t *testing.T, script string) { t.Helper() directory := t.TempDir() path := filepath.Join(directory, "java") if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script+"\n"), 0o755); err != nil { t.Fatalf("write fake java: %v", err) } t.Setenv("PATH", directory) } func TestSidecarArgumentsSilencesTheUnsafeWarningOnAJvmThatTakesTheFlag(t *testing.T) { fakeJava(t, "exit 0") args := sidecarArguments(context.Background(), "/tmp/sidecar.jar", 51129, "android", "663c91b1") want := []string{ "--sun-misc-unsafe-memory-access=allow", "-jar", "/tmp/sidecar.jar", "--port", "51129", "--platform", "android", "--serial", "663c91b1", } if !slices.Equal(args, want) { t.Fatalf("sidecar argv = %q, want %q", args, want) } } func TestSidecarArgumentsOmitsTheFlagAJvmBefore23WouldRefuseToStartWith(t *testing.T) { fakeJava(t, `case "$1" in --sun-misc-unsafe-memory-access=*) echo "Unrecognized option: $1" >&2; exit 1;; esac; exit 0`) args := sidecarArguments(context.Background(), "/tmp/sidecar.jar", 51129, "android", "") want := []string{"-jar", "/tmp/sidecar.jar", "--port", "51129", "--platform", "android"} if !slices.Equal(args, want) { t.Fatalf("sidecar argv = %q, want %q", args, want) } }