mirror of
https://github.com/CharlesWiltgen/Axiom.git
synced 2026-09-20 19:58:20 +08:00
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:
Binary file not shown.
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user