mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
* feat(companion): add appState, eraseText, pressKey runner handlers
The Go runner transport already calls these methods; the in-device runner
implemented them only latently. They become load-bearing on the device
path, where the hybrid's legacy-companion fallback is absent. Backward
compatible: the simulator hybrid never calls them.
* feat(ios): resolve physical devices from devicectl
ResolveDevice parses xcrun devicectl list devices into Device{Name,
HardwareUDID, CoreDeviceID}: the hardware UDID feeds xcodebuild/iproxy
and the CoreDevice id feeds devicectl install. Matches by name or either
id; errors list candidates on none/ambiguous. Fixes the stale sidecar
comment on ResolveTarget.
* feat(ioscompanion): runner-only device driver mode
NewDevice reuses Driver with d.companion set to the runner dialed over an
iproxy usbmux tunnel, hybrid=false, runnerClient=nil. The existing accessor
seams then route launch/snapshot/text/gesture to the runner with no new
DeviceDriver methods. Device seams swap clear-state to a devicectl
reinstall, container reset to a warn-once no-op, and paste grant to a no-op.
realSpawnDeviceRunner builds and signs the runner at run time via the App
Store Connect API key (no Xcode UI), caching on a source hash.
* test(ioscompanion): cover device wiring, routing, and shell-out argv
Seam-driven NewDevice wiring + gesture/text routing (asserting no keyboard
HID), devicectl/build/test/iproxy argv builders, xctestrun test-target dict
name parsing, signing-credential env checks, and source-hash cache keying.
* feat(testrun): route physical-device iOS runs to the device driver
Execute resolves a non-simulator iOS target through ios.ResolveDevice into
its hardware UDID and CoreDevice id; buildDriver constructs NewDevice via a
seam instead of rejecting the device. Generalizes the --ios-device and
--ios-app-path help to cover the device path; signing stays env-read, never
a flag.
* feat(doctor): device prereqs replace java/sidecar for ios-device
iosDeviceChecks now verifies devicectl, iproxy on PATH, a connected+paired
device (via ios.ConnectedDevices), and App Store Connect signing creds (via
ioscompanion.VerifyDeviceSigning). The retired JVM sidecar checks stay only
under android.
* feat(conformance): device backend uses iphoneos app and tunnel orphan checks
The device backend now builds via just ios-device, points --ios-app-path at
the Debug-iphoneos bundle, and reinstalls each run for clear-state. The G5
orphan scan replaces the retired sidecar.jar check with lingering iproxy and
device test-without-building sessions (destination platform=iOS,id=).
* feat(folio): device build linking the iosArm64 framework
project.yml selects the Kotlin framework slice by SDK (iosArm64 for
iphoneos, iosSimulatorArm64 for simulator) and links via -framework Shared
on the SDK-conditional search path. New ios-device/test-ios-device recipes
mirror ios/test-ios, signing the Debug-iphoneos build with the .env API key.
* docs(cli): document ios-device doctor checks and the device flags
The --ios-device flag now also selects a connected device; --ios-app-path
covers the device install; the doctor gains an ios-device platform whose
checks are devicectl, iproxy, a paired device, and signing credentials.
Corrects the --clear-data default to true.
* fix(ioscompanion): resolve signing key path to absolute
xcodebuild's -authenticationKeyPath requires an absolute path, but .env
files commonly carry a repo-relative one. Resolve it against the working
directory before the stat so a relative ASC_API_KEY_PATH still signs.
* fix(ioscompanion): re-enable signing for the device runner build
companion/project.yml disables code signing for the simulator build, so
the device build inherited it and produced an unsigned runner that the
device rejected at install (0xe8008018). build-for-testing now forces
CODE_SIGNING_ALLOWED/REQUIRED=YES so automatic provisioning signs it.
* fix(ioscompanion): key the device build cache on signing identity
The cache marker hashed only sources, so switching signing team or key
reused a runner signed with the stale identity, which the device rejects at
install (0xe8008018). Fold team + key id into the cache key so a signing
change forces a rebuild.
* docs(getting-started): document physical iOS device setup
Lists the iproxy requirement and the App Store Connect signing env vars
(SANDERLING_IOS_TEAM, ASC_API_*) a device run needs, plus the
test-ios-device recipe and the doctor check.
* feat(ios): native usbmux client and in-process tunnel forwarder
Talk to macOS usbmuxd directly instead of shelling out to iproxy, so the
device path depends on nothing beyond macOS + Xcode.
* refactor(ios): drive device tunnel via io.Closer seam
Replace the tunnelChild *exec.Cmd and spawnTunnel seam with a tunnel
io.Closer and startTunnel seam backed by the in-process usbmux forwarder.
* refactor(ios): remove iproxy spawn from device runner
* test(ios): cover tunnel close via io.Closer not child process
* feat(doctor): check usbmuxd socket instead of iproxy on PATH
* chore(conformance): drop iproxy orphan check; tunnel is in-process
* docs(ios): device tunnel uses native usbmux, nothing to install
* chore: gitignore the signing keys directory
* feat(folio): add Android launcher icon (black bg, white dot)
* feat(folio): add iOS app icon (black bg, white dot)
* feat(folio): add web favicon (black bg, white dot)
* docs(ioscompanion): fix stale const comments
* refactor(ioscompanion): inline single-use devicectl argv builders
* refactor(ioscompanion): inline xcodegenArgs, drop tautological argv tests
* refactor(ioscompanion): inline firstNonEmpty
* refactor(doctor): dedup usbmuxd socket path via ioscompanion seam
* test(doctor): trim redundant signing-check test
* refactor(ioscompanion): deliver COMPANION_PORT via TEST_RUNNER_ env
* fix(testrun): seam preflight so iOS routing tests pass on CI without xcrun
220 lines
7.7 KiB
Go
220 lines
7.7 KiB
Go
// This file implements the physical-device mode of Driver. The device is driven
|
|
// runner-only: the in-device XCUITest runner serves every capability over a
|
|
// usbmux tunnel, with no legacy companion. The simulator hybrid path is left
|
|
// byte-identical; device mode swaps three sim-only seams (reinstall, container
|
|
// reset, paste grant) and brings the runner up over the tunnel instead of a
|
|
// local listener.
|
|
package ioscompanion
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
|
|
)
|
|
|
|
// DeviceOptions configures a device-mode Driver. Signing credentials are not
|
|
// carried here: realSpawnDeviceRunner reads them from the environment at the
|
|
// point of use so secrets never reach the Options struct or run artifacts.
|
|
type DeviceOptions struct {
|
|
// HardwareUDID feeds xcodebuild -destination and the usbmux device match.
|
|
HardwareUDID string
|
|
// CoreDeviceID feeds devicectl install/uninstall.
|
|
CoreDeviceID string
|
|
// BundleID is the app under test.
|
|
BundleID string
|
|
// AppPath is the .app bundle installed via devicectl for clear-state.
|
|
AppPath string
|
|
// Output receives the runner session log path and driver warnings.
|
|
Output io.Writer
|
|
// DoubleTapGapMilliseconds overrides the synthesized double-tap gap.
|
|
DoubleTapGapMilliseconds float64
|
|
|
|
// Test seams. Production leaves them nil and NewDevice wires the real
|
|
// build/spawn/tunnel/dial.
|
|
spawnRunner func(ctx context.Context, address string) (*exec.Cmd, error)
|
|
startTunnel func(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error)
|
|
dialRunner func(address string) (transport.Companion, error)
|
|
pickAddress func() (string, error)
|
|
}
|
|
|
|
// deviceStartupTimeout bounds the runner's startup once its hosting test
|
|
// session is spawned and the tunnel is up. The session's cold start on a
|
|
// physical device is slower than the simulator's, and the build that precedes
|
|
// it runs outside this window (under the process context, not the startup one).
|
|
const deviceStartupTimeout = 180 * time.Second
|
|
|
|
// NewDevice brings up a runner-only Driver against a physical device: it builds
|
|
// and spawns the in-device runner, opens a usbmux tunnel to it, dials the runner
|
|
// over the tunnel, health-probes it, and caches the screen dimensions. Call
|
|
// Close when done to stop the runner session and the tunnel.
|
|
func NewDevice(ctx context.Context, options DeviceOptions) (*Driver, error) {
|
|
if options.HardwareUDID == "" {
|
|
return nil, errors.New("ios device: HardwareUDID is required")
|
|
}
|
|
if options.CoreDeviceID == "" {
|
|
return nil, errors.New("ios device: CoreDeviceID is required")
|
|
}
|
|
output := options.Output
|
|
if output == nil {
|
|
output = io.Discard
|
|
}
|
|
gap := options.DoubleTapGapMilliseconds
|
|
if gap <= 0 {
|
|
gap = DefaultDoubleTapGapMilliseconds
|
|
}
|
|
|
|
d := &Driver{
|
|
udid: options.HardwareUDID,
|
|
coreDeviceID: options.CoreDeviceID,
|
|
bundleID: options.BundleID,
|
|
appPath: options.AppPath,
|
|
output: output,
|
|
doubleTapGapMilliseconds: gap,
|
|
deviceMode: true,
|
|
hybrid: false,
|
|
spawnRunner: options.spawnRunner,
|
|
startTunnel: options.startTunnel,
|
|
}
|
|
if d.spawnRunner == nil {
|
|
d.spawnRunner = d.realSpawnDeviceRunner
|
|
}
|
|
if d.startTunnel == nil {
|
|
d.startTunnel = startUsbmuxTunnel
|
|
}
|
|
d.dialRunner = options.dialRunner
|
|
if d.dialRunner == nil {
|
|
d.dialRunner = func(address string) (transport.Companion, error) {
|
|
return transport.DialRunner(address, d.udid, d.bundleID)
|
|
}
|
|
}
|
|
if options.pickAddress != nil {
|
|
d.pickDeviceAddress = options.pickAddress
|
|
} else {
|
|
d.pickDeviceAddress = pickLoopbackAddress
|
|
}
|
|
|
|
// Device seams: clear-state reinstalls via devicectl; the container reset and
|
|
// paste grant are simulator-only and become no-ops. The runner types
|
|
// natively, so no paste prompt is ever hit.
|
|
d.reinstallApp = d.devicectlReinstall
|
|
d.resetContainer = d.deviceResetContainerUnsupported
|
|
d.grantPaste = func(context.Context) error { return nil }
|
|
d.restart = d.respawnDevice
|
|
d.processContext, d.processCancel = context.WithCancel(ctx)
|
|
|
|
if err := d.bringUpDevice(ctx); err != nil {
|
|
d.Close()
|
|
return nil, err
|
|
}
|
|
|
|
description, err := d.companion.Describe(ctx)
|
|
if err != nil {
|
|
d.Close()
|
|
return nil, fmt.Errorf("describe device: %w", err)
|
|
}
|
|
d.screenWidth = description.WidthPoints
|
|
d.screenHeight = description.HeightPoints
|
|
return d, nil
|
|
}
|
|
|
|
// bringUpDevice builds (if needed) and spawns the in-device runner, opens the
|
|
// tunnel, waits for the forwarded listener, dials the runner, and confirms
|
|
// health. The build runs inside spawnRunner under the process context, so the
|
|
// startup timeout only bounds the post-spawn wait, not the build.
|
|
func (d *Driver) bringUpDevice(ctx context.Context) error {
|
|
address, err := d.pickDeviceAddress()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, port, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
d.runnerAddress = address
|
|
|
|
// The runner listens on the device loopback at the same port number the host
|
|
// tunnel forwards from, so one picked free port covers both ends.
|
|
runnerChild, err := d.spawnRunner(d.processContext, address)
|
|
if err != nil {
|
|
return fmt.Errorf("spawn device runner: %w", err)
|
|
}
|
|
d.runnerChild = runnerChild
|
|
|
|
// The forwarder listens on the host loopback port and bridges to the same
|
|
// port number on the device, where the runner listens.
|
|
tunnel, err := d.startTunnel(d.processContext, d.udid, address, port)
|
|
if err != nil {
|
|
d.stopRunnerChild()
|
|
return fmt.Errorf("start tunnel: %w", err)
|
|
}
|
|
d.tunnel = tunnel
|
|
|
|
startupCtx, cancel := context.WithTimeout(ctx, deviceStartupTimeout)
|
|
defer cancel()
|
|
|
|
if err := waitForListener(startupCtx, address); err != nil {
|
|
d.stopTunnel()
|
|
d.stopRunnerChild()
|
|
return fmt.Errorf("device runner listener: %w", err)
|
|
}
|
|
|
|
companion, err := d.dialRunner(address)
|
|
if err != nil {
|
|
d.stopTunnel()
|
|
d.stopRunnerChild()
|
|
return fmt.Errorf("dial device runner: %w", err)
|
|
}
|
|
d.companion = companion
|
|
|
|
if err := d.waitForHealth(startupCtx); err != nil {
|
|
_ = companion.Close()
|
|
d.stopTunnel()
|
|
d.stopRunnerChild()
|
|
return fmt.Errorf("device runner health: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// respawnDevice is the device-path supervision restart: it tears down the runner
|
|
// transport, its hosting session, and the tunnel, then brings a fresh set up.
|
|
// Both the session and the tunnel restart together because a dropped usbmux
|
|
// connection can take either down.
|
|
func (d *Driver) respawnDevice(ctx context.Context) error {
|
|
if d.companion != nil {
|
|
_ = d.companion.Close()
|
|
}
|
|
d.stopRunnerChild()
|
|
d.stopTunnel()
|
|
return d.bringUpDevice(ctx)
|
|
}
|
|
|
|
// devicectlReinstall uninstalls then installs the app bundle via devicectl,
|
|
// keyed on the CoreDevice id. App lifecycle stays with devicectl: the runner's
|
|
// own install path is simulator-specific.
|
|
func (d *Driver) devicectlReinstall(ctx context.Context) error {
|
|
_ = exec.CommandContext(ctx, "xcrun", "devicectl", "device", "uninstall", "app", "--device", d.coreDeviceID, d.bundleID).Run()
|
|
output, err := exec.CommandContext(ctx, "xcrun", "devicectl", "device", "install", "app", "--device", d.coreDeviceID, d.appPath).CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("devicectl install: %w: %s", err, strings.TrimSpace(string(output)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// deviceResetContainerUnsupported warns once that device clear-state needs an
|
|
// app path for a devicectl reinstall: there is no simulator-style data-container
|
|
// wipe on a physical device.
|
|
func (d *Driver) deviceResetContainerUnsupported(context.Context) error {
|
|
if !d.clearStateWarned {
|
|
fmt.Fprintln(d.output, "clear-state on a physical device requires --ios-app-path for a reinstall; skipping (state not cleared)")
|
|
d.clearStateWarned = true
|
|
}
|
|
return nil
|
|
}
|