feat(ioscompanion): pair the legacy companion with the in-simulator runner

This commit is contained in:
pj committed 2026-06-07 16:47:56 +05:30
1 parent bbe013b8e4
commit 1220b62f68
2 files changed
+246 -7

No files matched your search

+157 -7
View File
@@ -34,6 +34,10 @@ import (
// a connection and answer a health probe.
const startupTimeout = 30 * time.Second
// runnerStartupTimeout bounds the in-simulator runner's startup. The runner is
// hosted by a test session whose cold start is far slower than the companion's.
const runnerStartupTimeout = 120 * time.Second
// shutdownGrace bounds how long the companion child gets to exit after SIGTERM
// before it is killed.
const shutdownGrace = 15 * time.Second
@@ -110,6 +114,17 @@ type Driver struct {
dial func(address string) (transport.Companion, error)
child *exec.Cmd
// The hybrid simulator companion pairs the legacy child (HID gestures,
// lifecycle, screenshot) with an in-simulator runner that serves
// collapse-free accessibility snapshots and native unicode typing.
// runnerClient is nil on the legacy-only path.
runnerClient transport.Companion
runnerChild *exec.Cmd
runnerAddress string
spawnRunner func(ctx context.Context, address string) (*exec.Cmd, error)
dialRunner func(address string) (transport.Companion, error)
hybrid bool
// processContext owns the companion child's lifetime: it is derived from
// New's context (so a canceled run still reaps the child) and canceled by
// Close. Spawning under a startup-scoped context would SIGTERM the child
@@ -142,6 +157,7 @@ func New(ctx context.Context, options Options) (*Driver, error) {
doubleTapGapMilliseconds: gap,
spawnChild: options.spawnChild,
dial: options.dialCompanion,
hybrid: hybridCompanionEnabled(),
}
if driverInstance.spawnChild == nil {
driverInstance.spawnChild = driverInstance.realSpawnChild
@@ -149,6 +165,14 @@ func New(ctx context.Context, options Options) (*Driver, error) {
if driverInstance.dial == nil {
driverInstance.dial = transport.Dial
}
if driverInstance.spawnRunner == nil {
driverInstance.spawnRunner = driverInstance.realSpawnRunner
}
if driverInstance.dialRunner == nil {
driverInstance.dialRunner = func(address string) (transport.Companion, error) {
return transport.DialRunner(address, driverInstance.udid, driverInstance.bundleID)
}
}
pickAddress := options.pickAddress
if pickAddress == nil {
pickAddress = pickLoopbackAddress
@@ -168,6 +192,12 @@ func New(ctx context.Context, options Options) (*Driver, error) {
if err := driverInstance.bringUp(ctx); err != nil {
return nil, err
}
if driverInstance.hybrid {
if err := driverInstance.bringUpRunner(ctx); err != nil {
driverInstance.Close()
return nil, fmt.Errorf("simulator runner: %w (set SANDERLING_SIMULATOR_COMPANION=legacy to bypass)", err)
}
}
description, err := driverInstance.companion.Describe(ctx)
if err != nil {
@@ -229,15 +259,84 @@ func (d *Driver) waitForHealth(ctx context.Context) error {
}
}
// respawnAndRedial tears down the current transport and child, then brings a
// fresh pair up at the same address. Used as the supervision restart.
// respawnAndRedial tears down the current transports and children, then brings
// fresh ones up at the same addresses. Used as the supervision restart. On the
// hybrid path both halves restart together: their failure modes overlap (a
// rebooted simulator drops both) and one orchestration keeps recovery simple.
func (d *Driver) respawnAndRedial(ctx context.Context) error {
if d.companion != nil {
_ = d.companion.Close()
d.companion = nil
}
d.stopChild()
return d.bringUp(ctx)
if d.runnerClient != nil {
_ = d.runnerClient.Close()
d.runnerClient = nil
}
d.stopRunnerChild()
if err := d.bringUp(ctx); err != nil {
return err
}
if d.hybrid {
return d.bringUpRunner(ctx)
}
return nil
}
// hybridCompanionEnabled reports whether the simulator driver should pair the
// legacy companion with the in-simulator runner. The hybrid is opt-in while it
// is proven out; SANDERLING_SIMULATOR_COMPANION=hybrid enables it.
func hybridCompanionEnabled() bool {
return os.Getenv("SANDERLING_SIMULATOR_COMPANION") == "hybrid"
}
// bringUpRunner spawns the in-simulator runner, waits for its listener, dials,
// and confirms it serves snapshots. Used by New and the in-place restart.
func (d *Driver) bringUpRunner(ctx context.Context) error {
startupCtx, cancel := context.WithTimeout(ctx, runnerStartupTimeout)
defer cancel()
if d.runnerAddress == "" {
address, err := pickLoopbackAddress()
if err != nil {
return err
}
d.runnerAddress = address
}
child, err := d.spawnRunner(d.processContext, d.runnerAddress)
if err != nil {
return fmt.Errorf("spawn runner: %w", err)
}
d.runnerChild = child
if err := waitForListener(startupCtx, d.runnerAddress); err != nil {
d.stopRunnerChild()
return fmt.Errorf("runner listener: %w", err)
}
client, err := d.dialRunner(d.runnerAddress)
if err != nil {
d.stopRunnerChild()
return fmt.Errorf("dial runner: %w", err)
}
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
for {
if _, healthErr := client.AccessibilityInfo(startupCtx); healthErr == nil {
break
}
select {
case <-startupCtx.Done():
_ = client.Close()
d.stopRunnerChild()
return fmt.Errorf("runner health: %w", startupCtx.Err())
case <-ticker.C:
}
}
d.runnerClient = client
return nil
}
// withRecovery runs call, and on a connection-level failure performs one
@@ -301,8 +400,9 @@ func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool, e
// must go through the pasteboard, since HID cannot express it) never trips
// the iOS paste-permission prompt. clearState reinstall resets the grant,
// so it is reapplied on every launch. Best effort: if it fails, the paste
// path still handles the prompt, just slower.
if d.bundleID != "" {
// path still handles the prompt, just slower. The hybrid path types
// natively and never touches the pasteboard, so it skips the grant.
if d.bundleID != "" && d.runnerTyper() == nil {
if err := d.grantPaste(ctx); err != nil {
fmt.Fprintf(d.output, "grant pasteboard access failed (continuing): %v\n", err)
}
@@ -458,6 +558,16 @@ func (d *Driver) textEditor() transport.TextEditor {
return nil
}
// runnerTyper returns the in-simulator runner's native typing capability, or
// nil outside the hybrid path. Resolved per call because a restart replaces
// d.runnerClient.
func (d *Driver) runnerTyper() transport.TextTyper {
if typer, ok := d.runnerClient.(transport.TextTyper); ok {
return typer
}
return nil
}
// 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.
@@ -506,6 +616,18 @@ func (d *Driver) resolveSelectorCenter(ctx context.Context, selector string) (in
}
func (d *Driver) InputText(ctx context.Context, text string) error {
// Hybrid path: clear the focused field atomically with the legacy HID
// select-all chord, then let the runner type the text natively. Typing
// covers unicode without the pasteboard and its permission dialog; the
// chord clears regardless of where a tap left the cursor.
if d.runnerTyper() != nil {
return d.withRecovery(ctx, func() error {
if err := d.companion.SendHID(ctx, clearFieldEvents()...); err != nil {
return fmt.Errorf("clear field: %w", err)
}
return d.runnerTyper().TypeText(ctx, text, false)
})
}
// A text-editing companion replaces the field's content natively, which
// covers unicode without the pasteboard and its permission dialog.
if d.textEditor() != nil {
@@ -699,13 +821,23 @@ func (d *Driver) ForegroundApp(ctx context.Context) (string, error) {
const collapsedDumpRetries = 6
const collapsedDumpDelay = 150 * time.Millisecond
// snapshotCompanion is the transport that serves accessibility dumps: the
// in-simulator runner when the hybrid is active (its snapshots never collapse),
// otherwise the legacy companion.
func (d *Driver) snapshotCompanion() transport.Companion {
if d.runnerClient != nil {
return d.runnerClient
}
return d.companion
}
// describeAllRaw fetches the flat accessibility dump with one-restart recovery
// and no collapse handling. The settle loop uses it: it treats a collapsed dump
// as transitional itself, so an inner retry here would double the wait.
func (d *Driver) describeAllRaw(ctx context.Context) ([]byte, error) {
var dump []byte
err := d.withRecovery(ctx, func() error {
info, infoErr := d.companion.AccessibilityInfo(ctx)
info, infoErr := d.snapshotCompanion().AccessibilityInfo(ctx)
if infoErr != nil {
return infoErr
}
@@ -745,13 +877,18 @@ func (d *Driver) makeRunner() runner {
return simctlRunner{companion: d.companion, udid: d.udid}
}
// Close stops the companion child and releases the transport.
// Close stops the companion and runner children and releases the transports.
func (d *Driver) Close() {
if d.companion != nil {
_ = d.companion.Close()
d.companion = nil
}
d.stopChild()
if d.runnerClient != nil {
_ = d.runnerClient.Close()
d.runnerClient = nil
}
d.stopRunnerChild()
if d.processCancel != nil {
d.processCancel()
}
@@ -762,6 +899,19 @@ func (d *Driver) Close() {
func (d *Driver) stopChild() {
child := d.child
d.child = nil
stopProcess(child)
}
// stopRunnerChild terminates the runner's hosting session the same way. The
// session tears down its in-simulator children on SIGTERM; killing it outright
// would orphan them.
func (d *Driver) stopRunnerChild() {
child := d.runnerChild
d.runnerChild = nil
stopProcess(child)
}
func stopProcess(child *exec.Cmd) {
if child == nil || child.Process == nil {
return
}
@@ -0,0 +1,89 @@
package ioscompanion
import (
"bytes"
"context"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"syscall"
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/runnerassets"
)
// runnerPortPlaceholder is the marker the prepare script plants in the
// packaged test configuration's environment. The spawner substitutes the
// session's port before launching.
const runnerPortPlaceholder = "__COMPANION_PORT__"
// realSpawnRunner extracts the embedded runner bundle, writes a test
// configuration bound to the session's port, and starts the hosting test
// session against the configured simulator. Cancel sends SIGTERM so the
// session tears down its in-simulator children cleanly.
func (d *Driver) realSpawnRunner(ctx context.Context, address string) (*exec.Cmd, error) {
extractDirectory := filepath.Join(os.TempDir(), "sanderling-runner")
testRunPath, err := runnerassets.Extract(extractDirectory)
if err != nil {
return nil, fmt.Errorf("extract runner: %w", err)
}
_, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
boundTestRunPath, err := bindTestRunPort(testRunPath, port)
if err != nil {
return nil, err
}
logPath := filepath.Join(extractDirectory, "runner-session-"+port+".log")
logFile, err := os.Create(logPath)
if err != nil {
return nil, fmt.Errorf("create runner log: %w", err)
}
command := exec.CommandContext(ctx, "xcrun", "xcodebuild", "test-without-building",
"-xctestrun", boundTestRunPath,
"-destination", "platform=iOS Simulator,id="+d.udid)
// The session log stays out of the run output: the build tool is noisy
// and would pollute the run's error scan. The path is reported once so a
// failed startup is diagnosable.
command.Stdout = logFile
command.Stderr = logFile
command.Env = []string{
"HOME=" + os.Getenv("HOME"),
"PATH=/usr/bin:/bin",
"TMPDIR=" + os.TempDir(),
}
command.Cancel = func() error { return command.Process.Signal(syscall.SIGTERM) }
command.WaitDelay = shutdownGrace
startErr := command.Start()
// The child holds its own descriptor after Start, so the parent's copy
// closes either way.
logFile.Close()
if startErr != nil {
return nil, fmt.Errorf("start runner session: %w", startErr)
}
fmt.Fprintf(d.output, "runner session pid=%d listening on %s (log: %s)\n", command.Process.Pid, address, logPath)
return command, nil
}
// bindTestRunPort writes a copy of the extracted test configuration with the
// port placeholder substituted, next to the original so its relative paths
// keep resolving. Returns the bound copy's path.
func bindTestRunPort(testRunPath, port string) (string, error) {
configuration, err := os.ReadFile(testRunPath)
if err != nil {
return "", fmt.Errorf("read test configuration: %w", err)
}
if !bytes.Contains(configuration, []byte(runnerPortPlaceholder)) {
return "", fmt.Errorf("test configuration %s carries no %s placeholder", testRunPath, runnerPortPlaceholder)
}
bound := bytes.ReplaceAll(configuration, []byte(runnerPortPlaceholder), []byte(port))
boundPath := filepath.Join(filepath.Dir(testRunPath), "runner-"+port+".xctestrun")
if err := os.WriteFile(boundPath, bound, 0o644); err != nil {
return "", fmt.Errorf("write bound test configuration: %w", err)
}
return boundPath, nil
}