mirror of
https://github.com/CharlesWiltgen/Axiom.git
synced 2026-09-20 19:58:20 +08:00
e996de9d79
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.
160 lines
5.3 KiB
Go
160 lines
5.3 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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>")
|
|
|
|
// 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 []bootedSim
|
|
for runtime, devs := range parsed.Devices {
|
|
for _, d := range devs {
|
|
if d.State == "Booted" {
|
|
booted = append(booted, bootedSim{UDID: d.UDID, Name: d.Name, Runtime: runtimeLabel(runtime)})
|
|
}
|
|
}
|
|
}
|
|
sort.Slice(booted, func(i, j int) bool { return booted[i].UDID < booted[j].UDID })
|
|
return booted, nil
|
|
}
|
|
|
|
// 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 := bootedSims(listJSON)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
switch len(booted) {
|
|
case 0:
|
|
return "", errNoBootedSim
|
|
case 1:
|
|
return booted[0].UDID, nil
|
|
default:
|
|
return "", &ambiguousSimError{sims: booted}
|
|
}
|
|
}
|
|
|
|
// resolveUDID returns explicit if non-empty, else the booted simulator's UDID.
|
|
func resolveUDID(ctx context.Context, explicit string) (string, error) {
|
|
if explicit != "" {
|
|
return explicit, nil
|
|
}
|
|
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 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")
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("simctl list devices: %w", err)
|
|
}
|
|
booted, err = bootedSims(res.Stdout)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
if len(booted) == 0 {
|
|
return "", nil, errNoBootedSim
|
|
}
|
|
return booted[0].UDID, booted, nil
|
|
}
|
|
|
|
// describeUI runs `axe describe-ui` for the resolved sim and parses the tree.
|
|
func describeUI(ctx context.Context, udid string) ([]AXElement, error) {
|
|
res, err := runAxe(ctx, 0, "describe-ui", "--udid", udid)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("axe describe-ui: %w", err)
|
|
}
|
|
return parseDescribeUI(res.Stdout)
|
|
}
|