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.
This commit is contained in:
pj committed 2026-06-08 23:20:35 +05:30
1 parent f5cfc4407e
commit 743b62b2bd
4 files changed
+117 -10

No files matched your search

+47 -6
View File
@@ -14,6 +14,8 @@ import (
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion"
"github.com/priyanshujain/sanderling/internal/ios"
"github.com/priyanshujain/sanderling/internal/sidecarassets" "github.com/priyanshujain/sanderling/internal/sidecarassets"
) )
@@ -59,8 +61,8 @@ func androidChecks() []doctorCheck {
// iosChecks covers the simulator path, which the native companion drives with // iosChecks covers the simulator path, which the native companion drives with
// no JVM. A simulator host with no Java still passes. Physical-device runs // no JVM. A simulator host with no Java still passes. Physical-device runs
// additionally need java and the sidecar JAR, covered by iosDeviceChecks and // additionally need devicectl, iproxy, a connected device, and signing
// surfaced through the "all" union. // credentials, covered by iosDeviceChecks and surfaced through the "all" union.
func iosChecks() []doctorCheck { func iosChecks() []doctorCheck {
return []doctorCheck{ return []doctorCheck{
{Name: "xcrun on PATH (ios simulator)", Run: checkExecutableOnPath("xcrun")}, {Name: "xcrun on PATH (ios simulator)", Run: checkExecutableOnPath("xcrun")},
@@ -68,12 +70,16 @@ func iosChecks() []doctorCheck {
} }
} }
// iosDeviceChecks covers the extra prerequisites a physical iOS device needs: // iosDeviceChecks covers the prerequisites a physical iOS device needs: the
// the JVM and a real sidecar JAR for the sidecar driver path. // runner is built and driven over a usbmux tunnel, so devicectl installs the
// app, iproxy forwards the tunnel, a device must be connected and paired, and
// App Store Connect signing credentials must be present for the no-UI build.
func iosDeviceChecks() []doctorCheck { func iosDeviceChecks() []doctorCheck {
return []doctorCheck{ return []doctorCheck{
{Name: "java 17+ on PATH (ios physical device)", Run: checkJavaVersion}, {Name: "devicectl available (ios physical device)", Run: checkDevicectl},
{Name: "sidecar JAR is real (ios physical device)", Run: checkSidecarJAR}, {Name: "iproxy on PATH (ios physical device)", Run: checkExecutableOnPath("iproxy")},
{Name: "an iOS device is connected and paired", Run: checkDeviceConnected},
{Name: "App Store Connect signing credentials present", Run: checkDeviceSigning},
} }
} }
@@ -120,6 +126,41 @@ func checkSimctl(ctx context.Context) error {
return nil 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
)
// 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
}
// 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 { func checkSidecarJAR(_ context.Context) error {
if sidecarassets.IsPlaceholder() { if sidecarassets.IsPlaceholder() {
return fmt.Errorf("placeholder JAR embedded; run `make sidecar && make sanderling` to embed the real fat JAR") return fmt.Errorf("placeholder JAR embedded; run `make sidecar && make sanderling` to embed the real fat JAR")
+56 -4
View File
@@ -6,8 +6,12 @@ import (
"errors" "errors"
"flag" "flag"
"io" "io"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/priyanshujain/sanderling/internal/ios"
) )
func TestRunDoctorChecks_AllPass(t *testing.T) { func TestRunDoctorChecks_AllPass(t *testing.T) {
@@ -142,15 +146,63 @@ 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", "iproxy", "connected and paired", "signing credentials"} {
found := false found := false
for _, c := range doctorChecksFor("ios-device") { for _, c := range checks {
if strings.Contains(c.Name, "java") { if strings.Contains(c.Name, want) {
found = true found = true
} }
} }
if !found { 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_EnvAndKeyFile(t *testing.T) {
// The signing check defers to the driver's credential verification, which
// reads the same environment a real build would.
for _, key := range []string{"SANDERLING_IOS_TEAM", "DEVELOPMENT_TEAM", "ASC_API_KEY_PATH", "ASC_API_KEY_ID", "ASC_API_ISSUER_ID"} {
t.Setenv(key, "")
}
if err := checkDeviceSigning(context.Background()); err == nil {
t.Fatal("missing signing env must fail")
}
keyPath := filepath.Join(t.TempDir(), "AuthKey.p8")
if err := os.WriteFile(keyPath, []byte("key"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("SANDERLING_IOS_TEAM", "TEAM1")
t.Setenv("ASC_API_KEY_ID", "KID")
t.Setenv("ASC_API_ISSUER_ID", "ISS")
t.Setenv("ASC_API_KEY_PATH", keyPath)
if err := checkDeviceSigning(context.Background()); err != nil {
t.Fatalf("complete signing env with a present key must pass: %v", err)
} }
} }
@@ -75,6 +75,14 @@ func readSigningCredentials() (signingCredentials, error) {
return creds, nil 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
}
func firstNonEmpty(values ...string) string { func firstNonEmpty(values ...string) string {
for _, value := range values { for _, value := range values {
if value != "" { if value != "" {
+6
View File
@@ -39,6 +39,12 @@ type coreDeviceList struct {
// canned devicectl output without invoking xcrun. // canned devicectl output without invoking xcrun.
var listDevices = coreDevices 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 // ResolveDevice picks the physical iOS device a run drives. An empty query
// resolves the single connected device (an error names them all when several // 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 // are connected). A non-empty query matches a device by name, hardware UDID, or