Files
charleswiltgen__axiom/tools/xcui/cmd_input.go
T
Charles Wiltgen e996de9d79 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.
2026-09-19 15:43:23 -07:00

190 lines
7.7 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"io"
"os"
"regexp"
"sort"
"strings"
"time"
)
// axeInputVerbs are the AXe subcommands xcui forwards verbatim.
//
// Passthrough, NOT reimplementation: xcui parses none of AXe's flags, so these stay
// flag-compatible as AXe evolves and there is no per-flag surface to keep in sync.
// The point is that input goes through runAxe, which owns the SimulatorKit fallback —
// guidance that says "run `xcui tap`" cannot drift out of that handling the way
// guidance that says "run `axe tap`, and also remember a DEVELOPER_DIR prefix when
// doctor tells you to" can.
//
// Deliberately excluded: describe-ui (xcui reads the tree itself, via describeUI),
// list-simulators (xcui resolves the booted sim), and init (installs AXe's own skill
// files for other tools — nothing to do with driving a simulator).
var axeInputVerbs = map[string]string{
"tap": "Tap a point, or locate an element by accessibility and tap it",
"slider": "Set a slider to a value from 0 to 100",
"type": "Type a sequence of characters",
"swipe": "Swipe from one point to another",
"drag": "Point-to-point drag using explicit touch move events",
"button": "Press a hardware button",
"key": "Press a single key by keycode",
"key-sequence": "Press a sequence of keys by keycode",
"key-combo": "Press a key while holding modifiers",
"touch": "Touch down/up at specific coordinates",
"gesture": "Perform a preset gesture pattern",
"screenshot": "Capture the display as a PNG",
}
// inputTimeout bounds a single input verb. Generous: `type` on a long string and
// `gesture` are the slow ones, and a false timeout would look like a flaky tap.
const inputTimeout = 60 * time.Second
// axeVerbRe matches a subcommand line in `axe --help`: EXACTLY two leading spaces,
// then the verb. The exact indent is what separates a verb from a wrapped
// description line, which AXe indents to the description column (~26 spaces).
//
// A looser `\s{2,}…\s{2,}\S` was width-dependent: `axe --help` wraps to the terminal,
// so running it from a pipe rewrapped the text and the pattern matched the stray
// word "capture" off a continuation line while missing `record-video`. Anchor to
// structure, not to whatever width the help happened to wrap at.
var axeVerbRe = regexp.MustCompile(`(?m)^ {2}([a-z][a-z0-9-]*) `)
// unforwardedAxeVerbs reports AXe input verbs xcui does not forward.
//
// The allowlist is static, so a verb AXe adds reads as "unknown command" until an
// Axiom release — the passthrough silently lags the tool it fronts. doctor surfaces
// the drift instead of waiting for a user to hit it. Verbs xcui deliberately owns
// (describe-ui, list-simulators, init) are never reported.
func unforwardedAxeVerbs(axeHelp string) []string {
// xcui owns these (it reads the tree and resolves the sim itself); the streaming
// verbs are deliberately left bare because they outlive any request timeout.
// Neither group is drift, so neither is reported.
owned := map[string]bool{
"describe-ui": true, "list-simulators": true, "init": true, "help": true,
"stream-video": true, "record-video": true,
}
// Only the SUBCOMMANDS block: OPTIONS entries share the two-space indent.
if i := strings.Index(axeHelp, "SUBCOMMANDS:"); i >= 0 {
axeHelp = axeHelp[i:]
}
var missing []string
for _, m := range axeVerbRe.FindAllStringSubmatch(axeHelp, -1) {
v := m[1]
if owned[v] || axeInputVerbs[v] != "" {
continue
}
missing = append(missing, v)
}
sort.Strings(missing)
return missing
}
// runInput forwards an AXe input verb, injecting --udid when the caller omitted it so
// the common single-simulator case needs no bookkeeping. AXe's stdout/stderr and exit
// code pass through unchanged — callers parse AXe's output, not a re-rendering of it.
func runInput(out io.Writer, verb string, args []string) int {
// Only a LEADING --help is a help request. Scanning all of argv hijacked a
// literal one — `xcui type -- --help` types the string "--help" into a field —
// and swallowing the error made `xcui tap --help` exit 0 printing nothing when
// AXe is not installed.
if len(args) > 0 && (args[0] == "--help" || args[0] == "-h") {
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
}
return 0
}
ctx := context.Background()
if !hasFlag(args, "--udid") {
udid, err := resolveUDID(ctx, "")
if err != nil {
fmt.Fprintf(os.Stderr, "xcui %s: %v\n", verb, err)
return 2
}
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
}
// Propagate AXe's own exit code so callers can branch on it exactly as they
// would running axe directly — the passthrough must be transparent in the
// failure direction too, not just the success one.
//
// Matched on the ExitCode() capability rather than *exec.ExitError concretely:
// that covers any wrapper the exec layer may grow, and it is constructible in
// a test, which a real *exec.ExitError is not without spawning a process.
var coded interface{ ExitCode() int }
if errors.As(err, &coded) && coded.ExitCode() > 0 {
return coded.ExitCode()
}
return 1
}
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 {
for _, a := range args {
if a == name || (len(a) > len(name) && a[:len(name)+1] == name+"=") {
return true
}
}
return false
}