mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07: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
This commit is contained in:
35 files changed
+2370
-81
No files matched your search
@@ -73,3 +73,4 @@ examples/folio-web/.vite/
|
||||
/research/
|
||||
|
||||
/talk/
|
||||
keys/*
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion"
|
||||
"github.com/priyanshujain/sanderling/internal/ios"
|
||||
"github.com/priyanshujain/sanderling/internal/sidecarassets"
|
||||
)
|
||||
|
||||
@@ -59,8 +61,9 @@ func androidChecks() []doctorCheck {
|
||||
|
||||
// iosChecks covers the simulator path, which the native companion drives with
|
||||
// no JVM. A simulator host with no Java still passes. Physical-device runs
|
||||
// additionally need java and the sidecar JAR, covered by iosDeviceChecks and
|
||||
// surfaced through the "all" union.
|
||||
// additionally need devicectl, the usbmuxd socket, a connected device, and
|
||||
// signing credentials, covered by iosDeviceChecks and surfaced through the
|
||||
// "all" union.
|
||||
func iosChecks() []doctorCheck {
|
||||
return []doctorCheck{
|
||||
{Name: "xcrun on PATH (ios simulator)", Run: checkExecutableOnPath("xcrun")},
|
||||
@@ -68,12 +71,18 @@ func iosChecks() []doctorCheck {
|
||||
}
|
||||
}
|
||||
|
||||
// iosDeviceChecks covers the extra prerequisites a physical iOS device needs:
|
||||
// the JVM and a real sidecar JAR for the sidecar driver path.
|
||||
// iosDeviceChecks covers the prerequisites a physical iOS device needs: the
|
||||
// runner is built and driven over a native usbmux tunnel, so devicectl installs
|
||||
// the app, the macOS usbmuxd socket carries the tunnel, a device must be
|
||||
// connected and paired, and App Store Connect signing credentials must be
|
||||
// present for the no-UI build. Everything here is part of macOS + Xcode; nothing
|
||||
// is installed.
|
||||
func iosDeviceChecks() []doctorCheck {
|
||||
return []doctorCheck{
|
||||
{Name: "java 17+ on PATH (ios physical device)", Run: checkJavaVersion},
|
||||
{Name: "sidecar JAR is real (ios physical device)", Run: checkSidecarJAR},
|
||||
{Name: "devicectl available (ios physical device)", Run: checkDevicectl},
|
||||
{Name: "usbmuxd socket present (ios physical device)", Run: checkUsbmuxd},
|
||||
{Name: "an iOS device is connected and paired", Run: checkDeviceConnected},
|
||||
{Name: "App Store Connect signing credentials present", Run: checkDeviceSigning},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +129,48 @@ func checkSimctl(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Device-check seams: package-level so the doctor's device checks run against
|
||||
// canned results instead of a real device.
|
||||
var (
|
||||
doctorConnectedDevices = ios.ConnectedDevices
|
||||
doctorVerifySigning = ioscompanion.VerifyDeviceSigning
|
||||
doctorVerifyUsbmuxd = ioscompanion.VerifyUsbmuxdSocket
|
||||
)
|
||||
|
||||
// checkDevicectl exercises `xcrun devicectl --version`: devicectl is an xcrun
|
||||
// subcommand, so a PATH lookup cannot find it.
|
||||
func checkDevicectl(ctx context.Context) error {
|
||||
if err := exec.CommandContext(ctx, "xcrun", "devicectl", "--version").Run(); err != nil {
|
||||
return fmt.Errorf("xcrun devicectl --version: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkUsbmuxd confirms the macOS usbmuxd socket is present: the native device
|
||||
// tunnel speaks to it directly instead of shelling out to a third-party client.
|
||||
func checkUsbmuxd(_ context.Context) error {
|
||||
return doctorVerifyUsbmuxd()
|
||||
}
|
||||
|
||||
// checkDeviceConnected confirms at least one physical iOS device is connected
|
||||
// and paired, the prerequisite for the tunnel and the install.
|
||||
func checkDeviceConnected(ctx context.Context) error {
|
||||
devices, err := doctorConnectedDevices(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(devices) == 0 {
|
||||
return fmt.Errorf("no connected iOS device; connect and pair an iPhone")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkDeviceSigning confirms the App Store Connect signing environment is
|
||||
// complete and the key file exists, so the no-UI device build can sign.
|
||||
func checkDeviceSigning(_ context.Context) error {
|
||||
return doctorVerifySigning()
|
||||
}
|
||||
|
||||
func checkSidecarJAR(_ context.Context) error {
|
||||
if sidecarassets.IsPlaceholder() {
|
||||
return fmt.Errorf("placeholder JAR embedded; run `make sidecar && make sanderling` to embed the real fat JAR")
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/ios"
|
||||
)
|
||||
|
||||
func TestRunDoctorChecks_AllPass(t *testing.T) {
|
||||
@@ -142,15 +144,58 @@ func TestDoctorChecksFor_iOSSimulator_OmitsJava(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorChecksFor_iOSDevice_IncludesJava(t *testing.T) {
|
||||
func TestDoctorChecksFor_iOSDevice_CoversDevicePrereqs(t *testing.T) {
|
||||
checks := doctorChecksFor("ios-device")
|
||||
for _, c := range checks {
|
||||
if strings.Contains(c.Name, "java") || strings.Contains(c.Name, "sidecar") {
|
||||
t.Errorf("device checks must not include the retired %q", c.Name)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"devicectl", "usbmuxd", "connected and paired", "signing credentials"} {
|
||||
found := false
|
||||
for _, c := range doctorChecksFor("ios-device") {
|
||||
if strings.Contains(c.Name, "java") {
|
||||
for _, c := range checks {
|
||||
if strings.Contains(c.Name, want) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("ios-device checks must include java for the sidecar path")
|
||||
t.Errorf("ios-device checks missing %q: %+v", want, checks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDeviceConnected(t *testing.T) {
|
||||
original := doctorConnectedDevices
|
||||
t.Cleanup(func() { doctorConnectedDevices = original })
|
||||
|
||||
doctorConnectedDevices = func(context.Context) ([]ios.Device, error) {
|
||||
return []ios.Device{{Name: "iPhone"}}, nil
|
||||
}
|
||||
if err := checkDeviceConnected(context.Background()); err != nil {
|
||||
t.Fatalf("a connected device must pass: %v", err)
|
||||
}
|
||||
|
||||
doctorConnectedDevices = func(context.Context) ([]ios.Device, error) { return nil, nil }
|
||||
if err := checkDeviceConnected(context.Background()); err == nil {
|
||||
t.Fatal("no device must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDeviceSigning_SurfacesSeamResult(t *testing.T) {
|
||||
// checkDeviceSigning is a passthrough to the driver's credential check; the
|
||||
// credential logic itself is covered by TestReadSigningCredentials* in the
|
||||
// ioscompanion package. Here we only confirm the wiring through the seam.
|
||||
original := doctorVerifySigning
|
||||
t.Cleanup(func() { doctorVerifySigning = original })
|
||||
|
||||
doctorVerifySigning = func() error { return nil }
|
||||
if err := checkDeviceSigning(context.Background()); err != nil {
|
||||
t.Fatalf("a passing signing check must surface nil: %v", err)
|
||||
}
|
||||
|
||||
doctorVerifySigning = func() error { return errors.New("missing credentials") }
|
||||
if err := checkDeviceSigning(context.Background()); err == nil {
|
||||
t.Fatal("a failing signing check must surface the error")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,8 +52,8 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) {
|
||||
flagSet.StringVar(&options.bundleID, "bundle-id", "", "target app bundle ID (required)")
|
||||
flagSet.StringVar(&options.platform, "platform", "android", "target platform: android, ios, web")
|
||||
flagSet.StringVar(&options.avd, "avd", "", "Android AVD name to boot if no device is connected")
|
||||
flagSet.StringVar(&options.iosDevice, "ios-device", "", "iOS simulator name or UDID to boot if none is running")
|
||||
flagSet.StringVar(&options.iosAppPath, "ios-app-path", "", "path to the .app bundle for iOS simulator clear-state reinstall")
|
||||
flagSet.StringVar(&options.iosDevice, "ios-device", "", "iOS target: a simulator name/UDID to boot, or a connected device's name, UDID, or CoreDevice id")
|
||||
flagSet.StringVar(&options.iosAppPath, "ios-app-path", "", "path to the .app bundle for iOS clear-state reinstall (simulator: simctl; device: devicectl)")
|
||||
flagSet.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration")
|
||||
flagSet.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)")
|
||||
flagSet.StringVar(&options.output, "output", "./runs", "output directory for traces")
|
||||
|
||||
@@ -31,6 +31,29 @@ enum AppLifecycle {
|
||||
}
|
||||
}
|
||||
|
||||
// state reports the app's run state in the vocabulary the Go transport maps
|
||||
// to a process state: foreground, background, or notRunning. On the
|
||||
// simulator the hybrid never calls it; on device it backs ForegroundApp.
|
||||
static func state(bundleIdentifier: String) -> String {
|
||||
var result = "notRunning"
|
||||
let collect = {
|
||||
switch XCUIApplication(bundleIdentifier: bundleIdentifier).state {
|
||||
case .runningForeground:
|
||||
result = "foreground"
|
||||
case .runningBackground, .runningBackgroundSuspended:
|
||||
result = "background"
|
||||
default:
|
||||
result = "notRunning"
|
||||
}
|
||||
}
|
||||
if Thread.isMainThread {
|
||||
collect()
|
||||
} else {
|
||||
DispatchQueue.main.sync(execute: collect)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// onMainCatching runs automation work on the main thread and converts a
|
||||
// framework assertion into a thrown error so the server survives it.
|
||||
private static func onMainCatching(_ work: @escaping () -> Void) throws {
|
||||
|
||||
@@ -122,6 +122,19 @@ final class Server {
|
||||
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
|
||||
try TextInput.type(text: text, replace: replace, bundleIdentifier: bundleIdentifier)
|
||||
return ["ok": true]
|
||||
case "eraseText":
|
||||
let count = params["count"] as? Int ?? 0
|
||||
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
|
||||
try TextInput.erase(count: count, bundleIdentifier: bundleIdentifier)
|
||||
return ["ok": true]
|
||||
case "pressKey":
|
||||
let key = params["key"] as? String ?? ""
|
||||
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
|
||||
try TextInput.pressKey(key: key, bundleIdentifier: bundleIdentifier)
|
||||
return ["ok": true]
|
||||
case "appState":
|
||||
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
|
||||
return ["state": AppLifecycle.state(bundleIdentifier: bundleIdentifier)]
|
||||
case "screenshot":
|
||||
return try screenshot()
|
||||
default:
|
||||
|
||||
@@ -37,6 +37,40 @@ enum TextInput {
|
||||
}
|
||||
}
|
||||
|
||||
// erase deletes count characters from the focused field by typing that many
|
||||
// delete keys. Used on device, where EraseText routes to the runner instead
|
||||
// of the legacy companion's HID stream.
|
||||
static func erase(count: Int, bundleIdentifier: String) throws {
|
||||
guard count > 0 else { return }
|
||||
let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: count)
|
||||
try typeOnFocus(deletes, bundleIdentifier: bundleIdentifier)
|
||||
}
|
||||
|
||||
// pressKey types a single logical key into the focused field. Only return is
|
||||
// supported, matching the simulator companion's key surface.
|
||||
static func pressKey(key: String, bundleIdentifier: String) throws {
|
||||
switch key {
|
||||
case "return", "enter", "Return", "Enter":
|
||||
try typeOnFocus(XCUIKeyboardKey.return.rawValue, bundleIdentifier: bundleIdentifier)
|
||||
default:
|
||||
throw TextInputError.typingFailed("unsupported key \(key)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func typeOnFocus(_ payload: String, bundleIdentifier: String) throws {
|
||||
let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
|
||||
var caughtError: NSError?
|
||||
var completed = false
|
||||
runOnMain {
|
||||
completed = CompanionRunCatching({
|
||||
application.typeText(payload)
|
||||
}, &caughtError)
|
||||
}
|
||||
if !completed {
|
||||
throw TextInputError.typingFailed(caughtError?.localizedDescription ?? "unknown")
|
||||
}
|
||||
}
|
||||
|
||||
private static func runOnMain(_ work: () -> Void) {
|
||||
if Thread.isMainThread {
|
||||
work()
|
||||
|
||||
+23
-16
@@ -7,8 +7,8 @@
|
||||
# Backends:
|
||||
# BACKEND=simulator (default) drive the booted iOS simulator
|
||||
# BACKEND=device drive an attached physical iPhone via the
|
||||
# native sidecar; select it with
|
||||
# IOS_DEVICE="<name>" (passed as --ios-device)
|
||||
# driver's runner-only device path; select it
|
||||
# with IOS_DEVICE="<name>" (passed as --ios-device)
|
||||
#
|
||||
# Usage:
|
||||
# ./gates.sh run the simulator gates
|
||||
@@ -37,7 +37,13 @@ IOS_DEVICE="${IOS_DEVICE:-iPhone 17 Pro}"
|
||||
|
||||
bundle_id="app.folio"
|
||||
spec_path="${folio_directory}/sanderling/spec.ts"
|
||||
ios_app="${folio_directory}/app/iosApp/build/Build/Products/Debug-iphonesimulator/iosApp.app"
|
||||
# The built app bundle differs by SDK: the simulator build lands under
|
||||
# Debug-iphonesimulator, the device build under Debug-iphoneos.
|
||||
if [[ "$BACKEND" == "device" ]]; then
|
||||
ios_app="${folio_directory}/app/iosApp/build/Build/Products/Debug-iphoneos/iosApp.app"
|
||||
else
|
||||
ios_app="${folio_directory}/app/iosApp/build/Build/Products/Debug-iphonesimulator/iosApp.app"
|
||||
fi
|
||||
|
||||
# The companion binary, embedded for simulator runs. Referenced by file name
|
||||
# only for the orphan-process check; prose elsewhere says "the companion".
|
||||
@@ -187,7 +193,8 @@ print(samples[rank - 1])
|
||||
|
||||
# G5 orphan check: report any lingering companion, runner session (the hybrid
|
||||
# simulator driver hosts an in-simulator runner), and, on the device backend,
|
||||
# the XCTest runner java sidecar. Empty output means clean.
|
||||
# the device runner session. The usbmux tunnel is an in-process forwarder that
|
||||
# dies with sanderling, so it leaves no process to check. Empty output is clean.
|
||||
orphan_processes() {
|
||||
local found=""
|
||||
if pgrep -f "$companion_process_name" >/dev/null 2>&1; then
|
||||
@@ -200,9 +207,10 @@ orphan_processes() {
|
||||
found+="runner-app "
|
||||
fi
|
||||
if [[ "$BACKEND" == "device" ]]; then
|
||||
# The native sidecar that drives the XCTest runner for physical devices.
|
||||
if pgrep -f "sanderling.*sidecar.jar" >/dev/null 2>&1; then
|
||||
found+="sidecar "
|
||||
# The device test session that hosts the runner. Its destination carries
|
||||
# platform=iOS,id=<udid>.
|
||||
if pgrep -f "xctestrun.*platform=iOS,id=" >/dev/null 2>&1; then
|
||||
found+="device-session "
|
||||
fi
|
||||
fi
|
||||
printf '%s' "$found"
|
||||
@@ -215,15 +223,11 @@ invoke_sanderling() {
|
||||
local output_log="$2"
|
||||
local exit_status_file="$3"
|
||||
|
||||
local target_flags=()
|
||||
if [[ "$BACKEND" == "device" ]]; then
|
||||
target_flags=(--ios-device "$IOS_DEVICE")
|
||||
else
|
||||
# Simulator: build + install the current app so each run starts from the
|
||||
# current build, matching the test-ios recipe. clear-state reinstall uses
|
||||
# --ios-app-path. just ios boots IOS_DEVICE if nothing is booted.
|
||||
target_flags=(--ios-device "$IOS_DEVICE" --ios-app-path "$ios_app")
|
||||
fi
|
||||
# Both backends pass --ios-app-path so each run reinstalls the current build
|
||||
# for a clean clear-state start (device install via devicectl, simulator via
|
||||
# simctl). The device backend selects the connected iPhone by name; the
|
||||
# simulator backend boots IOS_DEVICE if nothing is booted.
|
||||
local target_flags=(--ios-device "$IOS_DEVICE" --ios-app-path "$ios_app")
|
||||
|
||||
local status=0
|
||||
"$SANDERLING" test \
|
||||
@@ -254,6 +258,9 @@ run_gates() {
|
||||
if [[ "$BACKEND" == "simulator" ]]; then
|
||||
echo "preparing folio build for the simulator backend"
|
||||
( cd "$folio_directory" && just ios >/dev/null )
|
||||
else
|
||||
echo "preparing folio device build"
|
||||
( cd "$folio_directory" && just ios-device >/dev/null )
|
||||
fi
|
||||
|
||||
local timestamp
|
||||
|
||||
+6
-4
@@ -19,11 +19,12 @@ Run a spec against an app for a fixed duration.
|
||||
| `--launcher-activity` | resolved | Optional `<pkg>/<activity>` to launch. Overrides default resolution. |
|
||||
| `--platform` | `android` | Target platform: `android`, `ios`, or `web`. |
|
||||
| `--avd` | optional (android) | Android AVD name to boot if no device is connected. Required only when no device is connected and multiple AVDs exist. |
|
||||
| `--ios-device` | optional (ios) | iOS simulator name or UDID to boot if none is running. |
|
||||
| `--ios-device` | optional (ios) | iOS target: a simulator name/UDID to boot, or a connected device's name, UDID, or CoreDevice id. |
|
||||
| `--ios-app-path` | optional (ios) | Path to the `.app` bundle for clear-state reinstall (simulator via `simctl`, device via `devicectl`). |
|
||||
| `--duration` | `5m` | Total test duration (`30s`, `5m`, `2h`, `1d`). |
|
||||
| `--seed` | `0` | PRNG seed. `0` uses a random seed and records it in `meta.json`. |
|
||||
| `--output` | `./runs` | Output directory for traces. |
|
||||
| `--clear-data` | `false` | Clear app data before launching so the run starts from a fresh install. |
|
||||
| `--clear-data` | `true` | Clear app data before launching so the run starts from a fresh install. Pass `--clear-data=false` to resume prior state. |
|
||||
|
||||
## `sanderling replay [run-or-runs-dir]`
|
||||
|
||||
@@ -42,7 +43,7 @@ See [the replay UI page](./replay/) for the panel reference and keyboard shortcu
|
||||
Check the host environment for a working sanderling setup.
|
||||
|
||||
```
|
||||
sanderling doctor [--platform web|android|ios|all]
|
||||
sanderling doctor [--platform web|android|ios|ios-device|all]
|
||||
```
|
||||
|
||||
`--platform` defaults to `all`, which runs every platform's checks (deduped). Pass a specific platform to scope the output.
|
||||
@@ -51,7 +52,8 @@ sanderling doctor [--platform web|android|ios|all]
|
||||
|---|---|
|
||||
| `web` | headless Chromium can launch (the bundled CDP surface boots a real browser). |
|
||||
| `android` | `adb` on PATH; `emulator` on PATH or under `ANDROID_HOME`; Java 17+; embedded native sidecar JAR is real. |
|
||||
| `ios` | `xcrun` on PATH; `simctl` on PATH; Java 17+; embedded native sidecar JAR is real. |
|
||||
| `ios` | `xcrun` on PATH; `simctl` on PATH. The simulator path drives the native companion with no JVM. |
|
||||
| `ios-device` | the `ios` checks plus `devicectl`; the macOS `usbmuxd` socket; a connected, paired device; App Store Connect signing credentials present. |
|
||||
|
||||
## `sanderling version`
|
||||
|
||||
|
||||
@@ -65,6 +65,21 @@ IOS_DEVICE="iPhone 15" just test-ios # pick a different simulator
|
||||
|
||||
`just test-ios` boots the simulator if needed, builds and installs the app, then runs `sanderling test --platform ios`.
|
||||
|
||||
#### Physical device
|
||||
|
||||
A connected iPhone is driven over a usbmux tunnel by a runner the driver builds and signs at run time. The tunnel talks to macOS's own `usbmuxd`, so nothing extra is installed beyond Xcode. It needs App Store Connect signing credentials in the environment (a gitignored `.env` is loaded by `just`):
|
||||
|
||||
```sh
|
||||
SANDERLING_IOS_TEAM=<10-char team id>
|
||||
ASC_API_KEY_ID=<key id>
|
||||
ASC_API_ISSUER_ID=<issuer id>
|
||||
ASC_API_KEY_PATH=<absolute path to AuthKey_*.p8>
|
||||
|
||||
IOS_DEVICE="iPhone" just test-ios-device # name, UDID, or CoreDevice id
|
||||
```
|
||||
|
||||
Run `sanderling doctor --platform ios-device` to check `devicectl`, the `usbmuxd` socket, a connected and paired device, and the signing credentials before a run.
|
||||
|
||||
### Web
|
||||
|
||||
From either example. For the KMP wasmJs build, use `examples/folio`:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="Folio"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="false"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<activity
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 709 B |
Binary file not shown.
|
After Width: | Height: | Size: 563 B |
Binary file not shown.
|
After Width: | Height: | Size: 924 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -19,25 +19,33 @@ targets:
|
||||
sources:
|
||||
- iosApp
|
||||
dependencies:
|
||||
- framework: ../shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework
|
||||
embed: false
|
||||
- sdk: libsqlite3.tbd
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: app.folio
|
||||
PRODUCT_NAME: iosApp
|
||||
INFOPLIST_FILE: iosApp/Info.plist
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
# The Kotlin framework slice differs by SDK: device builds link the
|
||||
# iosArm64 framework, simulator builds the iosSimulatorArm64 one. The
|
||||
# search path and the prebuild gradle task both follow this variable.
|
||||
KOTLIN_FRAMEWORK_DIR[sdk=iphoneos*]: iosArm64
|
||||
KOTLIN_FRAMEWORK_DIR[sdk=iphonesimulator*]: iosSimulatorArm64
|
||||
FRAMEWORK_SEARCH_PATHS:
|
||||
- $(SRCROOT)/../shared/build/bin/$(KOTLIN_FRAMEWORK_DIR)/debugFramework
|
||||
OTHER_LDFLAGS:
|
||||
- -ObjC
|
||||
KOTLIN_FRAMEWORK_DIR: iosSimulatorArm64
|
||||
- -framework
|
||||
- Shared
|
||||
preBuildScripts:
|
||||
- name: Build Kotlin framework
|
||||
script: |
|
||||
cd "$SRCROOT/../.."
|
||||
if [[ "$PLATFORM_NAME" == iphoneos ]]; then
|
||||
task=linkDebugFrameworkIosArm64
|
||||
else
|
||||
task=linkDebugFrameworkIosSimulatorArm64
|
||||
fi
|
||||
ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" \
|
||||
./gradlew :app:shared:linkDebugFrameworkIosSimulatorArm64
|
||||
outputFiles:
|
||||
- $(SRCROOT)/../shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework/Shared
|
||||
./gradlew :app:shared:$task
|
||||
basedOnDependencyAnalysis: false
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Folio</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' fill='black'/><circle cx='32' cy='32' r='14' fill='white'/></svg>">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #000; color: #fff; font-family: system-ui, sans-serif; }
|
||||
#app { height: 100%; }
|
||||
|
||||
@@ -8,6 +8,7 @@ seed := env_var_or_default("SEED", "0")
|
||||
output := env_var_or_default("OUTPUT", justfile_directory() / "sanderling" / "runs")
|
||||
ios_device := env_var_or_default("IOS_DEVICE", "iPhone 17 Pro")
|
||||
ios_app := justfile_directory() / "app" / "iosApp" / "build" / "Build" / "Products" / "Debug-iphonesimulator" / "iosApp.app"
|
||||
ios_app_device := justfile_directory() / "app" / "iosApp" / "build" / "Build" / "Products" / "Debug-iphoneos" / "iosApp.app"
|
||||
|
||||
default:
|
||||
@just --list
|
||||
@@ -122,6 +123,25 @@ ios:
|
||||
xcrun simctl install booted "{{ios_app}}"
|
||||
xcrun simctl launch booted app.folio
|
||||
|
||||
# Build the folio app for a connected physical iOS device (signed via .env creds).
|
||||
# Signing reads ASC_API_* and DEVELOPMENT_TEAM/SANDERLING_IOS_TEAM from .env;
|
||||
# sanderling installs the build on the device per run via devicectl.
|
||||
ios-device:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export ANDROID_HOME="$(just _android-home)"
|
||||
just ios-gen
|
||||
xcodebuild -project app/iosApp/iosApp.xcodeproj -scheme iosApp \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-derivedDataPath app/iosApp/build \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$ASC_API_KEY_PATH" \
|
||||
-authenticationKeyID "$ASC_API_KEY_ID" \
|
||||
-authenticationKeyIssuerID "$ASC_API_ISSUER_ID" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="${SANDERLING_IOS_TEAM:-${DEVELOPMENT_TEAM:-}}" \
|
||||
build | tail -5
|
||||
|
||||
# Run 'sanderling test' against the folio app. Uses a connected device if one is
|
||||
# online; otherwise boots AVD=<name> when provided, or auto-boots a bootable AVD.
|
||||
# Depends on install so the run always fuzzes the current build, matching
|
||||
@@ -173,3 +193,20 @@ test-ios:
|
||||
--duration "{{duration}}" \
|
||||
--seed "{{seed}}" \
|
||||
--output "{{output}}"
|
||||
|
||||
# Requires App Store Connect signing creds in .env and IOS_DEVICE set to the
|
||||
# connected device's name (or UDID / CoreDevice id).
|
||||
# Build + install + run sanderling spec on a connected physical iOS device.
|
||||
test-ios-device:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
just ios-device
|
||||
"{{sanderling}}" test \
|
||||
--platform ios \
|
||||
--spec "{{justfile_directory()}}/sanderling/spec.ts" \
|
||||
--bundle-id app.folio \
|
||||
--ios-app-path "{{ios_app_device}}" \
|
||||
--ios-device "{{ios_device}}" \
|
||||
--duration "{{duration}}" \
|
||||
--seed "{{seed}}" \
|
||||
--output "{{output}}"
|
||||
@@ -0,0 +1,219 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package ioscompanion
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"os/exec"
|
||||
"testing"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
|
||||
)
|
||||
|
||||
// startLoopbackListener accepts and immediately closes connections so
|
||||
// waitForListener succeeds against a real address without a real runner.
|
||||
func startLoopbackListener(t *testing.T) string {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { listener.Close() })
|
||||
go func() {
|
||||
for {
|
||||
conn, acceptErr := listener.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
return listener.Addr().String()
|
||||
}
|
||||
|
||||
func testDeviceOptions(address string, companion transport.Companion) DeviceOptions {
|
||||
return DeviceOptions{
|
||||
HardwareUDID: "00008140-HW",
|
||||
CoreDeviceID: "CORE-1",
|
||||
BundleID: "com.example.app",
|
||||
Output: &bytes.Buffer{},
|
||||
spawnRunner: func(context.Context, string) (*exec.Cmd, error) { return &exec.Cmd{}, nil },
|
||||
startTunnel: func(context.Context, string, string, string) (io.Closer, error) { return io.NopCloser(nil), nil },
|
||||
dialRunner: func(string) (transport.Companion, error) { return companion, nil },
|
||||
pickAddress: func() (string, error) { return address, nil },
|
||||
}
|
||||
}
|
||||
|
||||
func newDeviceCompanion() *fakeTextEditingCompanion {
|
||||
companion := &fakeTextEditingCompanion{}
|
||||
companion.accessibilityJSON = "[]"
|
||||
companion.describe = transport.ScreenDescription{WidthPoints: 393, HeightPoints: 852}
|
||||
return companion
|
||||
}
|
||||
|
||||
func TestNewDeviceWiresRunnerOnlyMode(t *testing.T) {
|
||||
address := startLoopbackListener(t)
|
||||
companion := newDeviceCompanion()
|
||||
d, err := NewDevice(context.Background(), testDeviceOptions(address, companion))
|
||||
if err != nil {
|
||||
t.Fatalf("NewDevice: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
if !d.deviceMode {
|
||||
t.Fatal("deviceMode must be true")
|
||||
}
|
||||
if d.hybrid {
|
||||
t.Fatal("device mode must not be hybrid")
|
||||
}
|
||||
if d.runnerClient != nil {
|
||||
t.Fatal("device mode must leave runnerClient nil so InputText avoids the hybrid HID chord")
|
||||
}
|
||||
if d.companion != companion {
|
||||
t.Fatal("d.companion must be the runner dialed over the tunnel")
|
||||
}
|
||||
if d.coreDeviceID != "CORE-1" {
|
||||
t.Fatalf("coreDeviceID = %q, want CORE-1", d.coreDeviceID)
|
||||
}
|
||||
if d.screenWidth != 393 || d.screenHeight != 852 {
|
||||
t.Fatalf("screen = %dx%d, want 393x852", d.screenWidth, d.screenHeight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDeviceRequiresIdentifiers(t *testing.T) {
|
||||
if _, err := NewDevice(context.Background(), DeviceOptions{CoreDeviceID: "x"}); err == nil {
|
||||
t.Fatal("missing HardwareUDID must error")
|
||||
}
|
||||
if _, err := NewDevice(context.Background(), DeviceOptions{HardwareUDID: "x"}); err == nil {
|
||||
t.Fatal("missing CoreDeviceID must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceInputTextUsesNativeEditorNotKeyboardHID(t *testing.T) {
|
||||
address := startLoopbackListener(t)
|
||||
companion := newDeviceCompanion()
|
||||
d, err := NewDevice(context.Background(), testDeviceOptions(address, companion))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
if err := d.InputText(context.Background(), "héllo 🌟"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(companion.inputTexts) != 1 || companion.inputTexts[0] != "héllo 🌟" {
|
||||
t.Fatalf("inputTexts = %v, want native replace of the text", companion.inputTexts)
|
||||
}
|
||||
for _, call := range companion.recorded() {
|
||||
if call == "hid" {
|
||||
t.Fatal("device text must go through the native editor, never a keyboard HID chord")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceGesturesRouteTouchHIDToRunner(t *testing.T) {
|
||||
address := startLoopbackListener(t)
|
||||
companion := newDeviceCompanion()
|
||||
d, err := NewDevice(context.Background(), testDeviceOptions(address, companion))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
if err := d.DoubleTap(context.Background(), 10, 20); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if indexOf(companion.recorded(), "hid") < 0 {
|
||||
t.Fatalf("gesture must send touch HID to the runner; got %v", companion.recorded())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceEraseAndPressKeyRouteThroughEditor(t *testing.T) {
|
||||
address := startLoopbackListener(t)
|
||||
companion := newDeviceCompanion()
|
||||
d, err := NewDevice(context.Background(), testDeviceOptions(address, companion))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
if err := d.EraseText(context.Background(), 4); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.PressKey(context.Background(), "enter"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(companion.eraseCounts) != 1 || companion.eraseCounts[0] != 4 {
|
||||
t.Fatalf("eraseCounts = %v, want [4]", companion.eraseCounts)
|
||||
}
|
||||
if len(companion.pressedKeys) != 1 || companion.pressedKeys[0] != "enter" {
|
||||
t.Fatalf("pressedKeys = %v, want [enter]", companion.pressedKeys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceClearStateWithoutAppPathWarnsOnce(t *testing.T) {
|
||||
output := &bytes.Buffer{}
|
||||
d := &Driver{output: output, deviceMode: true}
|
||||
d.resetContainer = d.deviceResetContainerUnsupported
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := d.deviceResetContainerUnsupported(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := bytes.Count(output.Bytes(), []byte("requires --ios-app-path")); got != 1 {
|
||||
t.Fatalf("warning emitted %d times, want once", got)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingCloser struct{ closed bool }
|
||||
|
||||
func (c *recordingCloser) Close() error {
|
||||
c.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDeviceCloseStopsRunnerAndTunnel(t *testing.T) {
|
||||
d := &Driver{output: &bytes.Buffer{}}
|
||||
runner := exec.Command("sleep", "30")
|
||||
if err := runner.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tunnel := &recordingCloser{}
|
||||
d.runnerChild = runner
|
||||
d.tunnel = tunnel
|
||||
|
||||
d.Close()
|
||||
|
||||
if d.runnerChild != nil || d.tunnel != nil {
|
||||
t.Fatal("Close must clear the runner child and the tunnel")
|
||||
}
|
||||
if runner.ProcessState == nil {
|
||||
t.Fatal("Close must reap the runner session child")
|
||||
}
|
||||
if !tunnel.closed {
|
||||
t.Fatal("Close must close the usbmux tunnel")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package ioscompanion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// These env vars name the signing inputs so the account-specific team id is
|
||||
// never committed. The App Store Connect API key path/id/issuer come from the
|
||||
// same source. They back the no-Xcode-UI signing path.
|
||||
const (
|
||||
envTeam = "SANDERLING_IOS_TEAM"
|
||||
envTeamFallback = "DEVELOPMENT_TEAM"
|
||||
envAuthKeyPath = "ASC_API_KEY_PATH"
|
||||
envAuthKeyID = "ASC_API_KEY_ID"
|
||||
envAuthIssuer = "ASC_API_ISSUER_ID"
|
||||
envCompanionDir = "SANDERLING_COMPANION_DIR"
|
||||
)
|
||||
|
||||
// deviceRunnerScheme and deviceRunnerProject mirror companion/project.yml. The
|
||||
// build product is the runner whose xctestrun the test session consumes.
|
||||
const (
|
||||
deviceRunnerScheme = "CompanionRunner"
|
||||
deviceRunnerProject = "CompanionRunner.xcodeproj"
|
||||
)
|
||||
|
||||
// signingCredentials carries the no-UI signing inputs read from the environment.
|
||||
type signingCredentials struct {
|
||||
team string
|
||||
authKeyPath string
|
||||
authKeyID string
|
||||
authIssuerID string
|
||||
}
|
||||
|
||||
// readSigningCredentials gathers the signing inputs from the environment and
|
||||
// reports every missing one at once. The .p8 key must exist on disk.
|
||||
func readSigningCredentials() (signingCredentials, error) {
|
||||
team := os.Getenv(envTeam)
|
||||
if team == "" {
|
||||
team = os.Getenv(envTeamFallback)
|
||||
}
|
||||
creds := signingCredentials{
|
||||
team: team,
|
||||
authKeyPath: os.Getenv(envAuthKeyPath),
|
||||
authKeyID: os.Getenv(envAuthKeyID),
|
||||
authIssuerID: os.Getenv(envAuthIssuer),
|
||||
}
|
||||
var missing []string
|
||||
if creds.team == "" {
|
||||
missing = append(missing, envTeam)
|
||||
}
|
||||
if creds.authKeyPath == "" {
|
||||
missing = append(missing, envAuthKeyPath)
|
||||
}
|
||||
if creds.authKeyID == "" {
|
||||
missing = append(missing, envAuthKeyID)
|
||||
}
|
||||
if creds.authIssuerID == "" {
|
||||
missing = append(missing, envAuthIssuer)
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return creds, fmt.Errorf("device signing requires environment variables: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
// xcodebuild's -authenticationKeyPath demands an absolute path, but .env
|
||||
// files commonly carry a repo-relative one. Resolve it against the working
|
||||
// directory before the stat so a relative key still works.
|
||||
if absolute, err := filepath.Abs(creds.authKeyPath); err == nil {
|
||||
creds.authKeyPath = absolute
|
||||
}
|
||||
if _, err := os.Stat(creds.authKeyPath); err != nil {
|
||||
return creds, fmt.Errorf("App Store Connect key not found at %s: %w", creds.authKeyPath, err)
|
||||
}
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
// VerifyDeviceSigning reports whether the device signing environment is complete
|
||||
// and the App Store Connect key file exists. The doctor calls it so the device
|
||||
// preflight surfaces missing credentials before a run reaches the build step.
|
||||
func VerifyDeviceSigning() error {
|
||||
_, err := readSigningCredentials()
|
||||
return err
|
||||
}
|
||||
|
||||
// realSpawnDeviceRunner regenerates the runner project, builds it for the device
|
||||
// (skipping when the cached build matches the current sources), and spawns the
|
||||
// test session that hosts the runner on the device, passing the session port via
|
||||
// TEST_RUNNER_COMPANION_PORT. address carries the host loopback port, reused as
|
||||
// the device-side COMPANION_PORT.
|
||||
func (d *Driver) realSpawnDeviceRunner(ctx context.Context, address string) (*exec.Cmd, error) {
|
||||
_, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
companionDir, err := resolveCompanionDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
creds, err := readSigningCredentials()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
derivedDataPath := filepath.Join(os.TempDir(), "sanderling-device-runner")
|
||||
if err := os.MkdirAll(derivedDataPath, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := d.buildDeviceRunnerIfNeeded(ctx, companionDir, derivedDataPath, creds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
xctestrunPath, err := locateDeviceXctestrun(derivedDataPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logPath := filepath.Join(derivedDataPath, "device-session-"+port+".log")
|
||||
logFile, err := os.Create(logPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create device session log: %w", err)
|
||||
}
|
||||
|
||||
args := testWithoutBuildingArgs(xctestrunPath, d.udid, creds)
|
||||
command := exec.CommandContext(ctx, "xcrun", args...)
|
||||
command.Stdout = logFile
|
||||
command.Stderr = logFile
|
||||
// Minimal environment: the session can echo its environment into the run
|
||||
// log, so secrets in the parent environment must not reach it. Signing is
|
||||
// passed by flag (a key-file path), not env. xcodebuild forwards any
|
||||
// TEST_RUNNER_-prefixed var into the runner with the prefix stripped, so the
|
||||
// non-secret COMPANION_PORT reaches the device that way instead of a plist
|
||||
// patch.
|
||||
command.Env = []string{
|
||||
"HOME=" + os.Getenv("HOME"),
|
||||
"PATH=/usr/bin:/bin",
|
||||
"TMPDIR=" + os.TempDir(),
|
||||
"TEST_RUNNER_COMPANION_PORT=" + port,
|
||||
}
|
||||
command.Cancel = func() error { return command.Process.Signal(syscall.SIGTERM) }
|
||||
command.WaitDelay = shutdownGrace
|
||||
startErr := command.Start()
|
||||
logFile.Close()
|
||||
if startErr != nil {
|
||||
return nil, fmt.Errorf("start device session: %w", startErr)
|
||||
}
|
||||
fmt.Fprintf(d.output, "device runner session pid=%d port=%s (log: %s)\n", command.Process.Pid, port, logPath)
|
||||
return command, nil
|
||||
}
|
||||
|
||||
// buildDeviceRunnerIfNeeded regenerates the project and runs build-for-testing,
|
||||
// skipping the build when a marker recording the current build key already
|
||||
// matches. The device signature is per-account/per-device, so the build cannot
|
||||
// be embedded; the stable derivedDataPath makes the build incremental.
|
||||
func (d *Driver) buildDeviceRunnerIfNeeded(ctx context.Context, companionDir, derivedDataPath string, creds signingCredentials) error {
|
||||
key, err := buildCacheKey(companionDir, creds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
marker := filepath.Join(derivedDataPath, "device-runner.sha256")
|
||||
if existing, readErr := os.ReadFile(marker); readErr == nil && string(existing) == key {
|
||||
fmt.Fprintln(d.output, "device runner build is up to date; skipping build")
|
||||
return nil
|
||||
}
|
||||
|
||||
if out, genErr := runQuiet(ctx, companionDir, "xcodegen", "--spec", filepath.Join(companionDir, "project.yml")); genErr != nil {
|
||||
return fmt.Errorf("xcodegen: %w: %s", genErr, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
projectPath := filepath.Join(companionDir, deviceRunnerProject)
|
||||
args := buildForTestingArgs(projectPath, derivedDataPath, creds)
|
||||
fmt.Fprintln(d.output, "building device runner (first run is slow; subsequent runs are cached)")
|
||||
if out, buildErr := runQuiet(ctx, companionDir, append([]string{"xcrun"}, args...)...); buildErr != nil {
|
||||
return fmt.Errorf("build-for-testing: %w: %s", buildErr, tailLines(string(out), 20))
|
||||
}
|
||||
if err := os.WriteFile(marker, []byte(key), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildCacheKey combines the source hash with the signing identity so a changed
|
||||
// team or key invalidates the cached build. A runner signed with a stale
|
||||
// identity would otherwise be reused and rejected at install (0xe8008018).
|
||||
func buildCacheKey(companionDir string, creds signingCredentials) (string, error) {
|
||||
sources, err := sourceHash(companionDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256([]byte(sources + "\x00" + creds.team + "\x00" + creds.authKeyID))
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// buildForTestingArgs builds the runner for a generic device destination, signed
|
||||
// through the App Store Connect API key with automatic provisioning. A generic
|
||||
// destination keeps the build off any specific booted device; the wildcard dev
|
||||
// profile covers every provisioned device in the team.
|
||||
func buildForTestingArgs(projectPath, derivedDataPath string, creds signingCredentials) []string {
|
||||
return []string{"xcodebuild", "build-for-testing",
|
||||
"-project", projectPath,
|
||||
"-scheme", deviceRunnerScheme,
|
||||
"-destination", "generic/platform=iOS",
|
||||
"-derivedDataPath", derivedDataPath,
|
||||
"-allowProvisioningUpdates",
|
||||
"-authenticationKeyPath", creds.authKeyPath,
|
||||
"-authenticationKeyID", creds.authKeyID,
|
||||
"-authenticationKeyIssuerID", creds.authIssuerID,
|
||||
// companion/project.yml disables signing for the simulator build; the
|
||||
// device install rejects an unsigned runner (0xe8008018), so signing is
|
||||
// re-enabled here and the team drives automatic provisioning.
|
||||
"CODE_SIGNING_ALLOWED=YES",
|
||||
"CODE_SIGNING_REQUIRED=YES",
|
||||
"CODE_SIGN_STYLE=Automatic",
|
||||
"DEVELOPMENT_TEAM=" + creds.team,
|
||||
"GENERATE_INFOPLIST_FILE=YES",
|
||||
}
|
||||
}
|
||||
|
||||
// testWithoutBuildingArgs runs the prebuilt runner's test session on the
|
||||
// specific device, installing the signed runner via the same automatic
|
||||
// provisioning the build used.
|
||||
func testWithoutBuildingArgs(xctestrunPath, hardwareUDID string, creds signingCredentials) []string {
|
||||
return []string{"xcodebuild", "test-without-building",
|
||||
"-xctestrun", xctestrunPath,
|
||||
"-destination", "platform=iOS,id=" + hardwareUDID,
|
||||
"-allowProvisioningUpdates",
|
||||
"-authenticationKeyPath", creds.authKeyPath,
|
||||
"-authenticationKeyID", creds.authKeyID,
|
||||
"-authenticationKeyIssuerID", creds.authIssuerID,
|
||||
}
|
||||
}
|
||||
|
||||
// locateDeviceXctestrun finds the device build's xctestrun under the derived
|
||||
// data products. The name embeds the device SDK version, so it is discovered
|
||||
// rather than hardcoded.
|
||||
func locateDeviceXctestrun(derivedDataPath string) (string, error) {
|
||||
products := filepath.Join(derivedDataPath, "Build", "Products")
|
||||
entries, err := os.ReadDir(products)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read build products: %w", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasSuffix(entry.Name(), ".xctestrun") {
|
||||
return filepath.Join(products, entry.Name()), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no xctestrun under %s", products)
|
||||
}
|
||||
|
||||
// sourceHash digests the runner sources and project spec so a source edit
|
||||
// invalidates the cached device build. Mirrors the runnerassets checksum reuse.
|
||||
func sourceHash(companionDir string) (string, error) {
|
||||
var paths []string
|
||||
sourcesDir := filepath.Join(companionDir, "Sources")
|
||||
walkErr := filepath.Walk(sourcesDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return "", walkErr
|
||||
}
|
||||
paths = append(paths, filepath.Join(companionDir, "project.yml"))
|
||||
sort.Strings(paths)
|
||||
hash := sha256.New()
|
||||
for _, path := range paths {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(hash, "%s\n", path)
|
||||
hash.Write(content)
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// resolveCompanionDir finds the companion source tree: the SANDERLING_COMPANION_DIR
|
||||
// override if set, otherwise the nearest ancestor of the working directory that
|
||||
// holds companion/project.yml. The device runner is built from source at run
|
||||
// time, so the tree must be present (it is, in a source checkout).
|
||||
func resolveCompanionDir() (string, error) {
|
||||
if override := os.Getenv(envCompanionDir); override != "" {
|
||||
if _, err := os.Stat(filepath.Join(override, "project.yml")); err != nil {
|
||||
return "", fmt.Errorf("%s=%s has no project.yml: %w", envCompanionDir, override, err)
|
||||
}
|
||||
return override, nil
|
||||
}
|
||||
directory, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for {
|
||||
candidate := filepath.Join(directory, "companion")
|
||||
if _, statErr := os.Stat(filepath.Join(candidate, "project.yml")); statErr == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
parent := filepath.Dir(directory)
|
||||
if parent == directory {
|
||||
break
|
||||
}
|
||||
directory = parent
|
||||
}
|
||||
return "", fmt.Errorf("companion source tree not found; run from a sanderling checkout or set %s", envCompanionDir)
|
||||
}
|
||||
|
||||
// runQuiet runs a command in dir with the inherited environment and returns its
|
||||
// combined output. Used for the transient build steps (xcodegen, xcodebuild
|
||||
// build-for-testing) whose output is surfaced only on failure.
|
||||
func runQuiet(ctx context.Context, dir string, args ...string) ([]byte, error) {
|
||||
command := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||
command.Dir = dir
|
||||
return command.CombinedOutput()
|
||||
}
|
||||
|
||||
// tailLines returns the last n lines of text, so a long xcodebuild failure log
|
||||
// surfaces its tail (where the error is) without flooding the run output.
|
||||
func tailLines(text string, n int) string {
|
||||
lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
|
||||
if len(lines) <= n {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
return strings.Join(lines[len(lines)-n:], "\n")
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package ioscompanion
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadSigningCredentialsReportsMissing(t *testing.T) {
|
||||
for _, key := range []string{envTeam, envTeamFallback, envAuthKeyPath, envAuthKeyID, envAuthIssuer} {
|
||||
t.Setenv(key, "")
|
||||
}
|
||||
_, err := readSigningCredentials()
|
||||
if err == nil {
|
||||
t.Fatal("missing credentials must error")
|
||||
}
|
||||
for _, want := range []string{envTeam, envAuthKeyPath, envAuthKeyID, envAuthIssuer} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error should name missing var %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSigningCredentialsRejectsMissingKeyFile(t *testing.T) {
|
||||
t.Setenv(envTeam, "TEAM1")
|
||||
t.Setenv(envAuthKeyID, "KID")
|
||||
t.Setenv(envAuthIssuer, "ISS")
|
||||
t.Setenv(envAuthKeyPath, filepath.Join(t.TempDir(), "absent.p8"))
|
||||
if _, err := readSigningCredentials(); err == nil {
|
||||
t.Fatal("a missing .p8 key file must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSigningCredentialsAcceptsPresentKey(t *testing.T) {
|
||||
keyPath := filepath.Join(t.TempDir(), "AuthKey.p8")
|
||||
if err := os.WriteFile(keyPath, []byte("-----BEGIN PRIVATE KEY-----"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(envTeam, "")
|
||||
t.Setenv(envTeamFallback, "FALLBACKTEAM")
|
||||
t.Setenv(envAuthKeyID, "KID")
|
||||
t.Setenv(envAuthIssuer, "ISS")
|
||||
t.Setenv(envAuthKeyPath, keyPath)
|
||||
creds, err := readSigningCredentials()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if creds.team != "FALLBACKTEAM" {
|
||||
t.Fatalf("team = %q, want the DEVELOPMENT_TEAM fallback", creds.team)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSigningCredentialsResolvesRelativeKeyPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile("AuthKey.p8", []byte("key"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(envTeam, "TEAM1")
|
||||
t.Setenv(envAuthKeyID, "KID")
|
||||
t.Setenv(envAuthIssuer, "ISS")
|
||||
t.Setenv(envAuthKeyPath, "AuthKey.p8")
|
||||
creds, err := readSigningCredentials()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !filepath.IsAbs(creds.authKeyPath) {
|
||||
t.Fatalf("authKeyPath = %q, want an absolute path for xcodebuild", creds.authKeyPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCacheKeyChangesWithSigningIdentity(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "Sources"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "Sources", "Server.swift"), []byte("v1"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "project.yml"), []byte("name: x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base, err := buildCacheKey(dir, signingCredentials{team: "TEAM1", authKeyID: "KID1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherTeam, err := buildCacheKey(dir, signingCredentials{team: "TEAM2", authKeyID: "KID1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherKey, err := buildCacheKey(dir, signingCredentials{team: "TEAM1", authKeyID: "KID2"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if base == otherTeam {
|
||||
t.Fatal("a changed team must invalidate the cached build")
|
||||
}
|
||||
if base == otherKey {
|
||||
t.Fatal("a changed signing key must invalidate the cached build")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceHashChangesWithSources(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "Sources"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "Sources", "Server.swift"), []byte("v1"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "project.yml"), []byte("name: x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := sourceHash(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "Sources", "Server.swift"), []byte("v2"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := sourceHash(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first == second {
|
||||
t.Fatal("a source edit must change the hash so the cached build is invalidated")
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,16 @@ type Driver struct {
|
||||
dialRunner func(address string) (transport.Companion, error)
|
||||
hybrid bool
|
||||
|
||||
// Device-mode fields. On the physical-device path d.companion is the runner
|
||||
// dialed over a usbmux tunnel, hybrid is false, and runnerClient is nil.
|
||||
// coreDeviceID feeds devicectl; tunnel is the in-process usbmux forwarder
|
||||
// bridging the host loopback port to the runner's device-side port.
|
||||
deviceMode bool
|
||||
coreDeviceID string
|
||||
tunnel io.Closer
|
||||
startTunnel func(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error)
|
||||
pickDeviceAddress func() (string, error)
|
||||
|
||||
// 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
|
||||
@@ -985,11 +995,23 @@ func (d *Driver) Close() {
|
||||
d.runnerClient = nil
|
||||
}
|
||||
d.stopRunnerChild()
|
||||
d.stopTunnel()
|
||||
if d.processCancel != nil {
|
||||
d.processCancel()
|
||||
}
|
||||
}
|
||||
|
||||
// stopTunnel closes the in-process usbmux forwarder on the device path. Closing
|
||||
// its listener ends the accept loop and lets the open bridges drain; a nil
|
||||
// tunnel (the simulator path) is a no-op.
|
||||
func (d *Driver) stopTunnel() {
|
||||
tunnel := d.tunnel
|
||||
d.tunnel = nil
|
||||
if tunnel != nil {
|
||||
_ = tunnel.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// stopChild terminates the companion child gracefully (SIGTERM, grace window,
|
||||
// then SIGKILL) so it leaves no orphan behind.
|
||||
func (d *Driver) stopChild() {
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
// This file implements the host-to-device TCP forward the device runner needs,
|
||||
// natively, against macOS's own usbmuxd. The runner exposes a JSON-RPC server on
|
||||
// the device loopback; the host dials it through this forward. usbmuxd is the
|
||||
// macOS daemon at /var/run/usbmuxd that already multiplexes every USB device
|
||||
// connection; a third-party client (iproxy and the like) is only a thin speaker
|
||||
// of the same protocol, so talking to the socket directly keeps the device path
|
||||
// dependent on nothing beyond the OS.
|
||||
package ioscompanion
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// usbmuxdSocket is the macOS usbmuxd unix socket. It is part of the base OS, not
|
||||
// an installed dependency.
|
||||
const usbmuxdSocket = "/var/run/usbmuxd"
|
||||
|
||||
// VerifyUsbmuxdSocket reports whether the macOS usbmuxd socket is present. The
|
||||
// doctor calls it so the device preflight confirms the tunnel's transport
|
||||
// before a run reaches the build step.
|
||||
func VerifyUsbmuxdSocket() error {
|
||||
if _, err := os.Stat(usbmuxdSocket); err != nil {
|
||||
return fmt.Errorf("usbmuxd socket not found at %s: %w", usbmuxdSocket, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// usbmux message framing: a 16-byte little-endian header (length including the
|
||||
// header, protocol version, payload type, request tag) precedes an XML plist.
|
||||
const (
|
||||
usbmuxHeaderLength = 16
|
||||
usbmuxVersion = 1
|
||||
usbmuxPayloadPlist = 8
|
||||
usbmuxTag = 1
|
||||
)
|
||||
|
||||
// usbmuxDial opens a live byte pipe to devicePort on the device identified by
|
||||
// hardwareUDID: it resolves the device's usbmux id, then issues a Connect whose
|
||||
// success turns the usbmuxd socket into a raw conduit to that device port. The
|
||||
// returned net.Conn is the device side of the runner's TCP server.
|
||||
func usbmuxDial(ctx context.Context, hardwareUDID string, devicePort int) (net.Conn, error) {
|
||||
deviceID, err := usbmuxDeviceID(ctx, hardwareUDID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn, err := dialUsbmuxd(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// usbmux carries the port in network byte order.
|
||||
request, err := encodePlistDict(map[string]any{
|
||||
"MessageType": "Connect",
|
||||
"DeviceID": deviceID,
|
||||
"PortNumber": int(htons(uint16(devicePort))),
|
||||
})
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := writeUsbmuxMessage(conn, request); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
reply, err := readUsbmuxMessage(conn)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
result, err := parsePlistDict(reply)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if number, _ := result["Number"].(int); number != 0 {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("usbmux: connect to device port %d failed (result %d)", devicePort, number)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// usbmuxDeviceID lists attached devices and returns the usbmux id of the one
|
||||
// whose serial matches hardwareUDID. usbmux ids are assigned per attachment and
|
||||
// can change across reconnects, so it is resolved fresh on every dial.
|
||||
func usbmuxDeviceID(ctx context.Context, hardwareUDID string) (int, error) {
|
||||
conn, err := dialUsbmuxd(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
request, err := encodePlistDict(map[string]any{"MessageType": "ListDevices"})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := writeUsbmuxMessage(conn, request); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
reply, err := readUsbmuxMessage(conn)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
list, err := parsePlistDict(reply)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return selectDeviceID(list, hardwareUDID)
|
||||
}
|
||||
|
||||
// selectDeviceID picks the usbmux DeviceID for hardwareUDID from a parsed
|
||||
// ListDevices reply, matching the serial with dashes and case ignored so the
|
||||
// devicectl HardwareUDID (dashed) and the raw USB serial (undashed) both resolve.
|
||||
func selectDeviceID(list map[string]any, hardwareUDID string) (int, error) {
|
||||
devices, _ := list["DeviceList"].([]any)
|
||||
target := normalizeSerial(hardwareUDID)
|
||||
for _, entry := range devices {
|
||||
device, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
properties, _ := device["Properties"].(map[string]any)
|
||||
serial, _ := properties["SerialNumber"].(string)
|
||||
if normalizeSerial(serial) != target {
|
||||
continue
|
||||
}
|
||||
if id, ok := device["DeviceID"].(int); ok {
|
||||
return id, nil
|
||||
}
|
||||
if id, ok := properties["DeviceID"].(int); ok {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("usbmux: device %s not attached", hardwareUDID)
|
||||
}
|
||||
|
||||
func normalizeSerial(serial string) string {
|
||||
return strings.ToLower(strings.ReplaceAll(serial, "-", ""))
|
||||
}
|
||||
|
||||
// htons swaps a port to network byte order, as the usbmux Connect PortNumber
|
||||
// requires.
|
||||
func htons(port uint16) uint16 {
|
||||
return port<<8 | port>>8
|
||||
}
|
||||
|
||||
func dialUsbmuxd(ctx context.Context) (net.Conn, error) {
|
||||
dialer := net.Dialer{}
|
||||
conn, err := dialer.DialContext(ctx, "unix", usbmuxdSocket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("usbmux: dial %s: %w", usbmuxdSocket, err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func writeUsbmuxMessage(conn net.Conn, payload []byte) error {
|
||||
header := make([]byte, usbmuxHeaderLength)
|
||||
binary.LittleEndian.PutUint32(header[0:4], uint32(usbmuxHeaderLength+len(payload)))
|
||||
binary.LittleEndian.PutUint32(header[4:8], usbmuxVersion)
|
||||
binary.LittleEndian.PutUint32(header[8:12], usbmuxPayloadPlist)
|
||||
binary.LittleEndian.PutUint32(header[12:16], usbmuxTag)
|
||||
if _, err := conn.Write(header); err != nil {
|
||||
return fmt.Errorf("usbmux: write header: %w", err)
|
||||
}
|
||||
if _, err := conn.Write(payload); err != nil {
|
||||
return fmt.Errorf("usbmux: write payload: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readUsbmuxMessage(conn net.Conn) ([]byte, error) {
|
||||
header := make([]byte, usbmuxHeaderLength)
|
||||
if _, err := io.ReadFull(conn, header); err != nil {
|
||||
return nil, fmt.Errorf("usbmux: read header: %w", err)
|
||||
}
|
||||
length := binary.LittleEndian.Uint32(header[0:4])
|
||||
if length < usbmuxHeaderLength {
|
||||
return nil, fmt.Errorf("usbmux: reply length %d shorter than header", length)
|
||||
}
|
||||
payload := make([]byte, length-usbmuxHeaderLength)
|
||||
if _, err := io.ReadFull(conn, payload); err != nil {
|
||||
return nil, fmt.Errorf("usbmux: read payload: %w", err)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// encodePlistDict renders a flat dict (string or int values) as the XML plist
|
||||
// usbmux requests use. Keys are sorted so the output is deterministic.
|
||||
func encodePlistDict(fields map[string]any) ([]byte, error) {
|
||||
var buffer bytes.Buffer
|
||||
buffer.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
|
||||
buffer.WriteString(`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">` + "\n")
|
||||
buffer.WriteString(`<plist version="1.0">` + "\n<dict>\n")
|
||||
keys := make([]string, 0, len(fields))
|
||||
for key := range fields {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
fmt.Fprintf(&buffer, "<key>%s</key>", key)
|
||||
switch value := fields[key].(type) {
|
||||
case string:
|
||||
buffer.WriteString("<string>")
|
||||
xml.EscapeText(&buffer, []byte(value))
|
||||
buffer.WriteString("</string>\n")
|
||||
case int:
|
||||
fmt.Fprintf(&buffer, "<integer>%d</integer>\n", value)
|
||||
default:
|
||||
return nil, fmt.Errorf("usbmux: unsupported plist value type %T for key %s", value, key)
|
||||
}
|
||||
}
|
||||
buffer.WriteString("</dict>\n</plist>\n")
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
// parsePlistDict parses an XML plist whose root is a dict into a generic map.
|
||||
func parsePlistDict(data []byte) (map[string]any, error) {
|
||||
value, err := parsePlist(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dict, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("usbmux: plist root is %T, want dict", value)
|
||||
}
|
||||
return dict, nil
|
||||
}
|
||||
|
||||
// parsePlist decodes an XML plist into nested map[string]any / []any / string /
|
||||
// int / bool values. It covers the element set usbmux replies use.
|
||||
func parsePlist(data []byte) (any, error) {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(data))
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("usbmux: parse plist: %w", err)
|
||||
}
|
||||
if start, ok := token.(xml.StartElement); ok && start.Name.Local == "plist" {
|
||||
return parsePlistChild(decoder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parsePlistChild reads forward to the next start element and parses it as a
|
||||
// value. It is used for the lone child of <plist> and of each <key>.
|
||||
func parsePlistChild(decoder *xml.Decoder) (any, error) {
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch element := token.(type) {
|
||||
case xml.StartElement:
|
||||
return parsePlistElement(decoder, element)
|
||||
case xml.EndElement:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parsePlistElement(decoder *xml.Decoder, start xml.StartElement) (any, error) {
|
||||
switch start.Name.Local {
|
||||
case "dict":
|
||||
return parsePlistDictBody(decoder)
|
||||
case "array":
|
||||
return parsePlistArray(decoder)
|
||||
case "string":
|
||||
return parsePlistText(decoder)
|
||||
case "integer":
|
||||
text, err := parsePlistText(decoder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
number, err := strconv.Atoi(strings.TrimSpace(text))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("usbmux: parse integer %q: %w", text, err)
|
||||
}
|
||||
return number, nil
|
||||
case "true":
|
||||
return true, decoder.Skip()
|
||||
case "false":
|
||||
return false, decoder.Skip()
|
||||
default:
|
||||
// Unhandled scalar (real, data, date): consume it and report nil so an
|
||||
// unexpected field never aborts parsing the fields that matter.
|
||||
return nil, decoder.Skip()
|
||||
}
|
||||
}
|
||||
|
||||
func parsePlistDictBody(decoder *xml.Decoder) (map[string]any, error) {
|
||||
result := map[string]any{}
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch element := token.(type) {
|
||||
case xml.StartElement:
|
||||
if element.Name.Local != "key" {
|
||||
return nil, fmt.Errorf("usbmux: expected <key>, got <%s>", element.Name.Local)
|
||||
}
|
||||
key, err := parsePlistText(decoder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err := parsePlistChild(decoder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[key] = value
|
||||
case xml.EndElement:
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parsePlistArray(decoder *xml.Decoder) ([]any, error) {
|
||||
var result []any
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch element := token.(type) {
|
||||
case xml.StartElement:
|
||||
value, err := parsePlistElement(decoder, element)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, value)
|
||||
case xml.EndElement:
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parsePlistText(decoder *xml.Decoder) (string, error) {
|
||||
var text strings.Builder
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch element := token.(type) {
|
||||
case xml.CharData:
|
||||
text.Write(element)
|
||||
case xml.EndElement:
|
||||
return text.String(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// usbmuxForwarder is the in-process replacement for an iproxy child: it accepts
|
||||
// host loopback connections and bridges each to a fresh device-port conduit over
|
||||
// usbmux. It satisfies io.Closer so the driver tears it down like any other
|
||||
// tunnel handle; closing the listener ends the accept loop and the bridges drain
|
||||
// as their copies finish.
|
||||
type usbmuxForwarder struct {
|
||||
listener net.Listener
|
||||
dialDevice func(ctx context.Context) (net.Conn, error)
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// startUsbmuxForwarder listens on localAddress and forwards every accepted
|
||||
// connection to devicePort on the device, over usbmux.
|
||||
func startUsbmuxForwarder(ctx context.Context, hardwareUDID, localAddress string, devicePort int) (*usbmuxForwarder, error) {
|
||||
return startForwarder(ctx, localAddress, func(dialCtx context.Context) (net.Conn, error) {
|
||||
return usbmuxDial(dialCtx, hardwareUDID, devicePort)
|
||||
})
|
||||
}
|
||||
|
||||
// startForwarder is the seam-friendly core: the device dialer is injected so a
|
||||
// test can bridge to an in-process echo server without a real device.
|
||||
func startForwarder(ctx context.Context, localAddress string, dialDevice func(context.Context) (net.Conn, error)) (*usbmuxForwarder, error) {
|
||||
listener, err := net.Listen("tcp", localAddress)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("usbmux forwarder: listen %s: %w", localAddress, err)
|
||||
}
|
||||
forwarder := &usbmuxForwarder{listener: listener, dialDevice: dialDevice, ctx: ctx}
|
||||
go forwarder.serve()
|
||||
return forwarder, nil
|
||||
}
|
||||
|
||||
func (f *usbmuxForwarder) serve() {
|
||||
for {
|
||||
hostConn, err := f.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go f.bridge(hostConn)
|
||||
}
|
||||
}
|
||||
|
||||
// bridge connects to the device side, then copies bytes in both directions
|
||||
// until either end closes. A failed device dial (the runner not yet listening)
|
||||
// closes the host side, which the runner transport reads as a dropped
|
||||
// connection and recovers from on its next call.
|
||||
func (f *usbmuxForwarder) bridge(hostConn net.Conn) {
|
||||
defer hostConn.Close()
|
||||
deviceConn, err := f.dialDevice(f.ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer deviceConn.Close()
|
||||
done := make(chan struct{}, 2)
|
||||
go func() { io.Copy(deviceConn, hostConn); done <- struct{}{} }()
|
||||
go func() { io.Copy(hostConn, deviceConn); done <- struct{}{} }()
|
||||
// One direction ending closes both conns via the defers, which unblocks the
|
||||
// other copy; waiting for one is enough to know the bridge is finished.
|
||||
<-done
|
||||
}
|
||||
|
||||
func (f *usbmuxForwarder) Close() error {
|
||||
return f.listener.Close()
|
||||
}
|
||||
|
||||
// startUsbmuxTunnel adapts the forwarder to the driver's startTunnel seam,
|
||||
// returning it as an io.Closer.
|
||||
func startUsbmuxTunnel(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error) {
|
||||
port, err := strconv.Atoi(devicePort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("usbmux tunnel: device port %q: %w", devicePort, err)
|
||||
}
|
||||
return startUsbmuxForwarder(ctx, hardwareUDID, localAddress, port)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package ioscompanion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHtonsSwapsBytes(t *testing.T) {
|
||||
// 49200 = 0xC030; network order swaps to 0x30C0 = 12480.
|
||||
if got := htons(49200); got != 12480 {
|
||||
t.Fatalf("htons(49200) = %d, want 12480", got)
|
||||
}
|
||||
if got := htons(0x1234); got != 0x3412 {
|
||||
t.Fatalf("htons(0x1234) = %#x, want 0x3412", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodePlistDictRoundTrips(t *testing.T) {
|
||||
encoded, err := encodePlistDict(map[string]any{
|
||||
"MessageType": "Connect",
|
||||
"DeviceID": 7,
|
||||
"PortNumber": 12480,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dict, err := parsePlistDict(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("parse round-trip: %v", err)
|
||||
}
|
||||
if dict["MessageType"] != "Connect" {
|
||||
t.Fatalf("MessageType = %v, want Connect", dict["MessageType"])
|
||||
}
|
||||
if dict["DeviceID"] != 7 {
|
||||
t.Fatalf("DeviceID = %v, want 7", dict["DeviceID"])
|
||||
}
|
||||
if dict["PortNumber"] != 12480 {
|
||||
t.Fatalf("PortNumber = %v, want 12480", dict["PortNumber"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodePlistDictEscapesStrings(t *testing.T) {
|
||||
encoded, err := encodePlistDict(map[string]any{"Name": "a & b <c>"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dict, err := parsePlistDict(encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dict["Name"] != "a & b <c>" {
|
||||
t.Fatalf("Name = %q, want the unescaped original", dict["Name"])
|
||||
}
|
||||
}
|
||||
|
||||
// listDevicesReply is a representative usbmux ListDevices response with one USB
|
||||
// device, matching the shape macOS usbmuxd returns.
|
||||
const listDevicesReply = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>DeviceList</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>DeviceID</key>
|
||||
<integer>7</integer>
|
||||
<key>MessageType</key>
|
||||
<string>Attached</string>
|
||||
<key>Properties</key>
|
||||
<dict>
|
||||
<key>ConnectionType</key>
|
||||
<string>USB</string>
|
||||
<key>SerialNumber</key>
|
||||
<string>00008140-00022C4A3E13001C</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>`
|
||||
|
||||
func TestSelectDeviceIDMatchesDashedSerial(t *testing.T) {
|
||||
list, err := parsePlistDict([]byte(listDevicesReply))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := selectDeviceID(list, "00008140-00022C4A3E13001C")
|
||||
if err != nil {
|
||||
t.Fatalf("select by dashed serial: %v", err)
|
||||
}
|
||||
if id != 7 {
|
||||
t.Fatalf("DeviceID = %d, want 7", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectDeviceIDMatchesUndashedSerial(t *testing.T) {
|
||||
list, err := parsePlistDict([]byte(listDevicesReply))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The raw USB serial carries no dash; it must still resolve.
|
||||
id, err := selectDeviceID(list, "0000814000022C4A3E13001C")
|
||||
if err != nil {
|
||||
t.Fatalf("select by undashed serial: %v", err)
|
||||
}
|
||||
if id != 7 {
|
||||
t.Fatalf("DeviceID = %d, want 7", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectDeviceIDNotAttached(t *testing.T) {
|
||||
list, err := parsePlistDict([]byte(listDevicesReply))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := selectDeviceID(list, "DEADBEEF-NOTHERE"); err == nil {
|
||||
t.Fatal("a serial not in the list must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePlistDictRejectsNonDictRoot(t *testing.T) {
|
||||
arrayRoot := `<?xml version="1.0"?><plist version="1.0"><array></array></plist>`
|
||||
if _, err := parsePlistDict([]byte(arrayRoot)); err == nil {
|
||||
t.Fatal("a non-dict plist root must error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestForwarderBridgesToDevice drives the forwarder end to end against an
|
||||
// in-process echo "device": a host connection through the loopback listener must
|
||||
// reach the injected device dialer and round-trip bytes.
|
||||
func TestForwarderBridgesToDevice(t *testing.T) {
|
||||
deviceListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer deviceListener.Close()
|
||||
go func() {
|
||||
for {
|
||||
conn, acceptErr := deviceListener.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
go io.Copy(conn, conn)
|
||||
}
|
||||
}()
|
||||
|
||||
localAddress, err := pickLoopbackAddress()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forwarder, err := startForwarder(context.Background(), localAddress, func(ctx context.Context) (net.Conn, error) {
|
||||
return net.Dial("tcp", deviceListener.Addr().String())
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer forwarder.Close()
|
||||
|
||||
hostConn, err := net.Dial("tcp", localAddress)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer hostConn.Close()
|
||||
|
||||
message := []byte("ping\n")
|
||||
if _, err := hostConn.Write(message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buffer := make([]byte, len(message))
|
||||
if _, err := io.ReadFull(hostConn, buffer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(buffer) != string(message) {
|
||||
t.Fatalf("round-trip = %q, want %q", buffer, message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForwarderClosesHostOnDeviceDialFailure asserts the failure path the runner
|
||||
// transport relies on: when the device dial fails (the runner not yet
|
||||
// listening), the forwarder closes the host side so the caller sees a dropped
|
||||
// connection and retries.
|
||||
func TestForwarderClosesHostOnDeviceDialFailure(t *testing.T) {
|
||||
localAddress, err := pickLoopbackAddress()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forwarder, err := startForwarder(context.Background(), localAddress, func(ctx context.Context) (net.Conn, error) {
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer forwarder.Close()
|
||||
|
||||
hostConn, err := net.Dial("tcp", localAddress)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer hostConn.Close()
|
||||
|
||||
// The forwarder closes its side after the failed device dial; the read
|
||||
// returns EOF rather than blocking forever.
|
||||
if _, err := io.ReadFull(hostConn, make([]byte, 1)); err == nil {
|
||||
t.Fatal("read must fail once the forwarder closes the host side")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwarderCloseStopsAcceptLoop(t *testing.T) {
|
||||
localAddress, err := pickLoopbackAddress()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forwarder, err := startForwarder(context.Background(), localAddress, func(ctx context.Context) (net.Conn, error) {
|
||||
return nil, io.EOF
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := forwarder.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// After Close the listener is gone, so a dial must fail.
|
||||
if conn, dialErr := net.Dial("tcp", localAddress); dialErr == nil {
|
||||
conn.Close()
|
||||
t.Fatal("dial must fail after the forwarder is closed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package ios
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Device is a physical iOS device resolved from devicectl. A device carries two
|
||||
// identifiers: HardwareUDID feeds xcodebuild -destination and the usbmux device
|
||||
// match, while CoreDeviceID feeds devicectl install/uninstall.
|
||||
type Device struct {
|
||||
Name string
|
||||
HardwareUDID string
|
||||
CoreDeviceID string
|
||||
}
|
||||
|
||||
// coreDevice mirrors the fields of one entry in devicectl's JSON device list.
|
||||
type coreDevice struct {
|
||||
Identifier string `json:"identifier"`
|
||||
HardwareProperties struct {
|
||||
UDID string `json:"udid"`
|
||||
} `json:"hardwareProperties"`
|
||||
DeviceProperties struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"deviceProperties"`
|
||||
}
|
||||
|
||||
type coreDeviceList struct {
|
||||
Result struct {
|
||||
Devices []coreDevice `json:"devices"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
// listDevices is a seam: overridable in tests so ResolveDevice runs against
|
||||
// canned devicectl output without invoking xcrun.
|
||||
var listDevices = coreDevices
|
||||
|
||||
// ConnectedDevices lists the physical iOS devices devicectl reports. The doctor
|
||||
// uses it to confirm at least one device is connected and paired.
|
||||
func ConnectedDevices(ctx context.Context) ([]Device, error) {
|
||||
return listDevices(ctx)
|
||||
}
|
||||
|
||||
// ResolveDevice picks the physical iOS device a run drives. An empty query
|
||||
// resolves the single connected device (an error names them all when several
|
||||
// are connected). A non-empty query matches a device by name, hardware UDID, or
|
||||
// CoreDevice id. No match and an ambiguous match are both errors that list the
|
||||
// connected devices so the caller can refine --ios-device.
|
||||
func ResolveDevice(ctx context.Context, query string) (Device, error) {
|
||||
devices, err := listDevices(ctx)
|
||||
if err != nil {
|
||||
return Device{}, fmt.Errorf("list devices: %w", err)
|
||||
}
|
||||
if len(devices) == 0 {
|
||||
return Device{}, fmt.Errorf("no connected iOS device found; connect and pair an iPhone, then check `xcrun devicectl list devices`")
|
||||
}
|
||||
|
||||
if query == "" {
|
||||
if len(devices) == 1 {
|
||||
return devices[0], nil
|
||||
}
|
||||
return Device{}, fmt.Errorf("multiple connected iOS devices; pass --ios-device to select one:%s", deviceLines(devices))
|
||||
}
|
||||
|
||||
var matches []Device
|
||||
for _, device := range devices {
|
||||
if device.Name == query || device.HardwareUDID == query || device.CoreDeviceID == query {
|
||||
matches = append(matches, device)
|
||||
}
|
||||
}
|
||||
switch len(matches) {
|
||||
case 1:
|
||||
return matches[0], nil
|
||||
case 0:
|
||||
return Device{}, fmt.Errorf("no connected iOS device matches %q; connected devices:%s", query, deviceLines(devices))
|
||||
default:
|
||||
return Device{}, fmt.Errorf("--ios-device %q matches multiple devices; select by hardware UDID or CoreDevice id:%s", query, deviceLines(matches))
|
||||
}
|
||||
}
|
||||
|
||||
func deviceLines(devices []Device) string {
|
||||
var lines strings.Builder
|
||||
for _, device := range devices {
|
||||
fmt.Fprintf(&lines, "\n %s (udid %s, id %s)", device.Name, device.HardwareUDID, device.CoreDeviceID)
|
||||
}
|
||||
return lines.String()
|
||||
}
|
||||
|
||||
// coreDevices invokes devicectl and parses its JSON device list. devicectl
|
||||
// writes the JSON to a file path rather than stdout, so a temp file backs the
|
||||
// --json-output flag and is read back after the command runs.
|
||||
func coreDevices(ctx context.Context) ([]Device, error) {
|
||||
outputFile, err := os.CreateTemp("", "sanderling-devices-*.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outputPath := outputFile.Name()
|
||||
outputFile.Close()
|
||||
defer os.Remove(outputPath)
|
||||
|
||||
if err := exec.CommandContext(ctx, "xcrun", "devicectl", "list", "devices", "--json-output", outputPath).Run(); err != nil {
|
||||
return nil, fmt.Errorf("xcrun devicectl list devices: %w", err)
|
||||
}
|
||||
data, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseDevices(data)
|
||||
}
|
||||
|
||||
func parseDevices(data []byte) ([]Device, error) {
|
||||
var list coreDeviceList
|
||||
if err := json.Unmarshal(data, &list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var devices []Device
|
||||
for _, entry := range list.Result.Devices {
|
||||
devices = append(devices, Device{
|
||||
Name: entry.DeviceProperties.Name,
|
||||
HardwareUDID: entry.HardwareProperties.UDID,
|
||||
CoreDeviceID: entry.Identifier,
|
||||
})
|
||||
}
|
||||
return devices, nil
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package ios
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// devicectlJSON mirrors the shape of `xcrun devicectl list devices
|
||||
// --json-output` for two connected devices, trimmed to the fields the parser
|
||||
// reads.
|
||||
const devicectlJSON = `{
|
||||
"info": {"outcome": "success"},
|
||||
"result": {
|
||||
"devices": [
|
||||
{
|
||||
"identifier": "1FB35C36-A358-56F9-AD9F-931DC1C867FF",
|
||||
"hardwareProperties": {"udid": "00008140-00022C4A3E13001C"},
|
||||
"deviceProperties": {"name": "iPhone"}
|
||||
},
|
||||
{
|
||||
"identifier": "2AC46D47-B469-67A0-BE0A-042ED2D978AA",
|
||||
"hardwareProperties": {"udid": "00008110-000A1B2C3D4E5F60"},
|
||||
"deviceProperties": {"name": "Test iPad"}
|
||||
}
|
||||
]
|
||||
}
|
||||
}`
|
||||
|
||||
func swapListDevices(t *testing.T, devices []Device, err error) {
|
||||
t.Helper()
|
||||
original := listDevices
|
||||
t.Cleanup(func() { listDevices = original })
|
||||
listDevices = func(context.Context) ([]Device, error) { return devices, err }
|
||||
}
|
||||
|
||||
func TestParseDevices(t *testing.T) {
|
||||
got, err := parseDevices([]byte(devicectlJSON))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("parsed %d devices, want 2", len(got))
|
||||
}
|
||||
want := Device{Name: "iPhone", HardwareUDID: "00008140-00022C4A3E13001C", CoreDeviceID: "1FB35C36-A358-56F9-AD9F-931DC1C867FF"}
|
||||
if got[0] != want {
|
||||
t.Fatalf("device[0] = %+v, want %+v", got[0], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDevices_InvalidJSON(t *testing.T) {
|
||||
if _, err := parseDevices([]byte("not json")); err == nil {
|
||||
t.Fatal("expected error on malformed JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDevice_MatchByName(t *testing.T) {
|
||||
devices, _ := parseDevices([]byte(devicectlJSON))
|
||||
swapListDevices(t, devices, nil)
|
||||
got, err := ResolveDevice(context.Background(), "iPhone")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.HardwareUDID != "00008140-00022C4A3E13001C" {
|
||||
t.Fatalf("resolved %+v, want the iPhone", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDevice_MatchByHardwareUDID(t *testing.T) {
|
||||
devices, _ := parseDevices([]byte(devicectlJSON))
|
||||
swapListDevices(t, devices, nil)
|
||||
got, err := ResolveDevice(context.Background(), "00008110-000A1B2C3D4E5F60")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Name != "Test iPad" {
|
||||
t.Fatalf("resolved %+v, want Test iPad", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDevice_MatchByCoreDeviceID(t *testing.T) {
|
||||
devices, _ := parseDevices([]byte(devicectlJSON))
|
||||
swapListDevices(t, devices, nil)
|
||||
got, err := ResolveDevice(context.Background(), "1FB35C36-A358-56F9-AD9F-931DC1C867FF")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Name != "iPhone" {
|
||||
t.Fatalf("resolved %+v, want iPhone", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDevice_EmptyQuerySingleDevice(t *testing.T) {
|
||||
swapListDevices(t, []Device{{Name: "iPhone", HardwareUDID: "udid-1", CoreDeviceID: "id-1"}}, nil)
|
||||
got, err := ResolveDevice(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.HardwareUDID != "udid-1" {
|
||||
t.Fatalf("resolved %+v, want the only device", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDevice_EmptyQueryMultipleErrors(t *testing.T) {
|
||||
devices, _ := parseDevices([]byte(devicectlJSON))
|
||||
swapListDevices(t, devices, nil)
|
||||
_, err := ResolveDevice(context.Background(), "")
|
||||
if err == nil {
|
||||
t.Fatal("expected ambiguity error for multiple devices with no query")
|
||||
}
|
||||
for _, want := range []string{"--ios-device", "iPhone", "Test iPad"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("ambiguity error missing %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDevice_NotFound(t *testing.T) {
|
||||
devices, _ := parseDevices([]byte(devicectlJSON))
|
||||
swapListDevices(t, devices, nil)
|
||||
_, err := ResolveDevice(context.Background(), "Pixel")
|
||||
if err == nil {
|
||||
t.Fatal("expected not-found error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "iPhone") {
|
||||
t.Errorf("not-found error should list candidates: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDevice_NoneConnected(t *testing.T) {
|
||||
swapListDevices(t, nil, nil)
|
||||
_, err := ResolveDevice(context.Background(), "iPhone")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no device is connected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDevice_ListError(t *testing.T) {
|
||||
swapListDevices(t, nil, errors.New("devicectl blew up"))
|
||||
if _, err := ResolveDevice(context.Background(), ""); err == nil {
|
||||
t.Fatal("expected list error to propagate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDevice_AmbiguousMatch(t *testing.T) {
|
||||
swapListDevices(t, []Device{
|
||||
{Name: "iPhone", HardwareUDID: "udid-a", CoreDeviceID: "id-a"},
|
||||
{Name: "iPhone", HardwareUDID: "udid-b", CoreDeviceID: "id-b"},
|
||||
}, nil)
|
||||
_, err := ResolveDevice(context.Background(), "iPhone")
|
||||
if err == nil {
|
||||
t.Fatal("expected ambiguous-match error for duplicate names")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "udid-a") || !strings.Contains(err.Error(), "udid-b") {
|
||||
t.Errorf("ambiguous error should list both candidates: %v", err)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -79,8 +79,8 @@ func BootedUDID(ctx context.Context) string {
|
||||
// available) resolves to that simulator. With no query, exactly one booted
|
||||
// simulator resolves to it; multiple booted simulators is an error that lists
|
||||
// them and asks the caller to pass --ios-device. A query that matches no
|
||||
// simulator resolves as a physical device (udid = query, simulator false) so
|
||||
// the sidecar path drives it.
|
||||
// simulator resolves as a physical device (udid = query, simulator false),
|
||||
// which the caller then resolves through ResolveDevice for the device driver.
|
||||
func ResolveTarget(ctx context.Context, query string) (udid string, isSimulator bool, err error) {
|
||||
if query != "" {
|
||||
booted, err := listBootedAll(ctx)
|
||||
|
||||
@@ -16,16 +16,81 @@ import (
|
||||
"github.com/priyanshujain/sanderling/internal/driver/chrome"
|
||||
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion"
|
||||
driverSidecar "github.com/priyanshujain/sanderling/internal/driver/sidecar"
|
||||
"github.com/priyanshujain/sanderling/internal/ios"
|
||||
"github.com/priyanshujain/sanderling/internal/sidecarassets"
|
||||
)
|
||||
|
||||
// iOS resolution seams: package-level so routing tests substitute canned
|
||||
// resolvers instead of shelling out to xcrun.
|
||||
var (
|
||||
iosResolveTarget = ios.ResolveTarget
|
||||
iosResolveDevice = ios.ResolveDevice
|
||||
iosEnsureSimulator = ios.EnsureSimulator
|
||||
)
|
||||
|
||||
// resolveIOSTarget decides whether a run drives a simulator or a physical
|
||||
// device and fills the iOS fields on Options. A simulator target is booted if
|
||||
// needed; a physical-device target is resolved to its hardware UDID and
|
||||
// CoreDevice id via devicectl.
|
||||
func resolveIOSTarget(ctx context.Context, options Options, stdout io.Writer) (Options, error) {
|
||||
udid, isSimulator, err := iosResolveTarget(ctx, options.IosDevice)
|
||||
if err != nil {
|
||||
// No query and nothing booted: keep boot-first behavior, then resolve
|
||||
// the simulator that EnsureSimulator just brought up.
|
||||
if options.IosDevice == "" {
|
||||
if bootErr := iosEnsureSimulator(ctx, "", stdout); bootErr != nil {
|
||||
return options, bootErr
|
||||
}
|
||||
udid, isSimulator, err = iosResolveTarget(ctx, "")
|
||||
}
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
} else if isSimulator {
|
||||
if err := iosEnsureSimulator(ctx, options.IosDevice, stdout); err != nil {
|
||||
return options, err
|
||||
}
|
||||
udid, isSimulator, err = iosResolveTarget(ctx, options.IosDevice)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
}
|
||||
options.iosUDID = udid
|
||||
options.iosIsSimulator = isSimulator
|
||||
if !isSimulator {
|
||||
device, err := iosResolveDevice(ctx, options.IosDevice)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
options.iosUDID = device.HardwareUDID
|
||||
options.iosCoreDeviceID = device.CoreDeviceID
|
||||
fmt.Fprintf(stdout, "using device: %s (udid %s, id %s)\n", device.Name, device.HardwareUDID, device.CoreDeviceID)
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// preflight is a seam so routing tests exercise driver construction without the
|
||||
// host-readiness checks (xcrun, java) that are absent on a Linux CI runner.
|
||||
var preflight = Preflight
|
||||
|
||||
// newDeviceDriver constructs the physical-device iOS driver and its cleanup. A
|
||||
// seam so routing tests assert the resolved identifiers reach DeviceOptions
|
||||
// without building or spawning a real runner.
|
||||
var newDeviceDriver = func(ctx context.Context, options ioscompanion.DeviceOptions) (driver.DeviceDriver, func(), error) {
|
||||
d, err := ioscompanion.NewDevice(ctx, options)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return d, d.Close, nil
|
||||
}
|
||||
|
||||
// buildDriver creates the appropriate DeviceDriver for the platform and returns
|
||||
// a cleanup function. For web, ChromeDriver is used directly. An iOS simulator
|
||||
// is driven by the native simulator companion (no JVM). Android uses the JVM
|
||||
// sidecar, which is extracted, spawned, and dialed. Physical iOS devices are
|
||||
// not yet supported.
|
||||
// is driven by the native simulator companion (no JVM). A physical iOS device
|
||||
// is driven runner-only over a usbmux tunnel. Android uses the JVM sidecar,
|
||||
// which is extracted, spawned, and dialed.
|
||||
func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver.DeviceDriver, func(), error) {
|
||||
if err := Preflight(ctx, options.Platform); err != nil {
|
||||
if err := preflight(ctx, options.Platform); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if options.Platform == "web" {
|
||||
@@ -47,7 +112,17 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
|
||||
}
|
||||
|
||||
if options.Platform == "ios" {
|
||||
return nil, nil, fmt.Errorf("physical-device iOS is not yet supported; run against a simulator instead")
|
||||
d, cleanup, err := newDeviceDriver(ctx, ioscompanion.DeviceOptions{
|
||||
HardwareUDID: options.iosUDID,
|
||||
CoreDeviceID: options.iosCoreDeviceID,
|
||||
BundleID: options.BundleID,
|
||||
AppPath: options.IosAppPath,
|
||||
Output: stdout,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("ios device driver: %w", err)
|
||||
}
|
||||
return d, cleanup, nil
|
||||
}
|
||||
|
||||
// Android uses the JVM sidecar, which requires java.
|
||||
|
||||
+135
-12
@@ -2,27 +2,150 @@ package testrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/driver"
|
||||
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion"
|
||||
"github.com/priyanshujain/sanderling/internal/ios"
|
||||
)
|
||||
|
||||
func TestBuildDriverRejectsPhysicalIOS(t *testing.T) {
|
||||
if _, err := exec.LookPath("xcrun"); err != nil {
|
||||
t.Skip("xcrun not on PATH; physical-iOS rejection is reached only after preflight")
|
||||
// stubDeviceDriver is a no-op DeviceDriver so routing tests never build or spawn
|
||||
// a real runner. Only construction wiring is under test.
|
||||
type stubDeviceDriver struct{ driver.DeviceDriver }
|
||||
|
||||
func TestBuildDriverRoutesPhysicalIOSToDeviceDriver(t *testing.T) {
|
||||
stubPreflight(t)
|
||||
original := newDeviceDriver
|
||||
t.Cleanup(func() { newDeviceDriver = original })
|
||||
|
||||
var got ioscompanion.DeviceOptions
|
||||
closed := false
|
||||
newDeviceDriver = func(_ context.Context, options ioscompanion.DeviceOptions) (driver.DeviceDriver, func(), error) {
|
||||
got = options
|
||||
return stubDeviceDriver{}, func() { closed = true }, nil
|
||||
}
|
||||
|
||||
options := Options{Platform: "ios", BundleID: "app.folio", IosAppPath: "/tmp/iosApp.app"}
|
||||
options.iosIsSimulator = false
|
||||
options.iosUDID = "00008140-HW"
|
||||
options.iosCoreDeviceID = "CORE-1"
|
||||
|
||||
d, cleanup, err := buildDriver(context.Background(), options, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("buildDriver: %v", err)
|
||||
}
|
||||
if d == nil {
|
||||
t.Fatal("expected a device driver")
|
||||
}
|
||||
if got.HardwareUDID != "00008140-HW" || got.CoreDeviceID != "CORE-1" {
|
||||
t.Fatalf("DeviceOptions ids = %+v, want the resolved hardware/core ids", got)
|
||||
}
|
||||
if got.BundleID != "app.folio" || got.AppPath != "/tmp/iosApp.app" {
|
||||
t.Fatalf("DeviceOptions = %+v, want bundle and app path threaded through", got)
|
||||
}
|
||||
cleanup()
|
||||
if !closed {
|
||||
t.Fatal("cleanup must close the device driver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDriverSurfacesDeviceConstructionError(t *testing.T) {
|
||||
stubPreflight(t)
|
||||
original := newDeviceDriver
|
||||
t.Cleanup(func() { newDeviceDriver = original })
|
||||
newDeviceDriver = func(context.Context, ioscompanion.DeviceOptions) (driver.DeviceDriver, func(), error) {
|
||||
return nil, nil, errors.New("no signing creds")
|
||||
}
|
||||
options := Options{Platform: "ios"}
|
||||
options.iosIsSimulator = false
|
||||
if _, _, err := buildDriver(context.Background(), options, io.Discard); err == nil {
|
||||
t.Fatal("expected the device construction error to surface")
|
||||
}
|
||||
}
|
||||
|
||||
// stubPreflight bypasses the host-readiness checks so routing tests exercise
|
||||
// driver construction on a Linux CI runner that lacks xcrun/java.
|
||||
func stubPreflight(t *testing.T) {
|
||||
t.Helper()
|
||||
original := preflight
|
||||
t.Cleanup(func() { preflight = original })
|
||||
preflight = func(context.Context, string) error { return nil }
|
||||
}
|
||||
|
||||
func swapIOSResolveSeams(t *testing.T) {
|
||||
t.Helper()
|
||||
origTarget, origDevice, origEnsure := iosResolveTarget, iosResolveDevice, iosEnsureSimulator
|
||||
t.Cleanup(func() {
|
||||
iosResolveTarget, iosResolveDevice, iosEnsureSimulator = origTarget, origDevice, origEnsure
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveIOSTargetPhysicalDeviceFillsIDs(t *testing.T) {
|
||||
swapIOSResolveSeams(t)
|
||||
iosResolveTarget = func(_ context.Context, query string) (string, bool, error) {
|
||||
return query, false, nil
|
||||
}
|
||||
resolveDeviceCalled := ""
|
||||
iosResolveDevice = func(_ context.Context, query string) (ios.Device, error) {
|
||||
resolveDeviceCalled = query
|
||||
return ios.Device{Name: "iPhone", HardwareUDID: "00008140-HW", CoreDeviceID: "CORE-1"}, nil
|
||||
}
|
||||
iosEnsureSimulator = func(context.Context, string, io.Writer) error {
|
||||
t.Fatal("must not boot a simulator on the device path")
|
||||
return nil
|
||||
}
|
||||
|
||||
options := Options{Platform: "ios", IosDevice: "iPhone"}
|
||||
resolved, err := resolveIOSTarget(context.Background(), options, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolveDeviceCalled != "iPhone" {
|
||||
t.Fatalf("ResolveDevice query = %q, want iPhone", resolveDeviceCalled)
|
||||
}
|
||||
if resolved.iosIsSimulator {
|
||||
t.Fatal("device target must not be marked a simulator")
|
||||
}
|
||||
if resolved.iosUDID != "00008140-HW" || resolved.iosCoreDeviceID != "CORE-1" {
|
||||
t.Fatalf("resolved ids = (%q, %q), want hardware/core ids", resolved.iosUDID, resolved.iosCoreDeviceID)
|
||||
}
|
||||
}
|
||||
|
||||
_, cleanup, err := buildDriver(context.Background(), options, io.Discard)
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
func TestResolveIOSTargetSimulatorSkipsDeviceResolution(t *testing.T) {
|
||||
swapIOSResolveSeams(t)
|
||||
iosResolveTarget = func(context.Context, string) (string, bool, error) {
|
||||
return "sim-udid", true, nil
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected physical-device iOS to be rejected")
|
||||
ensured := false
|
||||
iosEnsureSimulator = func(context.Context, string, io.Writer) error { ensured = true; return nil }
|
||||
iosResolveDevice = func(context.Context, string) (ios.Device, error) {
|
||||
t.Fatal("simulator path must not resolve a physical device")
|
||||
return ios.Device{}, nil
|
||||
}
|
||||
|
||||
options := Options{Platform: "ios", IosDevice: "iPhone 17 Pro"}
|
||||
resolved, err := resolveIOSTarget(context.Background(), options, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ensured {
|
||||
t.Fatal("simulator path must ensure the simulator is booted")
|
||||
}
|
||||
if !resolved.iosIsSimulator || resolved.iosUDID != "sim-udid" {
|
||||
t.Fatalf("resolved = %+v, want the booted simulator", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIOSTargetDeviceResolutionErrorSurfaces(t *testing.T) {
|
||||
swapIOSResolveSeams(t)
|
||||
iosResolveTarget = func(_ context.Context, query string) (string, bool, error) { return query, false, nil }
|
||||
iosResolveDevice = func(context.Context, string) (ios.Device, error) {
|
||||
return ios.Device{}, errors.New("no device connected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not yet supported") {
|
||||
t.Fatalf("expected an unsupported-physical-iOS error, got %v", err)
|
||||
options := Options{Platform: "ios", IosDevice: "iPhone"}
|
||||
if _, err := resolveIOSTarget(context.Background(), options, io.Discard); err == nil {
|
||||
t.Fatal("expected the device-resolution error to surface")
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/priyanshujain/sanderling/internal/android"
|
||||
"github.com/priyanshujain/sanderling/internal/bundler"
|
||||
"github.com/priyanshujain/sanderling/internal/driver"
|
||||
"github.com/priyanshujain/sanderling/internal/ios"
|
||||
"github.com/priyanshujain/sanderling/internal/runner"
|
||||
"github.com/priyanshujain/sanderling/internal/trace"
|
||||
"github.com/priyanshujain/sanderling/internal/verifier"
|
||||
@@ -34,10 +33,13 @@ type Options struct {
|
||||
Output string
|
||||
ClearData bool
|
||||
|
||||
// iosUDID and iosIsSimulator are filled by Execute after resolving the iOS
|
||||
// target, then read by buildDriver to choose the companion or sidecar path.
|
||||
// iosUDID, iosIsSimulator, and iosCoreDeviceID are filled by Execute after
|
||||
// resolving the iOS target, then read by buildDriver to choose the simulator
|
||||
// companion or the physical-device driver. On the device path iosUDID is the
|
||||
// hardware UDID and iosCoreDeviceID is the CoreDevice id.
|
||||
iosUDID string
|
||||
iosIsSimulator bool
|
||||
iosCoreDeviceID string
|
||||
}
|
||||
|
||||
// Execute runs the full test pipeline: bundle, launch app, verify properties.
|
||||
@@ -48,30 +50,11 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
|
||||
return err
|
||||
}
|
||||
case "ios":
|
||||
udid, isSimulator, err := ios.ResolveTarget(ctx, options.IosDevice)
|
||||
if err != nil {
|
||||
// No query and nothing booted: keep boot-first behavior, then resolve
|
||||
// the simulator that EnsureSimulator just brought up.
|
||||
if options.IosDevice == "" {
|
||||
if bootErr := ios.EnsureSimulator(ctx, "", stdout); bootErr != nil {
|
||||
return bootErr
|
||||
}
|
||||
udid, isSimulator, err = ios.ResolveTarget(ctx, "")
|
||||
}
|
||||
resolved, err := resolveIOSTarget(ctx, options, stdout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if isSimulator {
|
||||
if err := ios.EnsureSimulator(ctx, options.IosDevice, stdout); err != nil {
|
||||
return err
|
||||
}
|
||||
udid, isSimulator, err = ios.ResolveTarget(ctx, options.IosDevice)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
options.iosUDID = udid
|
||||
options.iosIsSimulator = isSimulator
|
||||
options = resolved
|
||||
}
|
||||
prep, err := prepareBundleInputs(options)
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user