From 966aeba6c075d2bb4b8a2173ccf7cdd283aa16bc Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 6 Jun 2026 23:45:13 +0530 Subject: [PATCH] feat(ios): add ResolveTarget for simulator vs physical-device routing --- internal/ios/ios.go | 78 +++++++++++++++++++++++++ internal/ios/ios_test.go | 119 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+) diff --git a/internal/ios/ios.go b/internal/ios/ios.go index b1af83c..e24f467 100644 --- a/internal/ios/ios.go +++ b/internal/ios/ios.go @@ -26,6 +26,7 @@ type simctlDeviceList struct { // with canned device lists without invoking xcrun. var ( listBooted = bootedSimulator + listBootedAll = bootedSimulators listAvailable = availableSimulators boot = bootSimulator waitForBoot = waitForSimulatorBoot @@ -73,6 +74,59 @@ func BootedUDID(ctx context.Context) string { return d.UDID } +// ResolveTarget decides which iOS target a run drives and whether it is a +// simulator. An explicit query matching a simulator name or UDID (booted or +// 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. +func ResolveTarget(ctx context.Context, query string) (udid string, isSimulator bool, err error) { + if query != "" { + booted, err := listBootedAll(ctx) + if err != nil { + return "", false, fmt.Errorf("list booted simulators: %w", err) + } + if match := matchSimulator(query, booted); match != nil { + return match.UDID, true, nil + } + available, err := listAvailable(ctx) + if err != nil { + return "", false, fmt.Errorf("list available simulators: %w", err) + } + if match := matchSimulator(query, available); match != nil { + return match.UDID, true, nil + } + return query, false, nil + } + + booted, err := listBootedAll(ctx) + if err != nil { + return "", false, fmt.Errorf("list booted simulators: %w", err) + } + switch len(booted) { + case 1: + return booted[0].UDID, true, nil + case 0: + return "", false, fmt.Errorf("no booted iOS simulator found") + default: + var lines strings.Builder + for _, device := range booted { + fmt.Fprintf(&lines, "\n %s (%s)", device.Name, device.UDID) + } + return "", false, fmt.Errorf("multiple booted iOS simulators; pass --ios-device to select one:%s", lines.String()) + } +} + +func matchSimulator(query string, devices []simDevice) *simDevice { + for i := range devices { + if devices[i].Name == query || devices[i].UDID == query { + return &devices[i] + } + } + return nil +} + func bootedSimulator(ctx context.Context) (*simDevice, error) { out, err := exec.CommandContext(ctx, "xcrun", "simctl", "list", "devices", "booted", "--json").Output() if err != nil { @@ -81,6 +135,14 @@ func bootedSimulator(ctx context.Context) (*simDevice, error) { return parseBootedDevice(out) } +func bootedSimulators(ctx context.Context) ([]simDevice, error) { + out, err := exec.CommandContext(ctx, "xcrun", "simctl", "list", "devices", "booted", "--json").Output() + if err != nil { + return nil, err + } + return parseBootedDevices(out) +} + func availableSimulators(ctx context.Context) ([]simDevice, error) { out, err := exec.CommandContext(ctx, "xcrun", "simctl", "list", "devices", "available", "--json").Output() if err != nil { @@ -104,6 +166,22 @@ func parseBootedDevice(out []byte) (*simDevice, error) { return nil, nil } +func parseBootedDevices(out []byte) ([]simDevice, error) { + var list simctlDeviceList + if err := json.Unmarshal(out, &list); err != nil { + return nil, err + } + var result []simDevice + for _, devices := range list.Devices { + for i := range devices { + if devices[i].State == "Booted" { + result = append(result, devices[i]) + } + } + } + return result, nil +} + func parseAvailableDevices(out []byte) ([]simDevice, error) { var list simctlDeviceList if err := json.Unmarshal(out, &list); err != nil { diff --git a/internal/ios/ios_test.go b/internal/ios/ios_test.go index d4b1f5b..c75c96d 100644 --- a/internal/ios/ios_test.go +++ b/internal/ios/ios_test.go @@ -208,6 +208,125 @@ func TestEnsureSimulator_BootsPickedSimulator(t *testing.T) { } } +func swapResolveSeams(t *testing.T) { + t.Helper() + origBootedAll, origAvailable := listBootedAll, listAvailable + t.Cleanup(func() { listBootedAll, listAvailable = origBootedAll, origAvailable }) +} + +func TestResolveTarget_ExplicitMatchesBootedSimulator(t *testing.T) { + swapResolveSeams(t) + listBootedAll = func(context.Context) ([]simDevice, error) { + return []simDevice{{UDID: "booted-udid", Name: "iPhone 15", State: "Booted", IsAvailable: true}}, nil + } + listAvailable = func(context.Context) ([]simDevice, error) { + t.Fatal("should not list available when a booted simulator matches") + return nil, nil + } + udid, isSimulator, err := ResolveTarget(context.Background(), "iPhone 15") + if err != nil { + t.Fatal(err) + } + if udid != "booted-udid" || !isSimulator { + t.Errorf("got (%q, %v), want (booted-udid, true)", udid, isSimulator) + } +} + +func TestResolveTarget_ExplicitMatchesAvailableByUDID(t *testing.T) { + swapResolveSeams(t) + listBootedAll = func(context.Context) ([]simDevice, error) { return nil, nil } + listAvailable = func(context.Context) ([]simDevice, error) { + return []simDevice{{UDID: "shutdown-udid", Name: "iPhone 16", IsAvailable: true}}, nil + } + udid, isSimulator, err := ResolveTarget(context.Background(), "shutdown-udid") + if err != nil { + t.Fatal(err) + } + if udid != "shutdown-udid" || !isSimulator { + t.Errorf("got (%q, %v), want (shutdown-udid, true)", udid, isSimulator) + } +} + +func TestResolveTarget_UnknownQueryResolvesAsPhysicalDevice(t *testing.T) { + swapResolveSeams(t) + listBootedAll = func(context.Context) ([]simDevice, error) { return nil, nil } + listAvailable = func(context.Context) ([]simDevice, error) { + return []simDevice{{UDID: "sim-udid", Name: "iPhone 16", IsAvailable: true}}, nil + } + udid, isSimulator, err := ResolveTarget(context.Background(), "00008110-physical-device-udid") + if err != nil { + t.Fatal(err) + } + if udid != "00008110-physical-device-udid" || isSimulator { + t.Errorf("got (%q, %v), want (00008110-physical-device-udid, false)", udid, isSimulator) + } +} + +func TestResolveTarget_NoQuerySingleBooted(t *testing.T) { + swapResolveSeams(t) + listBootedAll = func(context.Context) ([]simDevice, error) { + return []simDevice{{UDID: "only-booted", Name: "iPhone 15", State: "Booted", IsAvailable: true}}, nil + } + udid, isSimulator, err := ResolveTarget(context.Background(), "") + if err != nil { + t.Fatal(err) + } + if udid != "only-booted" || !isSimulator { + t.Errorf("got (%q, %v), want (only-booted, true)", udid, isSimulator) + } +} + +func TestResolveTarget_NoQueryZeroBootedErrors(t *testing.T) { + swapResolveSeams(t) + listBootedAll = func(context.Context) ([]simDevice, error) { return nil, nil } + _, _, err := ResolveTarget(context.Background(), "") + if err == nil { + t.Fatal("expected error when no simulator is booted") + } +} + +func TestResolveTarget_NoQueryMultipleBootedErrors(t *testing.T) { + swapResolveSeams(t) + listBootedAll = func(context.Context) ([]simDevice, error) { + return []simDevice{ + {UDID: "udid-a", Name: "iPhone 15", State: "Booted", IsAvailable: true}, + {UDID: "udid-b", Name: "iPad Pro", State: "Booted", IsAvailable: true}, + }, nil + } + _, _, err := ResolveTarget(context.Background(), "") + if err == nil { + t.Fatal("expected ambiguity error for multiple booted simulators") + } + message := err.Error() + for _, want := range []string{"--ios-device", "iPhone 15", "udid-a", "iPad Pro", "udid-b"} { + if !strings.Contains(message, want) { + t.Errorf("ambiguity error missing %q: %v", want, message) + } + } +} + +func TestResolveTarget_BootedListError(t *testing.T) { + swapResolveSeams(t) + listBootedAll = func(context.Context) ([]simDevice, error) { return nil, errors.New("xcrun blew up") } + if _, _, err := ResolveTarget(context.Background(), ""); err == nil { + t.Fatal("expected booted-list error to propagate") + } +} + +func TestParseBootedDevices_CollectsAllBooted(t *testing.T) { + got, err := parseBootedDevices([]byte(`{"devices":{"r":[ + {"udid":"a","state":"Booted","name":"iPhone 15","isAvailable":true}, + {"udid":"b","state":"Shutdown","name":"iPad","isAvailable":true}, + {"udid":"c","state":"Booted","name":"iPhone 16","isAvailable":true} + ]}}`)) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("got %d booted, want 2", len(got)) + } +} + func TestEnsureSimulator_BootedListError(t *testing.T) { origListBooted := listBooted t.Cleanup(func() { listBooted = origListBooted })