From 034f8d13084960fa83b210d97eb1b4ddde2bbf71 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 6 Jun 2026 23:45:12 +0530 Subject: [PATCH] feat(ioscompanion): implement DeviceDriver with companion supervision --- internal/driver/ioscompanion/driver.go | 731 +++++++++++++++++++++++++ 1 file changed, 731 insertions(+) create mode 100644 internal/driver/ioscompanion/driver.go diff --git a/internal/driver/ioscompanion/driver.go b/internal/driver/ioscompanion/driver.go new file mode 100644 index 0000000..8e0d589 --- /dev/null +++ b/internal/driver/ioscompanion/driver.go @@ -0,0 +1,731 @@ +// Package ioscompanion drives an iOS simulator through the native simulator +// companion. This file implements the DeviceDriver surface on top of the +// brand-free transport, supervises the companion child process, and recovers +// from a dropped connection with one in-place restart. +package ioscompanion + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + _ "image/png" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "sync" + "syscall" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/priyanshujain/sanderling/internal/driver" + "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/companionassets" + "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport" + "github.com/priyanshujain/sanderling/internal/hierarchy" +) + +// startupTimeout bounds how long New waits for the spawned companion to accept +// a connection and answer a health probe. +const startupTimeout = 30 * time.Second + +// shutdownGrace bounds how long the companion child gets to exit after SIGTERM +// before it is killed. +const shutdownGrace = 15 * time.Second + +// longPressHoldMilliseconds is how long LongPress holds the finger down. +const longPressHoldMilliseconds = 600 + +// Options configures a Driver. +type Options struct { + // UniqueDeviceIdentifier selects the booted simulator the companion drives. + UniqueDeviceIdentifier string + // BundleID is the app under test. Launch and Terminate act on it. + BundleID string + // AppPath is the .app bundle directory. Required for clear-state reinstall; + // when empty, clear state falls back to resetting the data container. + AppPath string + // Output receives companion stdout and stderr plus driver warnings. + Output io.Writer + // DoubleTapGapMilliseconds overrides the synthesized double-tap gap. + DoubleTapGapMilliseconds float64 + + // spawnChild, dialCompanion, and pickAddress are test seams. Production + // leaves them nil and New wires the real extraction, spawn, and dial. + spawnChild func(ctx context.Context, address string) (*exec.Cmd, error) + dialCompanion func(address string) (transport.Companion, error) + pickAddress func() (string, error) +} + +// Driver implements driver.DeviceDriver against an iOS simulator companion. +type Driver struct { + companion transport.Companion + udid string + bundleID string + appPath string + output io.Writer + + screenWidth int + screenHeight int + + doubleTapGapMilliseconds float64 + + // mu guards Snapshot's hierarchy+screenshot pairing and the lastTap record. + mu sync.Mutex + lastTap struct { + x, y float64 + set bool + } + + pastePrimed bool + clearStateWarned bool + + // restart rebuilds the transport in place after a connection-level failure. + // It is a seam so tests exercise the supervision logic without spawning a + // real companion. restarting guards against re-entrant restarts. + restart func(ctx context.Context) error + restarting bool + address string + + // resetContainer wipes the app data container for the clear-state fallback. + // A seam so tests skip the xcrun shell-out. + resetContainer func(ctx context.Context) error + + // idleClock drives WaitForIdle's settle poll. A seam so tests substitute a + // fake clock and avoid the real settle cap. + idleClock Clock + spawnChild func(ctx context.Context, address string) (*exec.Cmd, error) + dial func(address string) (transport.Companion, error) + child *exec.Cmd +} + +// New extracts the embedded companion, spawns it against the configured +// simulator, dials the transport, health-probes it, and caches the screen +// point dimensions. Call Close when done to stop the child. +func New(ctx context.Context, options Options) (*Driver, error) { + if options.UniqueDeviceIdentifier == "" { + return nil, errors.New("ios companion: UniqueDeviceIdentifier is required") + } + output := options.Output + if output == nil { + output = io.Discard + } + gap := options.DoubleTapGapMilliseconds + if gap <= 0 { + gap = DefaultDoubleTapGapMilliseconds + } + + driverInstance := &Driver{ + udid: options.UniqueDeviceIdentifier, + bundleID: options.BundleID, + appPath: options.AppPath, + output: output, + doubleTapGapMilliseconds: gap, + spawnChild: options.spawnChild, + dial: options.dialCompanion, + } + if driverInstance.spawnChild == nil { + driverInstance.spawnChild = driverInstance.realSpawnChild + } + if driverInstance.dial == nil { + driverInstance.dial = transport.Dial + } + pickAddress := options.pickAddress + if pickAddress == nil { + pickAddress = pickLoopbackAddress + } + + address, err := pickAddress() + if err != nil { + return nil, err + } + driverInstance.address = address + driverInstance.restart = driverInstance.respawnAndRedial + driverInstance.resetContainer = driverInstance.resetDataContainer + + if err := driverInstance.bringUp(ctx); err != nil { + return nil, err + } + + description, err := driverInstance.companion.Describe(ctx) + if err != nil { + driverInstance.Close() + return nil, fmt.Errorf("describe target: %w", err) + } + driverInstance.screenWidth = description.WidthPoints + driverInstance.screenHeight = description.HeightPoints + return driverInstance, nil +} + +// bringUp spawns the companion child, waits for the listener, dials, and +// confirms health. It is used by New and by the in-place restart. +func (d *Driver) bringUp(ctx context.Context) error { + startupCtx, cancel := context.WithTimeout(ctx, startupTimeout) + defer cancel() + + child, err := d.spawnChild(startupCtx, d.address) + if err != nil { + return fmt.Errorf("spawn companion: %w", err) + } + d.child = child + + if err := waitForListener(startupCtx, d.address); err != nil { + d.stopChild() + return fmt.Errorf("companion listener: %w", err) + } + + companion, err := d.dial(d.address) + if err != nil { + d.stopChild() + return fmt.Errorf("dial companion: %w", err) + } + d.companion = companion + + if err := d.waitForHealth(startupCtx); err != nil { + _ = companion.Close() + d.stopChild() + return fmt.Errorf("companion health: %w", err) + } + return nil +} + +// waitForHealth probes AccessibilityInfo until it succeeds or the context +// expires. A successful describe-all means the companion is attached to the +// simulator and ready to serve. +func (d *Driver) waitForHealth(ctx context.Context) error { + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + for { + if _, err := d.companion.AccessibilityInfo(ctx); err == nil { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +// respawnAndRedial tears down the current transport and child, then brings a +// fresh pair up at the same address. Used as the supervision restart. +func (d *Driver) respawnAndRedial(ctx context.Context) error { + if d.companion != nil { + _ = d.companion.Close() + d.companion = nil + } + d.stopChild() + return d.bringUp(ctx) +} + +// withRecovery runs call, and on a connection-level failure performs one +// in-place restart before retrying the call once. A non-connection error, or a +// second failure of any kind, surfaces to the caller. The restart budget is per +// failure incident: each healthy call resets restarting to false, so a later +// drop earns its own single restart. +func (d *Driver) withRecovery(ctx context.Context, call func() error) error { + err := call() + if err == nil || !isConnectionError(err) || d.restarting || d.restart == nil { + return err + } + d.restarting = true + defer func() { d.restarting = false }() + fmt.Fprintf(d.output, "companion connection lost (%v); restarting once\n", err) + if restartErr := d.restart(ctx); restartErr != nil { + return fmt.Errorf("companion restart failed: %w (original: %v)", restartErr, err) + } + return call() +} + +// isConnectionError reports whether err is a dropped-connection signal that a +// restart can recover from: a gRPC Unavailable status or an EOF. +func isConnectionError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, io.EOF) { + return true + } + if statusValue, ok := status.FromError(err); ok { + return statusValue.Code() == codes.Unavailable + } + return false +} + +func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool, env map[string]string) error { + if bundleID != "" { + d.bundleID = bundleID + } + if len(env) > 0 { + // The launch Start message carries an env map, but this backend does + // not pass it through: passing it would change the app's process + // environment in ways the rest of the run does not account for. Reject + // loudly rather than silently dropping the request. + return errors.New("ios companion: launch with environment variables is unsupported on this backend") + } + + // 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. + _ = d.withRecovery(ctx, func() error { return d.companion.Terminate(ctx, d.bundleID) }) + + if clearState { + if err := d.clearAppState(ctx); err != nil { + return err + } + } + + if err := d.withRecovery(ctx, func() error { + return d.companion.Launch(ctx, d.bundleID, true) + }); err != nil { + return fmt.Errorf("launch %s: %w", d.bundleID, err) + } + + if !d.pastePrimed { + if err := warmUpPaste(ctx, d.makeRunner()); err != nil { + fmt.Fprintf(d.output, "paste warm-up failed (continuing): %v\n", err) + } + d.pastePrimed = true + } + return nil +} + +// 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 +// container and warns once that a full reinstall needs the app path. +func (d *Driver) clearAppState(ctx context.Context) error { + if d.appPath != "" { + _ = d.withRecovery(ctx, func() error { return d.companion.Uninstall(ctx, d.bundleID) }) + if err := d.withRecovery(ctx, func() error { return d.companion.Install(ctx, d.appPath) }); err != nil { + return fmt.Errorf("reinstall %s: %w", d.appPath, err) + } + return nil + } + if !d.clearStateWarned { + fmt.Fprintln(d.output, "clear-state requested without an app path: resetting the data container only; pass the app path for a full reinstall") + d.clearStateWarned = true + } + return d.resetContainer(ctx) +} + +// resetDataContainer deletes the contents of the app's data container so the +// next launch starts with empty storage. +func (d *Driver) resetDataContainer(ctx context.Context) error { + output, err := exec.CommandContext(ctx, "xcrun", "simctl", "get_app_container", d.udid, d.bundleID, "data").Output() + if err != nil { + return fmt.Errorf("get app container: %w", err) + } + container := string(bytes.TrimSpace(output)) + if container == "" { + return nil + } + entries, err := os.ReadDir(container) + if err != nil { + return fmt.Errorf("read app container: %w", err) + } + for _, entry := range entries { + if err := os.RemoveAll(filepath.Join(container, entry.Name())); err != nil { + return fmt.Errorf("clear app container: %w", err) + } + } + return nil +} + +func (d *Driver) Terminate(ctx context.Context) error { + return d.withRecovery(ctx, func() error { return d.companion.Terminate(ctx, d.bundleID) }) +} + +func (d *Driver) Tap(ctx context.Context, x, y int) error { + d.mu.Lock() + d.lastTap.x = float64(x) + d.lastTap.y = float64(y) + d.lastTap.set = true + d.mu.Unlock() + return d.withRecovery(ctx, func() error { + return d.companion.SendHID(ctx, tapEvents(float64(x), float64(y))...) + }) +} + +func (d *Driver) DoubleTap(ctx context.Context, x, y int) error { + return d.withRecovery(ctx, func() error { + return d.companion.SendHID(ctx, doubleTapEvents(float64(x), float64(y), d.doubleTapGapMilliseconds)...) + }) +} + +func (d *Driver) LongPress(ctx context.Context, x, y int) error { + return d.withRecovery(ctx, func() error { + return d.companion.SendHID(ctx, longPressEvents(float64(x), float64(y), longPressHoldMilliseconds)...) + }) +} + +func (d *Driver) Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error { + seconds := duration.Seconds() + if seconds <= 0 { + seconds = 0.25 + } + return d.withRecovery(ctx, func() error { + return d.companion.SendHID(ctx, transport.SwipeEvent( + float64(fromX), float64(fromY), float64(toX), float64(toY), seconds)) + }) +} + +func (d *Driver) PressKey(ctx context.Context, key string) error { + usage, ok := pressKeyUsage(key) + if !ok { + return fmt.Errorf("ios companion: unsupported key %q", key) + } + return d.withRecovery(ctx, func() error { + return d.companion.SendHID(ctx, transport.KeyDown(usage), transport.KeyUp(usage)) + }) +} + +// pressKeyUsage maps the logical key names mobile runs emit to a HID usage. +// Only Return/Enter has a hardware-keyboard equivalent on the simulator; other +// names (notably "back" and "home") have no HID key and report unsupported. +func pressKeyUsage(key string) (uint32, bool) { + switch key { + case "enter", "return", "Enter", "Return": + return usageReturn, true + default: + return 0, false + } +} + +func (d *Driver) TapSelector(ctx context.Context, selector string) error { + x, y, err := d.resolveSelectorCenter(ctx, selector) + if err != nil { + return err + } + return d.Tap(ctx, x, y) +} + +func (d *Driver) DoubleTapSelector(ctx context.Context, selector string) error { + x, y, err := d.resolveSelectorCenter(ctx, selector) + if err != nil { + return err + } + return d.DoubleTap(ctx, x, y) +} + +// resolveSelectorCenter fetches a fresh hierarchy and returns the center of the +// first element matching selector. +func (d *Driver) resolveSelectorCenter(ctx context.Context, selector string) (int, int, error) { + hierarchyJSON, err := d.Hierarchy(ctx) + if err != nil { + return 0, 0, err + } + tree, err := hierarchy.Parse(hierarchyJSON) + if err != nil { + return 0, 0, fmt.Errorf("parse hierarchy: %w", err) + } + element := tree.Find(selector) + if element == nil { + return 0, 0, fmt.Errorf("selector %q matched no element", selector) + } + x, y := element.Bounds.Center() + return x, y, nil +} + +func (d *Driver) InputText(ctx context.Context, text string) error { + // The field target is only needed for the pasteboard fallback. Resolving it + // requires a describe-all, so the fast keyboard path skips that round-trip + // and lets inputText send the key presses directly. + var field fieldTarget + if _, skipped := typeString(text); len(skipped) > 0 { + field = d.resolveInputField(ctx) + } + return inputText(ctx, d.makeRunner(), text, field) +} + +// resolveInputField finds the editable element under the last tap so the +// pasteboard fallback can confirm the paste landed and refocus after dismissing +// the permission dialog. The runner always taps a field before typing, so +// lastTap names the focus point. An empty fieldTarget is returned when no +// editable element contains the tap (the fast keyboard path ignores it). +func (d *Driver) resolveInputField(ctx context.Context) fieldTarget { + d.mu.Lock() + tap := d.lastTap + d.mu.Unlock() + if !tap.set { + return fieldTarget{} + } + dump, err := d.describeAll(ctx) + if err != nil { + return fieldTarget{} + } + for _, element := range decodeDump(dump) { + if !isEditable(element.Type) { + continue + } + frame := element.Frame + if !finite(frame.X) || !finite(frame.Y) || !finite(frame.Width) || !finite(frame.Height) { + continue + } + if tap.x < frame.X || tap.x > frame.X+frame.Width || + tap.y < frame.Y || tap.y > frame.Y+frame.Height { + continue + } + return fieldTarget{ + identifier: stringValue(element.AXUniqueID), + centerX: frame.X + frame.Width/2, + centerY: frame.Y + frame.Height/2, + } + } + return fieldTarget{} +} + +func (d *Driver) EraseText(ctx context.Context, characterCount int) error { + return eraseText(ctx, d.makeRunner(), characterCount) +} + +func (d *Driver) Hierarchy(ctx context.Context) (string, error) { + dump, err := d.describeAll(ctx) + if err != nil { + return "", err + } + mapped, err := MapHierarchy(dump, d.screenWidth, d.screenHeight) + if err != nil { + return "", err + } + return string(mapped), nil +} + +func (d *Driver) Screenshot(ctx context.Context) (driver.Image, error) { + var data []byte + err := d.withRecovery(ctx, func() error { + var screenshotErr error + data, _, screenshotErr = d.companion.Screenshot(ctx) + return screenshotErr + }) + if err != nil { + return driver.Image{}, fmt.Errorf("screenshot: %w", err) + } + return decodeScreenshot(data) +} + +func (d *Driver) Snapshot(ctx context.Context) (string, driver.Image, error) { + d.mu.Lock() + defer d.mu.Unlock() + dump, err := d.describeAll(ctx) + if err != nil { + return "", driver.Image{}, err + } + var data []byte + if err := d.withRecovery(ctx, func() error { + var screenshotErr error + data, _, screenshotErr = d.companion.Screenshot(ctx) + return screenshotErr + }); err != nil { + return "", driver.Image{}, fmt.Errorf("screenshot: %w", err) + } + mapped, err := MapHierarchy(dump, d.screenWidth, d.screenHeight) + if err != nil { + return "", driver.Image{}, err + } + image, err := decodeScreenshot(data) + if err != nil { + return string(mapped), driver.Image{}, err + } + return string(mapped), image, nil +} + +// WaitForIdle polls the hierarchy until it settles. The duration argument is +// ignored: the ported settle constants (StabilityPollCap and friends) own the +// cap, matching the companion's own settle behavior. +func (d *Driver) WaitForIdle(ctx context.Context, _ time.Duration) error { + clock := d.idleClock + if clock == nil { + clock = SystemClock() + } + PollUntilStable(ctx, clock, func() *hierarchy.Tree { + dump, err := d.describeAll(ctx) + if err != nil { + return nil + } + mapped, err := MapHierarchy(dump, d.screenWidth, d.screenHeight) + if err != nil { + return nil + } + tree, err := hierarchy.Parse(string(mapped)) + if err != nil { + return nil + } + return tree + }) + return nil +} + +// RecentLogs returns no entries: the companion log RPC is a follow-up, so v1 +// reports an empty slice rather than failing. +func (d *Driver) RecentLogs(_ context.Context, _ time.Time, _ string) ([]driver.LogEntry, error) { + return []driver.LogEntry{}, nil +} + +func (d *Driver) Metrics(_ context.Context, _ string) (driver.Metrics, error) { + return driver.Metrics{}, nil +} + +func (d *Driver) Health(_ context.Context) (driver.Health, error) { + return driver.Health{Ready: true, Platform: "ios"}, nil +} + +// ForegroundApp reports the foreground app. It returns the app under test when +// it is running; otherwise it names another running user app, or "" when none +// is. The companion exposes process state but not a foreground flag, so "the +// app under test is running" stands in for "in the foreground". +func (d *Driver) ForegroundApp(ctx context.Context) (string, error) { + var apps []transport.InstalledApp + if err := d.withRecovery(ctx, func() error { + var listErr error + apps, listErr = d.companion.ListApps(ctx) + return listErr + }); err != nil { + return "", err + } + other := "" + for _, app := range apps { + if app.ProcessState != transport.ProcessStateRunning { + continue + } + if app.BundleID == d.bundleID { + return d.bundleID, nil + } + if app.InstallType == "user" && other == "" { + other = app.BundleID + } + } + return other, nil +} + +// describeAll fetches the flat accessibility dump with one-restart recovery. +func (d *Driver) describeAll(ctx context.Context) ([]byte, error) { + var dump []byte + err := d.withRecovery(ctx, func() error { + info, infoErr := d.companion.AccessibilityInfo(ctx) + if infoErr != nil { + return infoErr + } + dump = []byte(info) + return nil + }) + return dump, err +} + +// makeRunner builds the input runner backed by the current transport. The text +// runner does not route through withRecovery: it is invoked synchronously +// inside a single InputText call and a mid-paste connection drop surfaces as a +// normal error the runner retries. +func (d *Driver) makeRunner() runner { + return simctlRunner{companion: d.companion, udid: d.udid} +} + +// Close stops the companion child and releases the transport. +func (d *Driver) Close() { + if d.companion != nil { + _ = d.companion.Close() + d.companion = nil + } + d.stopChild() +} + +// stopChild terminates the companion child gracefully (SIGTERM, grace window, +// then SIGKILL) so it leaves no orphan behind. +func (d *Driver) stopChild() { + child := d.child + d.child = nil + if child == nil || child.Process == nil { + return + } + if err := child.Process.Signal(syscall.SIGTERM); err != nil { + _ = child.Process.Kill() + _ = child.Wait() + return + } + done := make(chan struct{}) + go func() { + _ = child.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(shutdownGrace): + _ = child.Process.Kill() + <-done + } +} + +// decodeScreenshot sniffs the PNG magic and decodes the pixel dimensions. The +// companion leaves image_format empty in practice, so the magic bytes are the +// only reliable format signal. No scaling is applied: the dimensions are pixels. +func decodeScreenshot(data []byte) (driver.Image, error) { + if len(data) < 8 || !bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")) { + return driver.Image{}, errors.New("screenshot: response is not a PNG") + } + config, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return driver.Image{}, fmt.Errorf("decode screenshot: %w", err) + } + return driver.Image{PNG: data, Width: config.Width, Height: config.Height}, nil +} + +// realSpawnChild extracts the embedded companion and starts it on the given +// address. Cancel sends SIGTERM so the companion detaches cleanly from the +// simulator; WaitDelay bounds the grace before the runtime kills it. +func (d *Driver) realSpawnChild(ctx context.Context, address string) (*exec.Cmd, error) { + extractDirectory := filepath.Join(os.TempDir(), "sanderling-companion") + binaryPath, err := companionassets.Extract(extractDirectory) + if err != nil { + return nil, fmt.Errorf("extract companion: %w", err) + } + _, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + command := exec.CommandContext(ctx, binaryPath, "--udid", d.udid, "--grpc-port", port) + command.Stdout = d.output + command.Stderr = d.output + command.Cancel = func() error { return command.Process.Signal(syscall.SIGTERM) } + command.WaitDelay = shutdownGrace + if err := command.Start(); err != nil { + return nil, fmt.Errorf("start companion: %w", err) + } + fmt.Fprintf(d.output, "companion pid=%d listening on %s\n", command.Process.Pid, address) + return command, nil +} + +// pickLoopbackAddress reserves a free loopback port and returns its address. +func pickLoopbackAddress() (string, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return "", err + } + defer listener.Close() + return listener.Addr().String(), nil +} + +// waitForListener blocks until address accepts a TCP connection or ctx expires. +func waitForListener(ctx context.Context, address string) error { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + dialer := net.Dialer{Timeout: time.Second} + conn, err := dialer.DialContext(ctx, "tcp", address) + if err == nil { + _ = conn.Close() + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +var ( + _ driver.DeviceDriver = (*Driver)(nil) + _ driver.ForegroundChecker = (*Driver)(nil) +)