mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
feat(ioscompanion): pair the legacy companion with the in-simulator runner
This commit is contained in:
1 parent
bbe013b8e4
commit
1220b62f68
2 files changed
+246
-7
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user