mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07: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.
|
// a connection and answer a health probe.
|
||||||
const startupTimeout = 30 * time.Second
|
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
|
// shutdownGrace bounds how long the companion child gets to exit after SIGTERM
|
||||||
// before it is killed.
|
// before it is killed.
|
||||||
const shutdownGrace = 15 * time.Second
|
const shutdownGrace = 15 * time.Second
|
||||||
@@ -110,6 +114,17 @@ type Driver struct {
|
|||||||
dial func(address string) (transport.Companion, error)
|
dial func(address string) (transport.Companion, error)
|
||||||
child *exec.Cmd
|
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
|
// 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
|
// 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
|
// 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,
|
doubleTapGapMilliseconds: gap,
|
||||||
spawnChild: options.spawnChild,
|
spawnChild: options.spawnChild,
|
||||||
dial: options.dialCompanion,
|
dial: options.dialCompanion,
|
||||||
|
hybrid: hybridCompanionEnabled(),
|
||||||
}
|
}
|
||||||
if driverInstance.spawnChild == nil {
|
if driverInstance.spawnChild == nil {
|
||||||
driverInstance.spawnChild = driverInstance.realSpawnChild
|
driverInstance.spawnChild = driverInstance.realSpawnChild
|
||||||
@@ -149,6 +165,14 @@ func New(ctx context.Context, options Options) (*Driver, error) {
|
|||||||
if driverInstance.dial == nil {
|
if driverInstance.dial == nil {
|
||||||
driverInstance.dial = transport.Dial
|
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
|
pickAddress := options.pickAddress
|
||||||
if pickAddress == nil {
|
if pickAddress == nil {
|
||||||
pickAddress = pickLoopbackAddress
|
pickAddress = pickLoopbackAddress
|
||||||
@@ -168,6 +192,12 @@ func New(ctx context.Context, options Options) (*Driver, error) {
|
|||||||
if err := driverInstance.bringUp(ctx); err != nil {
|
if err := driverInstance.bringUp(ctx); err != nil {
|
||||||
return nil, err
|
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)
|
description, err := driverInstance.companion.Describe(ctx)
|
||||||
if err != nil {
|
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
|
// respawnAndRedial tears down the current transports and children, then brings
|
||||||
// fresh pair up at the same address. Used as the supervision restart.
|
// 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 {
|
func (d *Driver) respawnAndRedial(ctx context.Context) error {
|
||||||
if d.companion != nil {
|
if d.companion != nil {
|
||||||
_ = d.companion.Close()
|
_ = d.companion.Close()
|
||||||
d.companion = nil
|
d.companion = nil
|
||||||
}
|
}
|
||||||
d.stopChild()
|
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
|
// 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
|
// must go through the pasteboard, since HID cannot express it) never trips
|
||||||
// the iOS paste-permission prompt. clearState reinstall resets the grant,
|
// the iOS paste-permission prompt. clearState reinstall resets the grant,
|
||||||
// so it is reapplied on every launch. Best effort: if it fails, the paste
|
// so it is reapplied on every launch. Best effort: if it fails, the paste
|
||||||
// path still handles the prompt, just slower.
|
// path still handles the prompt, just slower. The hybrid path types
|
||||||
if d.bundleID != "" {
|
// natively and never touches the pasteboard, so it skips the grant.
|
||||||
|
if d.bundleID != "" && d.runnerTyper() == nil {
|
||||||
if err := d.grantPaste(ctx); err != nil {
|
if err := d.grantPaste(ctx); err != nil {
|
||||||
fmt.Fprintf(d.output, "grant pasteboard access failed (continuing): %v\n", err)
|
fmt.Fprintf(d.output, "grant pasteboard access failed (continuing): %v\n", err)
|
||||||
}
|
}
|
||||||
@@ -458,6 +558,16 @@ func (d *Driver) textEditor() transport.TextEditor {
|
|||||||
return nil
|
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.
|
// 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
|
// Only Return/Enter has a hardware-keyboard equivalent on the simulator; other
|
||||||
// names (notably "back" and "home") have no HID key and report unsupported.
|
// 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 {
|
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
|
// A text-editing companion replaces the field's content natively, which
|
||||||
// covers unicode without the pasteboard and its permission dialog.
|
// covers unicode without the pasteboard and its permission dialog.
|
||||||
if d.textEditor() != nil {
|
if d.textEditor() != nil {
|
||||||
@@ -699,13 +821,23 @@ func (d *Driver) ForegroundApp(ctx context.Context) (string, error) {
|
|||||||
const collapsedDumpRetries = 6
|
const collapsedDumpRetries = 6
|
||||||
const collapsedDumpDelay = 150 * time.Millisecond
|
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
|
// describeAllRaw fetches the flat accessibility dump with one-restart recovery
|
||||||
// and no collapse handling. The settle loop uses it: it treats a collapsed dump
|
// 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.
|
// as transitional itself, so an inner retry here would double the wait.
|
||||||
func (d *Driver) describeAllRaw(ctx context.Context) ([]byte, error) {
|
func (d *Driver) describeAllRaw(ctx context.Context) ([]byte, error) {
|
||||||
var dump []byte
|
var dump []byte
|
||||||
err := d.withRecovery(ctx, func() error {
|
err := d.withRecovery(ctx, func() error {
|
||||||
info, infoErr := d.companion.AccessibilityInfo(ctx)
|
info, infoErr := d.snapshotCompanion().AccessibilityInfo(ctx)
|
||||||
if infoErr != nil {
|
if infoErr != nil {
|
||||||
return infoErr
|
return infoErr
|
||||||
}
|
}
|
||||||
@@ -745,13 +877,18 @@ func (d *Driver) makeRunner() runner {
|
|||||||
return simctlRunner{companion: d.companion, udid: d.udid}
|
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() {
|
func (d *Driver) Close() {
|
||||||
if d.companion != nil {
|
if d.companion != nil {
|
||||||
_ = d.companion.Close()
|
_ = d.companion.Close()
|
||||||
d.companion = nil
|
d.companion = nil
|
||||||
}
|
}
|
||||||
d.stopChild()
|
d.stopChild()
|
||||||
|
if d.runnerClient != nil {
|
||||||
|
_ = d.runnerClient.Close()
|
||||||
|
d.runnerClient = nil
|
||||||
|
}
|
||||||
|
d.stopRunnerChild()
|
||||||
if d.processCancel != nil {
|
if d.processCancel != nil {
|
||||||
d.processCancel()
|
d.processCancel()
|
||||||
}
|
}
|
||||||
@@ -762,6 +899,19 @@ func (d *Driver) Close() {
|
|||||||
func (d *Driver) stopChild() {
|
func (d *Driver) stopChild() {
|
||||||
child := d.child
|
child := d.child
|
||||||
d.child = nil
|
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 {
|
if child == nil || child.Process == nil {
|
||||||
return
|
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