diff --git a/.claude-plugin/plugins/axiom/bin/xcui b/.claude-plugin/plugins/axiom/bin/xcui index 2c8ed660..25dfb0d6 100755 Binary files a/.claude-plugin/plugins/axiom/bin/xcui and b/.claude-plugin/plugins/axiom/bin/xcui differ diff --git a/tools/xcui/cmd_dialog.go b/tools/xcui/cmd_dialog.go index d6e5f10f..add6fdd5 100644 --- a/tools/xcui/cmd_dialog.go +++ b/tools/xcui/cmd_dialog.go @@ -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 } diff --git a/tools/xcui/cmd_dialog_test.go b/tools/xcui/cmd_dialog_test.go index 58a60eb8..50a6efb5 100644 --- a/tools/xcui/cmd_dialog_test.go +++ b/tools/xcui/cmd_dialog_test.go @@ -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) } diff --git a/tools/xcui/cmd_doctor.go b/tools/xcui/cmd_doctor.go index 692f224b..3e11bd19 100644 --- a/tools/xcui/cmd_doctor.go +++ b/tools/xcui/cmd_doctor.go @@ -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 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 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 ` 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) } diff --git a/tools/xcui/cmd_input.go b/tools/xcui/cmd_input.go index 1e0d4c1c..234e15a7 100644 --- a/tools/xcui/cmd_input.go +++ b/tools/xcui/cmd_input.go @@ -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 { diff --git a/tools/xcui/cmd_input_test.go b/tools/xcui/cmd_input_test.go index 2bc8b70b..48f27d76 100644 --- a/tools/xcui/cmd_input_test.go +++ b/tools/xcui/cmd_input_test.go @@ -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 ] [-y ] [--id ] [--tap-style ] --udid + +OPTIONS: + --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 ] [-y ] --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") + } +} diff --git a/tools/xcui/exec_test.go b/tools/xcui/exec_test.go index 2e98856c..e75ce4ec 100644 --- a/tools/xcui/exec_test.go +++ b/tools/xcui/exec_test.go @@ -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 +} diff --git a/tools/xcui/main.go b/tools/xcui/main.go index b2bb38fc..0898b5a9 100644 --- a/tools/xcui/main.go +++ b/tools/xcui/main.go @@ -22,7 +22,8 @@ Usage: xcui resize sweep --sizes [--screenshot-dir ] [--assert-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 --help' for per-command flags. ` diff --git a/tools/xcui/main_test.go b/tools/xcui/main_test.go index 2934c991..74690921 100644 --- a/tools/xcui/main_test.go +++ b/tools/xcui/main_test.go @@ -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) } } } diff --git a/tools/xcui/sim.go b/tools/xcui/sim.go index 84260a1e..a4f011cb 100644 --- a/tools/xcui/sim.go +++ b/tools/xcui/sim.go @@ -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 ") -// 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. diff --git a/tools/xcui/types.go b/tools/xcui/types.go index 37f2402a..8a4764a5 100644 --- a/tools/xcui/types.go +++ b/tools/xcui/types.go @@ -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`.