fix(xcui): send a physical tap, and refuse to guess the simulator

AXe's default tap style sends a simulator tap that SwiftUI controls
ignore while AXe still prints a success line. Measured on Xcode 27.1
with AXe 1.8.0, across iPhone 17 (iOS 27.0) and iPhone Duo (iOS 27.1):
neither a Button, a List row, a Button inside a List, a Menu, a row of
an open Menu nor a TabView tab activated under the default or the
`simulator` style, and `--tap-style physical` activated all six. The
same default left system alerts on screen while `dialog accept`
reported them handled.

`tap` and the `dialog` accept/dismiss taps now supply the physical
style unless the caller picks one, and an AXe older than 1.7.0 — which
has no `--tap-style` — is named as the cause instead of surfacing as an
unknown-flag failure.

With more than one simulator booted and no `--udid`, every device verb
now exits 2 and lists each booted device's UDID, name and runtime,
rather than driving whichever sorted first while reporting success.
`doctor` reports that list, fails its gate in that state, and leaves
`booted_udid` empty, because nothing may be targeted until the caller
chooses.
This commit is contained in:
Charles Wiltgen
2026-09-19 15:43:23 -07:00
parent dda05d9e4c
commit e996de9d79
11 changed files with 443 additions and 78 deletions
Binary file not shown.
+8 -4
View File
@@ -131,12 +131,13 @@ func findAlertButton(roots []AXElement, intent alertIntent) (AXElement, bool) {
}
// tapArgs builds the `axe tap` argument vector, preferring the stable
// accessibility id and falling back to the visible label.
// accessibility id and falling back to the visible label. The tap is physical:
// AXe's default style left alerts on screen while the report said handled.
func tapArgs(el AXElement, udid string) []string {
if id := deref(el.AXUniqueID); id != "" {
return []string{"tap", "--id", id, "--udid", udid}
return append([]string{"tap"}, withPhysicalTapStyle([]string{"--id", id, "--udid", udid})...)
}
return []string{"tap", "--label", buttonLabel(el), "--udid", udid}
return append([]string{"tap"}, withPhysicalTapStyle([]string{"--label", buttonLabel(el), "--udid", udid})...)
}
func runDialog(out io.Writer, args []string) int {
@@ -187,7 +188,10 @@ func runDialogTap(out io.Writer, action string, args []string) int {
return 1
}
rep.Button = buttonLabel(btn)
if _, err := runAxe(ctx, 0, tapArgs(btn, udid)...); err != nil {
if res, err := runAxe(ctx, 0, tapArgs(btn, udid)...); err != nil {
if isUnknownTapStyleError(string(res.Stderr)) {
fmt.Fprintf(os.Stderr, "dialog: this AXe has no %s (added in AXe 1.7.0), which xcui sends on every tap — upgrade with `brew upgrade cameroncooke/axe/axe`\n", tapStyleFlag)
}
fmt.Fprintln(os.Stderr, "dialog:", err)
return 2
}
+2 -2
View File
@@ -156,7 +156,7 @@ func TestTapArgsPrefersID(t *testing.T) {
roots, _ := parseDescribeUI([]byte(permissionAlert))
btn, _ := findAlertButton(roots, intentAccept) // "Allow" has AXUniqueId allow.btn
got := tapArgs(btn, "UDID1")
want := []string{"tap", "--id", "allow.btn", "--udid", "UDID1"}
want := []string{"tap", "--tap-style", "physical", "--id", "allow.btn", "--udid", "UDID1"}
if !equalStrings(got, want) {
t.Errorf("tapArgs = %v, want %v", got, want)
}
@@ -166,7 +166,7 @@ func TestTapArgsFallsBackToLabel(t *testing.T) {
roots, _ := parseDescribeUI([]byte(permissionAlert))
btn, _ := findAlertButton(roots, intentDismiss) // "Don't Allow", no AXUniqueId
got := tapArgs(btn, "UDID1")
want := []string{"tap", "--label", "Don't Allow", "--udid", "UDID1"}
want := []string{"tap", "--tap-style", "physical", "--label", "Don't Allow", "--udid", "UDID1"}
if !equalStrings(got, want) {
t.Errorf("tapArgs = %v, want %v", got, want)
}
+42 -8
View File
@@ -12,8 +12,13 @@ import (
"time"
)
func doctorExitCode(axePresent, simBooted, axeWorks bool) int {
if axePresent && simBooted && axeWorks {
// doctorExitCode maps the environment to xcui's documented gate: 0 means a caller
// can go straight on to driving the simulator. blocked covers the states where the
// pieces are all present but a device command would still refuse or fail — several
// simulators booted with no --udid, or an AXe that cannot take the tap style xcui
// sends. Reporting those as OK is what makes `xcui doctor && xcui tap …` lie.
func doctorExitCode(axePresent, simBooted, axeWorks, blocked bool) int {
if axePresent && simBooted && axeWorks && !blocked {
return 0
}
return 2
@@ -62,11 +67,25 @@ func runDoctor(out io.Writer, args []string) int {
}
}
// smokeUDID is the device the AXe smoke test exercises. It is deliberately
// separate from rep.BootedUDID: under ambiguity xcui still wants to prove AXe
// works, but must not advertise a target nothing will drive.
smokeUDID := ""
blocked := false
if udid, booted, err := resolveBootedInfo(ctx, *udidFlag); err == nil {
rep.BootedUDID = udid
smokeUDID = udid
rep.Booted = booted
if len(booted) > 1 {
rep.Note = fmt.Sprintf("%d simulators booted (%s) — xcui targets %s; pass --udid to pick another", len(booted), strings.Join(booted, ", "), udid)
rep.NextSteps = append(rep.NextSteps, "pass --udid <udid> to target a specific simulator")
// Keep this in step with ambiguousSimError in sim.go, which does the refusing.
blocked = true
labels := make([]string, len(booted))
for i, s := range booted {
labels[i] = fmt.Sprintf("%s %s", s.UDID, simLabel(s))
}
rep.Problems = append(rep.Problems, fmt.Sprintf("%d simulators booted (%s) — every device command refuses to run without --udid", len(booted), strings.Join(labels, ", ")))
rep.NextSteps = append(rep.NextSteps, "pass --udid <udid> on every command to target a specific simulator")
} else {
rep.BootedUDID = udid
}
} else {
rep.Problems = append(rep.Problems, "no booted simulator")
@@ -84,13 +103,25 @@ func runDoctor(out io.Writer, args []string) int {
rep.Note = joinNote(rep.Note, "AXe has verbs xcui does not forward ("+strings.Join(missing, ", ")+") — call them as `axe <verb>` until Axiom adds them")
}
}
// xcui supplies --tap-style physical on every tap. If a future AXe drops or
// renames the flag, each tap fails on an unknown flag; say so here instead.
if res, err := ExecRun(ctx, 10*time.Second, axePath, "tap", "--help"); err == nil {
if !tapStyleSupported(string(res.Stdout)) {
// AXe gained --tap-style in 1.7.0. Older AXe fails every xcui tap and
// every dialog accept/dismiss on an unknown flag, and describe-ui (the
// smoke test below) never touches it — so check for it explicitly.
blocked = true
rep.Problems = append(rep.Problems, "this AXe ("+orNone(rep.AxeVersion)+") does not offer `axe tap "+tapStyleFlag+"` — xcui requires AXe 1.7.0 or newer; every tap and dialog would fail on an unknown flag")
rep.NextSteps = append(rep.NextSteps, "upgrade AXe: brew upgrade cameroncooke/axe/axe")
}
}
}
axeWorks := true
if axePath != "" && rep.BootedUDID != "" {
if axePath != "" && smokeUDID != "" {
// Read the override AFTER the smoke test, not before: the decision is now
// made by running AXe, so asking first would always report "none".
if res, err := runAxe(ctx, 30*time.Second, "describe-ui", "--udid", rep.BootedUDID); err != nil {
if res, err := runAxe(ctx, 30*time.Second, "describe-ui", "--udid", smokeUDID); err != nil {
stderr := strings.TrimSpace(string(res.Stderr))
switch {
case IsTimeoutError(err):
@@ -120,11 +151,14 @@ func runDoctor(out io.Writer, args []string) int {
}
}
code := doctorExitCode(axePath != "", rep.BootedUDID != "", axeWorks)
code := doctorExitCode(axePath != "", smokeUDID != "", axeWorks, blocked)
rep.OK = code == 0
if *human {
fmt.Fprintf(out, "AXe: %s\nSim: %s\nOK: %v\n", orNone(rep.AxePath), orNone(rep.BootedUDID), rep.OK)
for _, s := range rep.Booted {
fmt.Fprintf(out, " booted: %s %s\n", s.UDID, simLabel(s))
}
if rep.Note != "" {
fmt.Fprintf(out, " note: %s\n", rep.Note)
}
+41
View File
@@ -95,6 +95,10 @@ func runInput(out io.Writer, verb string, args []string) int {
res, err := runAxe(context.Background(), inputTimeout, verb, "--help")
out.Write(res.Stdout)
os.Stderr.Write(res.Stderr)
if verb == "tap" && err == nil {
// AXe's help documents its own default, which xcui overrides.
fmt.Fprintf(os.Stderr, "\nxcui note: xcui sends %s %s unless you pass %s yourself.\n", tapStyleFlag, tapStylePhysical, tapStyleFlag)
}
if err != nil {
fmt.Fprintf(os.Stderr, "xcui %s --help: %v\n", verb, err)
return 2
@@ -111,11 +115,17 @@ func runInput(out io.Writer, verb string, args []string) int {
}
args = append([]string{"--udid", udid}, args...)
}
if verb == "tap" {
args = withPhysicalTapStyle(args)
}
res, err := runAxe(ctx, inputTimeout, append([]string{verb}, args...)...)
out.Write(res.Stdout)
os.Stderr.Write(res.Stderr)
if err != nil {
if isUnknownTapStyleError(string(res.Stderr)) {
fmt.Fprintf(os.Stderr, "xcui %s: this AXe has no %s (added in AXe 1.7.0), which xcui sends on every tap — upgrade with `brew upgrade cameroncooke/axe/axe`, or run `axe %s` directly\n", verb, tapStyleFlag, verb)
}
if IsTimeoutError(err) {
fmt.Fprintf(os.Stderr, "xcui %s: timed out after %s\n", verb, inputTimeout)
return 2
@@ -136,6 +146,37 @@ func runInput(out io.Writer, verb string, args []string) int {
return 0
}
// tapStyleFlag and tapStylePhysical are AXe's spelling for the only tap style that
// works. AXe's default sends FBSimulator tapAt for anything but a switch, which
// SwiftUI buttons, list rows, menus, and tabs ignored on Xcode 27.1 + AXe 1.8.0 while
// AXe still printed ✓; a physical touch down/up activated all of them. Both the `tap`
// passthrough and `dialog`'s alert taps go through withPhysicalTapStyle, so the flag
// is spelled once — `doctor` reports if a future AXe stops offering it.
const (
tapStyleFlag = "--tap-style"
tapStylePhysical = "physical"
)
// withPhysicalTapStyle prepends AXe's physical tap style unless the caller chose one.
func withPhysicalTapStyle(args []string) []string {
if hasFlag(args, tapStyleFlag) {
return args
}
return append([]string{tapStyleFlag, tapStylePhysical}, args...)
}
// isUnknownTapStyleError spots AXe rejecting the flag xcui supplies — what an AXe
// older than 1.7.0 does to every tap. The message names xcui's own addition, so
// without this the user reads an error about a flag they never passed.
func isUnknownTapStyleError(stderr string) bool {
return strings.Contains(stderr, "Unknown option") && strings.Contains(stderr, tapStyleFlag)
}
// tapStyleSupported reports whether `axe tap --help` still documents the flag.
func tapStyleSupported(tapHelp string) bool {
return strings.Contains(tapHelp, tapStyleFlag)
}
// hasFlag reports whether args already carries name, in either `--flag value` or
// `--flag=value` form.
func hasFlag(args []string, name string) bool {
+106 -4
View File
@@ -45,14 +45,15 @@ func TestInputVerbsDoNotShadowXcuiCommands(t *testing.T) {
}
}
func TestRunInputForwardsThroughRunAxe(t *testing.T) {
func TestRunInputForwardsNonTapVerbVerbatim(t *testing.T) {
// The whole point of the passthrough: input goes through runAxe, so it inherits
// the SimulatorKit fallback. Assert the call actually lands there, with the verb
// first and the caller's args untouched after it.
// first and the caller's args untouched after it. `tap` is the one verb xcui
// adds a flag to — TestRunInputTapStyle covers that.
calls := withFakeAxe(t, []axeResult{{"", nil}})
var out bytes.Buffer
code := runInput(&out, "tap", []string{"--udid", "SIM-1", "--id", "primary-cta"})
code := runInput(&out, "type", []string{"--udid", "SIM-1", "user@example.com"})
if code != 0 {
t.Fatalf("exit = %d, want 0", code)
}
@@ -60,11 +61,47 @@ func TestRunInputForwardsThroughRunAxe(t *testing.T) {
t.Fatalf("expected 1 AXe invocation, got %d", len(*calls))
}
got := strings.Join((*calls)[0].args, " ")
if got != "tap --udid SIM-1 --id primary-cta" {
if got != "type --udid SIM-1 user@example.com" {
t.Errorf("forwarded args = %q, want the verb followed by argv verbatim", got)
}
}
func TestRunInputTapStyle(t *testing.T) {
// AXe's default tap style sends FBSimulator tapAt, which activated no SwiftUI
// control tested on Xcode 27.1 + AXe 1.8.0 while still printing "✓ … completed
// successfully"; --tap-style physical activated all of them. So xcui supplies
// physical when the caller chose nothing, and never overrides a caller's choice.
// Only `tap` takes the flag — AXe rejects it on every other verb.
cases := []struct {
verb string
args []string
want string
why string
}{
{"tap", []string{"--udid", "SIM-1", "--id", "cta"},
"tap --tap-style physical --udid SIM-1 --id cta", "omitted style becomes physical"},
{"tap", []string{"--udid", "SIM-1", "--tap-style", "simulator", "--id", "cta"},
"tap --udid SIM-1 --tap-style simulator --id cta", "caller's style is forwarded untouched"},
{"tap", []string{"--udid", "SIM-1", "--tap-style=automatic", "-x", "1", "-y", "2"},
"tap --udid SIM-1 --tap-style=automatic -x 1 -y 2", "equals form counts as a choice"},
{"swipe", []string{"--udid", "SIM-1", "--start-x", "1", "--start-y", "2", "--end-x", "3", "--end-y", "4"},
"swipe --udid SIM-1 --start-x 1 --start-y 2 --end-x 3 --end-y 4", "non-tap verbs get no tap style"},
}
for _, c := range cases {
calls := withFakeAxe(t, []axeResult{{"", nil}})
var out bytes.Buffer
if code := runInput(&out, c.verb, c.args); code != 0 {
t.Fatalf("%s: exit = %d, want 0", c.why, code)
}
if len(*calls) != 1 {
t.Fatalf("%s: expected 1 AXe invocation, got %d", c.why, len(*calls))
}
if got := strings.Join((*calls)[0].args, " "); got != c.want {
t.Errorf("%s: forwarded %q, want %q", c.why, got, c.want)
}
}
}
func TestRunInputPropagatesAxeExitCode(t *testing.T) {
// A passthrough that swallows AXe's exit code is not transparent: callers branch
// on it exactly as they would running axe directly.
@@ -125,3 +162,68 @@ func TestUnforwardedAxeVerbsIgnoresWhatXcuiOwns(t *testing.T) {
t.Errorf("unforwardedAxeVerbs = %v, want empty", got)
}
}
func TestTapStyleSupported(t *testing.T) {
// xcui supplies --tap-style physical because AXe's default activates nothing.
// If a future AXe drops or renames the flag, every tap starts failing on an
// unknown flag, so doctor watches `axe tap --help` for it.
help := `USAGE: axe tap [-x <x>] [-y <y>] [--id <id>] [--tap-style <tap-style>] --udid <udid>
OPTIONS:
--tap-style <tap-style> Tap event style: automatic uses physical touch for
switches/toggles and simulator tap for other targets.
`
if !tapStyleSupported(help) {
t.Error("tapStyleSupported = false for help text that documents --tap-style")
}
if tapStyleSupported("USAGE: axe tap [-x <x>] [-y <y>] --udid <udid>\n") {
t.Error("tapStyleSupported = true for help text without the flag")
}
}
func TestRunInputRefusesAmbiguousSimulator(t *testing.T) {
// The whole point of the change is an exit code, so pin it through the real
// wiring: no --udid, several booted, nothing forwarded to AXe.
withFakeExec(t, threeBootedTwoRuntimes)
axeCalls := withFakeAxe(t, nil)
var out bytes.Buffer
if code := runInput(&out, "tap", []string{"--id", "cta"}); code != 2 {
t.Errorf("exit = %d, want 2 (environment error)", code)
}
if len(*axeCalls) != 0 {
t.Errorf("forwarded %d call(s) to AXe, want none — nothing may be driven", len(*axeCalls))
}
}
func TestRunInputResolvesSingleSimulatorThenAddsTapStyle(t *testing.T) {
// With one booted sim, xcui supplies both flags; this pins their order, which
// no other case covers (every other test passes --udid explicitly).
withFakeExec(t, sampleDevices)
calls := withFakeAxe(t, []axeResult{{"", nil}})
var out bytes.Buffer
if code := runInput(&out, "tap", []string{"--id", "cta"}); code != 0 {
t.Fatalf("exit = %d, want 0", code)
}
if len(*calls) != 1 {
t.Fatalf("expected 1 AXe invocation, got %d", len(*calls))
}
if got := strings.Join((*calls)[0].args, " "); got != "tap --tap-style physical --udid BBBB --id cta" {
t.Errorf("forwarded args = %q", got)
}
}
func TestIsUnknownTapStyleError(t *testing.T) {
// AXe below 1.7.0 has no --tap-style, so every xcui tap fails on the flag xcui
// itself added. Recognize that exact stderr so the advice can name the cause.
if !isUnknownTapStyleError("Error: Unknown option '--tap-style'\n") {
t.Error("did not recognize AXe's unknown-option error for --tap-style")
}
if isUnknownTapStyleError("Error: Unknown option '--wait-timeout'\n") {
t.Error("matched an unknown-option error for a different flag")
}
if isUnknownTapStyleError("axe: element not found\n") {
t.Error("matched an ordinary AXe failure")
}
}
+21
View File
@@ -23,3 +23,24 @@ func TestExecRunTimeout(t *testing.T) {
t.Errorf("expected TimeoutError, got %v", err)
}
}
// execCall records one subprocess xcui would have run.
type execCall struct {
name string
args []string
}
// withFakeExec replaces the ExecRun seam for the duration of a test, answering
// every call with stdout. It returns the calls made, so a test can assert that a
// path shelled out — or, more usefully, that it did not.
func withFakeExec(t *testing.T, stdout string) *[]execCall {
t.Helper()
calls := &[]execCall{}
orig := execRun
execRun = func(ctx context.Context, timeout time.Duration, name string, args ...string) (ExecResult, error) {
*calls = append(*calls, execCall{name: name, args: args})
return ExecResult{Stdout: []byte(stdout)}, nil
}
t.Cleanup(func() { execRun = orig })
return calls
}
+5 -3
View File
@@ -22,7 +22,8 @@ Usage:
xcui resize sweep --sizes <WxH,...> [--screenshot-dir <d>] [--assert-id <id>] [--strict]
Drive breakpoints, shoot and assert each (OS 27+)
Input (forwarded to AXe verbatim same flags, same output, same exit code):
Input (forwarded to AXe same flags, output and exit code; tap also gets
--tap-style physical unless you pass a style):
xcui tap | slider | type | swipe | drag | touch | gesture Drive the UI
xcui button | key | key-sequence | key-combo Hardware buttons and keys
xcui screenshot Capture the display as PNG
@@ -31,8 +32,9 @@ Prefer these over calling 'axe' directly: they inherit xcui's SimulatorKit/
DEVELOPER_DIR handling, so they keep working under an Xcode that AXe cannot load
on its own. Run 'xcui tap --help' to see AXe's own flags for a verb.
Default output is JSON; pass --human for prose. Most verbs auto-resolve the booted
simulator; pass --udid to target a specific one.
Default output is JSON; pass --human for prose. Verbs auto-resolve the booted
simulator when exactly one is booted; with several, they exit 2 and list them
pass --udid to name the one you mean.
Run 'xcui <command> --help' for per-command flags.
`
+118 -27
View File
@@ -1,6 +1,11 @@
package main
import "testing"
import (
"context"
"errors"
"reflect"
"testing"
)
func TestVersionConstSet(t *testing.T) {
if version == "" {
@@ -88,50 +93,136 @@ func TestPickBootedUDIDNoneBooted(t *testing.T) {
}
}
// With more than one sim booted the candidates must come back sorted, so the
// pick is stable across runs (Go map iteration order is randomized — without
// sorting, wait/assert could target a different sim each run).
func TestBootedUDIDsSortedDeterministic(t *testing.T) {
const twoBooted = `{"devices":{
"com.apple.CoreSimulator.SimRuntime.iOS-26-0":[
{"udid":"ZZZZ","state":"Booted","name":"iPhone 17 Pro"},
{"udid":"AAAA","state":"Booted","name":"iPhone 17"},
{"udid":"MMMM","state":"Shutdown","name":"iPad"}
]}}`
got, err := bootedUDIDs([]byte(twoBooted))
// Two same-named devices on different runtimes is the real-world shape: the
// runtime is the only thing that tells "iPhone 17 (26.5)" from "iPhone 17 (27)".
const threeBootedTwoRuntimes = `{"devices":{
"com.apple.CoreSimulator.SimRuntime.iOS-27-0":[
{"udid":"ZZZZ","state":"Booted","name":"iPhone 17"},
{"udid":"MMMM","state":"Shutdown","name":"iPad"}
],
"com.apple.CoreSimulator.SimRuntime.iOS-26-5":[
{"udid":"AAAA","state":"Booted","name":"iPhone 17"}
],
"com.apple.CoreSimulator.SimRuntime.iOS-27-1":[
{"udid":"DDDD","state":"Booted","name":"iPhone Duo"}
]}}`
// Sorted by UDID so the refusal lists devices in the same order every run
// (Go map iteration order is randomized).
func TestBootedSimsSortedWithRuntime(t *testing.T) {
got, err := bootedSims([]byte(threeBootedTwoRuntimes))
if err != nil {
t.Fatalf("err: %v", err)
}
if !equalStrings(got, []string{"AAAA", "ZZZZ"}) {
t.Errorf("bootedUDIDs = %v, want [AAAA ZZZZ] (sorted)", got)
want := []bootedSim{
{UDID: "AAAA", Name: "iPhone 17", Runtime: "iOS 26.5"},
{UDID: "DDDD", Name: "iPhone Duo", Runtime: "iOS 27.1"},
{UDID: "ZZZZ", Name: "iPhone 17", Runtime: "iOS 27.0"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("bootedSims = %+v, want %+v", got, want)
}
}
func TestBootedUDIDsNoneBooted(t *testing.T) {
func TestBootedSimsNoneBooted(t *testing.T) {
none := `{"devices":{"r":[{"udid":"AAAA","state":"Shutdown","name":"x"}]}}`
got, err := bootedUDIDs([]byte(none))
got, err := bootedSims([]byte(none))
if err != nil {
t.Fatalf("err: %v", err)
}
if len(got) != 0 {
t.Errorf("expected no booted sims, got %v", got)
if !reflect.DeepEqual(got, []bootedSim(nil)) {
t.Errorf("bootedSims = %#v, want nil", got)
}
}
func TestRuntimeLabel(t *testing.T) {
cases := map[string]string{
"com.apple.CoreSimulator.SimRuntime.iOS-26-5": "iOS 26.5",
"com.apple.CoreSimulator.SimRuntime.watchOS-27-0": "watchOS 27.0",
"com.apple.CoreSimulator.SimRuntime.xrOS-27-0": "xrOS 27.0",
"com.apple.CoreSimulator.SimRuntime.iOS": "iOS", // no version suffix
"r": "r", // unrecognized shape passes through rather than vanishing
}
for in, want := range cases {
if got := runtimeLabel(in); got != want {
t.Errorf("runtimeLabel(%q) = %q, want %q", in, got, want)
}
}
}
// With 2+ booted, a silent pick drove the WRONG device while every tap printed
// ✓ (six calls, measured in a downstream project 2026-08-31). The refusal must
// name every candidate so the caller can retry with --udid in one step.
func TestPickBootedUDIDRefusesWhenSeveralBooted(t *testing.T) {
udid, err := pickBootedUDID([]byte(threeBootedTwoRuntimes))
if udid != "" {
t.Errorf("udid = %q, want empty — no device may be guessed", udid)
}
var amb *ambiguousSimError
if !errors.As(err, &amb) {
t.Fatalf("err = %v, want *ambiguousSimError", err)
}
want := "3 simulators are booted and no --udid was given; refusing to guess which one to drive. " +
"Re-run with --udid set to one of:\n" +
" AAAA iPhone 17 (iOS 26.5)\n" +
" DDDD iPhone Duo (iOS 27.1)\n" +
" ZZZZ iPhone 17 (iOS 27.0)"
if err.Error() != want {
t.Errorf("message =\n%s\nwant\n%s", err.Error(), want)
}
}
func TestResolveUDIDExplicitSkipsAmbiguityCheck(t *testing.T) {
// --udid is the escape hatch: it must never consult simctl. Asserted on the
// exec seam, not just the return value — otherwise deleting the early return
// would shell out to the developer's real simctl and the test would still pass.
calls := withFakeExec(t, threeBootedTwoRuntimes)
got, err := resolveUDID(context.Background(), "DDDD")
if err != nil || got != "DDDD" {
t.Errorf("resolveUDID(explicit) = %q, %v; want DDDD, nil", got, err)
}
if len(*calls) != 0 {
t.Errorf("ran %d subprocess(es), want none", len(*calls))
}
}
func TestDoctorExitCode(t *testing.T) {
cases := []struct {
axe, sim, works bool
want int
axe, sim, works, blocked bool
want int
}{
{true, true, true, 0},
{true, true, false, 2}, // present + booted but AXe can't load its frameworks
{false, true, true, 2},
{true, false, true, 2},
{false, false, false, 2},
{true, true, true, false, 0},
{true, true, false, false, 2}, // present + booted but AXe can't load its frameworks
{false, true, true, false, 2},
{true, false, true, false, 2},
{false, false, false, false, 2},
// doctor is the gate the docs call "exit 0 = ready". An environment where
// every device verb refuses (2+ booted, no --udid) or where AXe cannot take
// the tap style xcui sends is NOT ready, however healthy the rest looks.
{true, true, true, true, 2},
}
for _, c := range cases {
if got := doctorExitCode(c.axe, c.sim, c.works); got != c.want {
t.Errorf("doctorExitCode(%v,%v,%v) = %d, want %d", c.axe, c.sim, c.works, got, c.want)
if got := doctorExitCode(c.axe, c.sim, c.works, c.blocked); got != c.want {
t.Errorf("doctorExitCode(%v,%v,%v,%v) = %d, want %d", c.axe, c.sim, c.works, c.blocked, got, c.want)
}
}
}
func TestSimLabel(t *testing.T) {
// simctl names often already carry the OS version, so appending the runtime
// verbatim produced "iPhone 17 (27) (iOS 27.0)".
cases := []struct {
in bootedSim
want string
}{
{bootedSim{Name: "iPhone 17 (27)", Runtime: "iOS 27.0"}, "iPhone 17 (27)"},
{bootedSim{Name: "iPhone 17 (26.5)", Runtime: "iOS 26.5"}, "iPhone 17 (26.5)"},
{bootedSim{Name: "iPhone Duo", Runtime: "iOS 27.1"}, "iPhone Duo (iOS 27.1)"},
{bootedSim{Name: "iPad", Runtime: ""}, "iPad"},
}
for _, c := range cases {
if got := simLabel(c.in); got != c.want {
t.Errorf("simLabel(%+v) = %q, want %q", c.in, got, c.want)
}
}
}
+88 -23
View File
@@ -6,49 +6,114 @@ import (
"errors"
"fmt"
"sort"
"strings"
)
// execRun is the subprocess seam for simulator resolution, so a test can assert
// which paths shell out (and which must not) instead of hitting the real simctl.
var execRun = ExecRun
// errNoBootedSim signals exit code 2 (environment error) to callers.
var errNoBootedSim = errors.New("no booted simulator found — boot one with: xcrun simctl boot <device>")
// bootedUDIDs returns the UDIDs of every Booted device in `simctl list devices
// -j` output, sorted. Sorting matters: Go map iteration order is randomized, so
// without it the pick varies across runs when more than one sim is booted.
// Returns an empty slice (not an error) when none are booted.
func bootedUDIDs(listJSON []byte) ([]string, error) {
// bootedSim is one Booted device from `simctl list devices -j`. Runtime is the
// only field that tells same-named devices apart ("iPhone 17" on 26.5 and on 27).
type bootedSim struct {
UDID string `json:"udid"`
Name string `json:"name"`
Runtime string `json:"runtime,omitempty"`
}
// bootedSims returns every Booted device, sorted by UDID. Sorting matters: Go map
// iteration order is randomized, so without it the listing would reorder across
// runs. Returns an empty slice (not an error) when none are booted.
func bootedSims(listJSON []byte) ([]bootedSim, error) {
var parsed struct {
Devices map[string][]struct {
UDID string `json:"udid"`
State string `json:"state"`
Name string `json:"name"`
} `json:"devices"`
}
if err := json.Unmarshal(listJSON, &parsed); err != nil {
return nil, fmt.Errorf("parse simctl list: %w", err)
}
var booted []string
for _, devs := range parsed.Devices {
var booted []bootedSim
for runtime, devs := range parsed.Devices {
for _, d := range devs {
if d.State == "Booted" {
booted = append(booted, d.UDID)
booted = append(booted, bootedSim{UDID: d.UDID, Name: d.Name, Runtime: runtimeLabel(runtime)})
}
}
}
sort.Strings(booted)
sort.Slice(booted, func(i, j int) bool { return booted[i].UDID < booted[j].UDID })
return booted, nil
}
// pickBootedUDID returns the first booted device's UDID in deterministic
// (sorted) order, or errNoBootedSim. With more than one sim booted the choice
// is stable across runs; pass --udid to target a specific one.
// runtimeLabel turns "com.apple.CoreSimulator.SimRuntime.iOS-26-5" into "iOS 26.5".
// A key that doesn't carry the prefix is returned unchanged rather than dropped; one
// with the prefix but no version keeps just the platform, so a listing never shows a
// 36-character identifier where a name belongs.
func runtimeLabel(key string) string {
const prefix = "com.apple.CoreSimulator.SimRuntime."
if !strings.HasPrefix(key, prefix) {
return key
}
platform, version, ok := strings.Cut(strings.TrimPrefix(key, prefix), "-")
if !ok {
return platform
}
return platform + " " + strings.ReplaceAll(version, "-", ".")
}
// simLabel renders a device for a human-readable listing. simctl names frequently
// already carry the OS version ("iPhone 17 (27)"), so the runtime is appended only
// when it adds something — otherwise the line reads "iPhone 17 (27) (iOS 27.0)".
func simLabel(s bootedSim) string {
if s.Runtime == "" {
return s.Name
}
version := s.Runtime
if _, v, ok := strings.Cut(s.Runtime, " "); ok {
version = v
}
major, _, _ := strings.Cut(version, ".")
if strings.Contains(s.Name, "("+version+")") || strings.Contains(s.Name, "("+major+")") {
return s.Name
}
return s.Name + " (" + s.Runtime + ")"
}
// ambiguousSimError refuses to pick a device when more than one is booted and the
// caller named none. A silent pick drove the wrong simulator while every tap
// printed ✓, so the refusal lists each candidate for a one-step --udid retry.
type ambiguousSimError struct{ sims []bootedSim }
func (e *ambiguousSimError) Error() string {
var b strings.Builder
fmt.Fprintf(&b, "%d simulators are booted and no --udid was given; refusing to guess which one to drive. "+
"Re-run with --udid set to one of:", len(e.sims))
for _, s := range e.sims {
fmt.Fprintf(&b, "\n %s %s", s.UDID, simLabel(s))
}
return b.String()
}
// pickBootedUDID returns the only booted device's UDID. It returns errNoBootedSim
// when none is booted and *ambiguousSimError when several are.
func pickBootedUDID(listJSON []byte) (string, error) {
booted, err := bootedUDIDs(listJSON)
booted, err := bootedSims(listJSON)
if err != nil {
return "", err
}
if len(booted) == 0 {
switch len(booted) {
case 0:
return "", errNoBootedSim
case 1:
return booted[0].UDID, nil
default:
return "", &ambiguousSimError{sims: booted}
}
return booted[0], nil
}
// resolveUDID returns explicit if non-empty, else the booted simulator's UDID.
@@ -56,32 +121,32 @@ func resolveUDID(ctx context.Context, explicit string) (string, error) {
if explicit != "" {
return explicit, nil
}
res, err := ExecRun(ctx, 0, "xcrun", "simctl", "list", "devices", "-j")
res, err := execRun(ctx, 0, "xcrun", "simctl", "list", "devices", "-j")
if err != nil {
return "", fmt.Errorf("simctl list devices: %w", err)
}
return pickBootedUDID(res.Stdout)
}
// resolveBootedInfo returns the target UDID plus the full sorted list of booted
// UDIDs. An explicit udid short-circuits enumeration (returned with a nil list).
// doctor uses the list to warn when more than one sim is booted.
func resolveBootedInfo(ctx context.Context, explicit string) (udid string, booted []string, err error) {
// resolveBootedInfo returns the target UDID plus every booted device. An explicit
// udid short-circuits enumeration (returned with a nil list). Unlike resolveUDID it
// never refuses: doctor reports the booted set instead of acting on one device.
func resolveBootedInfo(ctx context.Context, explicit string) (udid string, booted []bootedSim, err error) {
if explicit != "" {
return explicit, nil, nil
}
res, err := ExecRun(ctx, 0, "xcrun", "simctl", "list", "devices", "-j")
res, err := execRun(ctx, 0, "xcrun", "simctl", "list", "devices", "-j")
if err != nil {
return "", nil, fmt.Errorf("simctl list devices: %w", err)
}
booted, err = bootedUDIDs(res.Stdout)
booted, err = bootedSims(res.Stdout)
if err != nil {
return "", nil, err
}
if len(booted) == 0 {
return "", nil, errNoBootedSim
}
return booted[0], booted, nil
return booted[0].UDID, booted, nil
}
// describeUI runs `axe describe-ui` for the resolved sim and parses the tree.
+12 -7
View File
@@ -10,13 +10,18 @@ type DoctorReport struct {
XcodePath string `json:"xcode_path,omitempty"`
// AxeDeveloperDir is the DEVELOPER_DIR xcui injects for AXe when the selected
// Xcode relocated SimulatorKit.framework (Xcode 27 beta). Empty when unneeded.
AxeDeveloperDir string `json:"axe_developer_dir,omitempty"`
BootedUDID string `json:"booted_udid,omitempty"`
Installed bool `json:"installed,omitempty"` // true if --install ran brew
OK bool `json:"ok"`
Note string `json:"note,omitempty"` // advisory (e.g. >1 sim booted); does not flip OK
Problems []string `json:"problems,omitempty"`
NextSteps []string `json:"next_steps,omitempty"`
AxeDeveloperDir string `json:"axe_developer_dir,omitempty"`
// BootedUDID is the device commands will target. It is empty when several are
// booted and no --udid was given, because nothing may be targeted in that state.
BootedUDID string `json:"booted_udid,omitempty"`
// Booted lists every booted device when xcui resolved them itself, so a caller
// can pick a --udid from JSON instead of parsing prose.
Booted []bootedSim `json:"booted,omitempty"`
Installed bool `json:"installed,omitempty"` // true if --install ran brew
OK bool `json:"ok"`
Note string `json:"note,omitempty"` // advisory; blockers go in Problems and flip OK
Problems []string `json:"problems,omitempty"`
NextSteps []string `json:"next_steps,omitempty"`
}
// WaitReport is the JSON payload of `xcui wait`.