mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
preflightDevice was called only after web and both iOS branches had already returned, so it never saw a platform other than android, and it returns nil for anything but ios. The comment above the call said it was Android's java check; Android's java check is in runPreflight. Its premise was wrong too: a physical iOS device is runner-only over usbmux and needs no JVM. The replacement test asserts an iOS host with no java passes preflight. Also corrects two comments in the same file that still described iOS as routing through the JVM sidecar, one of them naming an IosDriverBackend type that exists nowhere in the tree.
299 lines
9.9 KiB
Go
299 lines
9.9 KiB
Go
package testrun
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/priyanshujain/sanderling/internal/android"
|
|
"github.com/priyanshujain/sanderling/internal/driver"
|
|
"github.com/priyanshujain/sanderling/internal/driver/chrome"
|
|
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion"
|
|
driverSidecar "github.com/priyanshujain/sanderling/internal/driver/sidecar"
|
|
"github.com/priyanshujain/sanderling/internal/ios"
|
|
"github.com/priyanshujain/sanderling/internal/sidecarassets"
|
|
)
|
|
|
|
// iOS resolution seams: package-level so routing tests substitute canned
|
|
// resolvers instead of shelling out to xcrun.
|
|
var (
|
|
iosResolveTarget = ios.ResolveTarget
|
|
iosResolveDevice = ios.ResolveDevice
|
|
iosEnsureSimulator = ios.EnsureSimulator
|
|
)
|
|
|
|
// resolveIOSTarget decides whether a run drives a simulator or a physical
|
|
// device and fills the iOS fields on Options. A simulator target is booted if
|
|
// needed; a physical-device target is resolved to its hardware UDID and
|
|
// CoreDevice id via devicectl.
|
|
func resolveIOSTarget(ctx context.Context, options Options, stdout io.Writer) (Options, error) {
|
|
udid, isSimulator, err := iosResolveTarget(ctx, options.IosDevice)
|
|
if err != nil {
|
|
// No query and nothing booted: keep boot-first behavior, then resolve
|
|
// the simulator that EnsureSimulator just brought up.
|
|
if options.IosDevice == "" {
|
|
if bootErr := iosEnsureSimulator(ctx, "", stdout); bootErr != nil {
|
|
return options, bootErr
|
|
}
|
|
udid, isSimulator, err = iosResolveTarget(ctx, "")
|
|
}
|
|
if err != nil {
|
|
return options, err
|
|
}
|
|
} else if isSimulator {
|
|
if err := iosEnsureSimulator(ctx, options.IosDevice, stdout); err != nil {
|
|
return options, err
|
|
}
|
|
udid, isSimulator, err = iosResolveTarget(ctx, options.IosDevice)
|
|
if err != nil {
|
|
return options, err
|
|
}
|
|
}
|
|
options.iosUDID = udid
|
|
options.iosIsSimulator = isSimulator
|
|
if !isSimulator {
|
|
device, err := iosResolveDevice(ctx, options.IosDevice)
|
|
if err != nil {
|
|
return options, err
|
|
}
|
|
options.iosUDID = device.HardwareUDID
|
|
options.iosCoreDeviceID = device.CoreDeviceID
|
|
fmt.Fprintf(stdout, "using device: %s (udid %s, id %s)\n", device.Name, device.HardwareUDID, device.CoreDeviceID)
|
|
}
|
|
return options, nil
|
|
}
|
|
|
|
// preflight is a seam so routing tests exercise driver construction without the
|
|
// host-readiness checks (xcrun, java) that are absent on a Linux CI runner.
|
|
var preflight = Preflight
|
|
|
|
// newDeviceDriver constructs the physical-device iOS driver and its cleanup. A
|
|
// seam so routing tests assert the resolved identifiers reach DeviceOptions
|
|
// without building or spawning a real runner.
|
|
var newDeviceDriver = func(ctx context.Context, options ioscompanion.DeviceOptions) (driver.DeviceDriver, func(), error) {
|
|
d, err := ioscompanion.NewDevice(ctx, options)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return d, d.Close, nil
|
|
}
|
|
|
|
// newSimulatorDriver constructs the iOS simulator driver and its cleanup. A
|
|
// seam so routing tests assert the run's options reach ioscompanion.Options
|
|
// without spawning a companion.
|
|
var newSimulatorDriver = func(ctx context.Context, options ioscompanion.Options) (driver.DeviceDriver, func(), error) {
|
|
d, err := ioscompanion.New(ctx, options)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return d, d.Close, nil
|
|
}
|
|
|
|
// buildDriver creates the appropriate DeviceDriver for the platform and returns
|
|
// a cleanup function. For web, ChromeDriver is used directly. An iOS simulator
|
|
// is driven by the native simulator companion (no JVM). A physical iOS device
|
|
// is driven runner-only over a usbmux tunnel. Android uses the JVM sidecar,
|
|
// which is extracted, spawned, and dialed.
|
|
func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver.DeviceDriver, func(), error) {
|
|
if err := preflight(ctx, options.Platform); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if options.Platform == "web" {
|
|
d := chrome.New()
|
|
return d, func() { _ = d.Terminate(context.Background()) }, nil
|
|
}
|
|
|
|
if options.Platform == "ios" && options.iosIsSimulator {
|
|
d, cleanup, err := newSimulatorDriver(ctx, ioscompanion.Options{
|
|
UniqueDeviceIdentifier: options.iosUDID,
|
|
BundleID: options.BundleID,
|
|
AppPath: options.IosAppPath,
|
|
ClearState: options.ClearData,
|
|
Output: stdout,
|
|
})
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("ios simulator driver: %w", err)
|
|
}
|
|
return d, cleanup, nil
|
|
}
|
|
|
|
if options.Platform == "ios" {
|
|
d, cleanup, err := newDeviceDriver(ctx, ioscompanion.DeviceOptions{
|
|
HardwareUDID: options.iosUDID,
|
|
CoreDeviceID: options.iosCoreDeviceID,
|
|
BundleID: options.BundleID,
|
|
AppPath: options.IosAppPath,
|
|
ClearState: options.ClearData,
|
|
Output: stdout,
|
|
})
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("ios device driver: %w", err)
|
|
}
|
|
return d, cleanup, nil
|
|
}
|
|
|
|
sidecarDirectory := os.TempDir() + "/sanderling-sidecar"
|
|
jarPath, err := sidecarassets.Extract(sidecarDirectory)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("extract sidecar: %w", err)
|
|
}
|
|
fmt.Fprintf(stdout, "sidecar JAR: %s (size=%d)\n", jarPath, sidecarassets.EmbeddedSize())
|
|
|
|
sidecarPort, err := pickFreePort()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
sidecarArgs := []string{"-jar", jarPath,
|
|
"--port", strconv.Itoa(sidecarPort),
|
|
"--platform", options.Platform,
|
|
}
|
|
if options.Device != "" {
|
|
sidecarArgs = append(sidecarArgs, "--serial", options.Device)
|
|
}
|
|
adbPath, err := android.AdbBinary()
|
|
if err != nil {
|
|
return nil, nil, preflightFailure("android", err)
|
|
}
|
|
sidecarCommand := exec.CommandContext(ctx, "java", sidecarArgs...)
|
|
sidecarCommand.Stdout = stdout
|
|
sidecarCommand.Stderr = stdout
|
|
sidecarCommand.Env = android.EnvWithAndroidPlatformTools(os.Environ(), adbPath)
|
|
// SIGTERM lets the sidecar run its shutdown hook. SIGKILL skips it and
|
|
// leaves the adb connection and the device-side instrumentation behind.
|
|
sidecarCommand.Cancel = func() error {
|
|
return sidecarCommand.Process.Signal(syscall.SIGTERM)
|
|
}
|
|
sidecarCommand.WaitDelay = sidecarShutdownGrace
|
|
if err := sidecarCommand.Start(); err != nil {
|
|
return nil, nil, fmt.Errorf("spawn sidecar: %w", err)
|
|
}
|
|
sidecarExited := watchSidecar(sidecarCommand)
|
|
address := fmt.Sprintf("127.0.0.1:%d", sidecarPort)
|
|
fmt.Fprintf(stdout, "sidecar pid=%d listening on %s (adb: %s)\n", sidecarCommand.Process.Pid, address, adbPath)
|
|
|
|
driverClient, err := driverSidecar.Dial(address)
|
|
if err != nil {
|
|
stopSidecar(sidecarCommand, sidecarExited)
|
|
return nil, nil, fmt.Errorf("dial sidecar: %w", err)
|
|
}
|
|
driverClient.SetPlatform(options.Platform)
|
|
driverClient.SetClearStateReinstall(options.Device, options.AndroidAppPath, stdout)
|
|
// WaitForHealth confirms the gRPC sidecar is up. This path is Android
|
|
// only: iOS has not routed through the sidecar since the native companion
|
|
// replaced it, and buildDriver returns before reaching here.
|
|
healthCtx, healthCancel := context.WithTimeout(ctx, sidecarStartupTimeout)
|
|
healthErr := awaitSidecar(healthCtx, address, sidecarStartupTimeout, func(pollCtx context.Context) error {
|
|
return driverClient.WaitForHealth(pollCtx, 250e6)
|
|
}, sidecarExited)
|
|
healthCancel()
|
|
if healthErr != nil {
|
|
stopSidecar(sidecarCommand, sidecarExited)
|
|
_ = driverClient.Close()
|
|
return nil, nil, healthErr
|
|
}
|
|
fmt.Fprintln(stdout, "sidecar is healthy")
|
|
|
|
cleanup := func() {
|
|
_ = driverClient.Close()
|
|
stopSidecar(sidecarCommand, sidecarExited)
|
|
}
|
|
return driverClient, cleanup, nil
|
|
}
|
|
|
|
// watchSidecar reaps the sidecar and publishes its exit status. The channel is
|
|
// closed after the send so the shutdown path can still receive once the startup
|
|
// path has taken the status.
|
|
func watchSidecar(sidecarCommand *exec.Cmd) <-chan error {
|
|
exited := make(chan error, 1)
|
|
go func() {
|
|
exited <- sidecarCommand.Wait()
|
|
close(exited)
|
|
}()
|
|
return exited
|
|
}
|
|
|
|
// awaitSidecar waits for the sidecar to answer a health check, racing that
|
|
// against the process exiting so a sidecar that dies during startup is reported
|
|
// as the exit it was rather than as a deadline half a minute later. Neither
|
|
// failure knows why the sidecar was unhappy, so both name what to look at
|
|
// instead of picking a cause.
|
|
func awaitSidecar(
|
|
ctx context.Context,
|
|
address string,
|
|
timeout time.Duration,
|
|
health func(context.Context) error,
|
|
exited <-chan error,
|
|
) error {
|
|
healthy := make(chan error, 1)
|
|
go func() { healthy <- health(ctx) }()
|
|
select {
|
|
case exitErr := <-exited:
|
|
return sidecarExitedError(address, exitErr)
|
|
case err := <-healthy:
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
select {
|
|
case exitErr := <-exited:
|
|
return sidecarExitedError(address, exitErr)
|
|
default:
|
|
return fmt.Errorf(
|
|
"sidecar did not answer a health check on %s within %s and is still running\n%s",
|
|
address, timeout, sidecarWhatToCheck,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
const sidecarWhatToCheck = "check the sidecar output above, then `sanderling doctor --platform=android` (java 17+, adb, Android SDK)"
|
|
|
|
func sidecarExitedError(address string, exitErr error) error {
|
|
status := "exit status 0"
|
|
if exitErr != nil {
|
|
status = exitErr.Error()
|
|
}
|
|
return fmt.Errorf(
|
|
"sidecar exited before it answered a health check on %s: %s\n%s",
|
|
address, status, sidecarWhatToCheck,
|
|
)
|
|
}
|
|
|
|
// sidecarShutdownGrace bounds how long the sidecar gets to run its shutdown
|
|
// hook (terminate the app, stop the XCTest runner) before being killed.
|
|
const sidecarShutdownGrace = 15 * time.Second
|
|
|
|
// stopSidecar terminates the sidecar gracefully so its shutdown hook can stop
|
|
// the device-side runner processes, escalating to SIGKILL when it does not
|
|
// exit within the grace window.
|
|
func stopSidecar(sidecarCommand *exec.Cmd, exited <-chan error) {
|
|
if sidecarCommand.Process == nil {
|
|
return
|
|
}
|
|
if err := sidecarCommand.Process.Signal(syscall.SIGTERM); err != nil {
|
|
_ = sidecarCommand.Process.Kill()
|
|
<-exited
|
|
return
|
|
}
|
|
select {
|
|
case <-exited:
|
|
case <-time.After(sidecarShutdownGrace):
|
|
_ = sidecarCommand.Process.Kill()
|
|
<-exited
|
|
}
|
|
}
|
|
|
|
func pickFreePort() (int, error) {
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer listener.Close()
|
|
return listener.Addr().(*net.TCPAddr).Port, nil
|
|
}
|