chore: rebuild the Codex, Cursor and MCP variants

Regenerated from the skill, agent, command and hook changes in the
preceding commits.
This commit is contained in:
Charles Wiltgen
2026-09-19 15:52:22 -07:00
parent 876d863a29
commit b0b4b03f5d
22 changed files with 3928 additions and 1557 deletions
+1 -1
View File
@@ -164,7 +164,7 @@ try:
---
**xcui** (scriptable sim UI & accessibility testing): Available at `{xcui_path}`. Drives the simulator via AXe + simctl. Run `xcui doctor` first (verifies AXe; `--install` adds it via brew). Key verbs: `xcui wait --for-element <id>`, `xcui assert --id <id> --label … --trait … --single`, `xcui a11y set --toggle <name> --value <on/off> --app <id>`, `xcui dialog accept|dismiss` (or `pregrant <bundle-id> <service>…`), `xcui voiceover traverse|assert --sequence <file>`. For taps use `axe tap --id <id>` directly. Workflow: `axiom-tools` (skills/xcui-ref.md). Command: `/axiom:ui`."""
**xcui** (scriptable sim UI & accessibility testing): Available at `{xcui_path}`. Drives the simulator via AXe + simctl. Run `xcui doctor` first (verifies AXe; `--install` adds it via brew). Key verbs: `xcui wait --for-element <id>`, `xcui assert --id <id> --label … --trait … --single`, `xcui a11y set --toggle <name> --value <on/off> --app <id>`, `xcui dialog accept|dismiss` (or `pregrant <bundle-id> <service>…`), `xcui voiceover traverse|assert --sequence <file>`. For taps use `xcui tap --id <id>` — it sends a physical touch; bare `axe tap` needs `--tap-style physical` or SwiftUI controls ignore it while still reporting success. Workflow: `axiom-tools` (skills/xcui-ref.md). Command: `/axiom:ui`."""
except OSError:
pass
+4 -2
View File
@@ -16,7 +16,9 @@ You are an expert at corpus-level production crash and hang triage. You fetch gr
## Single-Crash Escape Hatch
If the user has a **single** crash file (.ips, MetricKit, .crash, .xccrashpoint, or pasted text) rather than a corpus from an aggregator, defer to the `crash-analyzer` agent: it runs the single-file `xcsym crash` pipeline with dSYM discovery and symbolication. This agent is for corpus triage from Sentry / ASC only.
If the user has a **single** crash file (.ips, MetricKit, .crash, .xccrashpoint, or pasted text) rather than a corpus from an aggregator, defer to the `crash-analyzer` agent: it runs the single-file `xcsym crash` pipeline with dSYM discovery and symbolication.
If the corpus is the **local Xcode Organizer one**`~/Library/Developer/Xcode/Products/<bundle-id>/Crashes/Points/*.xccrashpoint` — read `axiom-shipping (skills/testflight-triage.md)`, "The On-Disk Organizer Corpus". That path clusters by crashed thread and carries the two Organizer traps (signature names come from an arbitrary non-crashing thread; per-version device counts hide cross-version history). This agent is for corpus triage from Sentry / ASC only.
## Workflow
@@ -162,4 +164,4 @@ Explicit command: `/axiom:triage [sentry|asc]`
## Scope
Fetches unresolved issues, normalizes them, runs `xcsym triage` to classify + cluster + flag suspension/idle-runloop noise, merges clusters into root-cause families, and produces a ranked report. For a **single crash file** (.ips, MetricKit, .crash, .xccrashpoint), use the crash-analyzer agent instead.
Fetches unresolved issues, normalizes them, runs `xcsym triage` to classify + cluster + flag suspension/idle-runloop noise, merges clusters into root-cause families, and produces a ranked report. For a **single crash file** (.ips, MetricKit, .crash, .xccrashpoint), use the crash-analyzer agent instead; for the **local Organizer corpus** of `.xccrashpoint` bundles, use `axiom-shipping (skills/testflight-triage.md)`.
@@ -38,9 +38,29 @@ Do NOT use this skill for:
Users expect "Shuffle," "Up Next," "Now Playing," "Smart Playlist" to mean exactly what they mean in Apple Music. Diverging from Apple's canonical translations feels wrong in every language — even if your translation is technically correct.
### Apple's own translations, on disk (most exact)
Every installed simulator runtime ships Apple's shipped `.strings` for its bundled apps — the same text users read in Music, Mail, and Settings. `.strings` there are binary plists, so read them with `plutil`:
```bash
RT=$(xcrun simctl list runtimes -j | python3 -c 'import json,sys; print([r["runtimeRoot"] for r in json.load(sys.stdin)["runtimes"] if r["platform"]=="iOS"][-1])')
ls "$RT/Applications/Music.app" | grep lproj # 56 locales in the 27.0 and 27.1 runtimes
plutil -p "$RT/Applications/Music.app/fr.lproj/Localizable.strings" | grep -i '"Songs"'
```
Music.app's keys are the English text, so grep the key and read the value: `Songs` is `Morceaux` in French and `노래` in Korean; `Shuffle` is `Aléatoire` and `임의 재생`. Some apps ship no `en.lproj` for exactly that reason — the key is the English.
The same runtime holds hundreds of **`AppShortcuts.strings`** files (753 in the iOS 27.0 runtime), which are Apple's own registered Siri phrases — the precedent to follow for phrase *structure*, not just vocabulary:
```bash
find "$RT" -name AppShortcuts.strings | head
```
Use this first when you need the exact form of a UI string. Use the Support pages below when you need what a user *says* out loud, which is a different register — the button may be `Lire` while the Siri verb is `mets`.
### Primary Sanity Check (Authoritative)
**Apple Support multi-locale pages** the authoritative source. Apple's help articles ship in every supported locale with hand-translated terminology. Fetch the same article across locales and compare:
**Apple Support multi-locale pages** — authoritative for spoken and user-facing phrasing. Apple's help articles ship in every supported locale with hand-translated terminology. Fetch the same article across locales and compare:
| Locale | URL pattern |
|--------|-------------|
@@ -90,6 +90,57 @@ Each entry has:
}
```
### Two key styles — and what a tool must read
An entry's key is either the English source text itself or a symbolic identifier. The first entry below has the English as its key; the second uses a symbolic key and carries the English in an explicit source unit.
```json
"Songs" : {
},
"quality.high" : {
"comment" : "Playback quality: 256 kbps",
"extractionState" : "extracted_with_value",
"localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "High" } } }
}
```
`String(localized: "Songs")` produces the first; `String(localized: "quality.high", defaultValue: "High")` produces the second, with `extracted_with_value` and an explicit source unit. So **the source string is `localizations[sourceLanguage]` if present, else the key** — a tool has to handle both, not pick one. (`sourceLanguage` is a top-level field and is not always `en`: in a catalog whose source is German the unit lands under `de`, so a tool that hardcodes `en` reads a translation as if it were the source.) A tool that reads only the source localization skips every key-as-English entry, and a search over keys alone misses every symbolic one.
### Writing a catalog from a tool
Xcode has one byte layout, and a generic JSON writer rewrites the whole file — measured on a real 970-key catalog, `json.dump(..., indent=2)` produced a **26,531-line diff** that buried the actual translation. The layout:
| Rule | Value |
|---|---|
| Indent | 2 spaces |
| Key separator | `" : "` — a space *before* the colon |
| Encoding | literal UTF-8, never `\u` escapes |
| Trailing newline | none |
| Empty entry | `{`, a blank line, then `}` at the entry's own indent |
| Keys inside an entry | sorted by code point |
| Keys of the top-level `strings` map | two writers, two orders — see below |
**The two Apple writers order the top-level keys differently.** `xcstringstool` sorts by code point. The Xcode editor's order matches Foundation's `localizedStandardCompare`, which is punctuation-aware: measured across two editor-written revisions of the same 1,086- and 1,447-key catalog, that comparison had 0 violations while plain code-point order had 139 and 212. So running `xcstringstool sync` over an editor-maintained catalog re-sorts every key — a 16,005-line diff on a file whose meaning did not change. Match whichever order the file already uses.
**`xcstringstool` ships with Xcode** and is the headless path in and out of a catalog. It is not on `PATH`; run it through `xcrun`:
```bash
xcrun xcstringstool extract --SwiftUI --modern-localizable-strings -o /tmp/sd $(find Sources -name '*.swift')
xcrun xcstringstool sync Localizable.xcstrings --stringsdata /tmp/sd/*.stringsdata --skip-marking-strings-stale
xcrun xcstringstool print Localizable.xcstrings # every key
```
(`Sources/**/*.swift` is not a substitute for that `find`: macOS ships bash 3.2, which has no `globstar`, so `**` silently matches one directory level.)
**`sync` prunes.** It is not an additive merge: every entry missing from the `.stringsdata` is marked `extractionState: stale` if it has translations and **deleted outright if it has none** — exit 0, no warning. Measured on a two-entry catalog: the untranslated entry vanished and the translated one came back stale. `--skip-marking-strings-stale` is the guard, and with it a 1,447-key catalog synced intact (122 untranslated entries all survived).
That makes the filename matter twice over: `sync` matches a `.stringsdata` table to the catalog **by filename**, so `Localizable.xcstrings` takes the `Localizable` table. Point it at `Strings.xcstrings` and nothing matches — so nothing is added, and without the guard flag every entry is pruned or staled.
`sync` re-emits the canonical layout only when it has something to write; a pure reformat with no source change is left exactly as it is, so it is not a repair tool for a bad write.
To write translations — which `sync` does not do — parse, mutate only the entries you mean to touch, and re-emit in the layout above, ordering the top-level keys the way the file already orders them. In JavaScript, `JSON.parse` also hoists integer-like keys (`"30"`) to the front, so a parse/stringify round-trip reorders the file on its own; parse into an order-preserving structure instead.
### Translation States
Xcode tracks state for each translation:
@@ -796,6 +847,28 @@ String(localized: "Confirm", comment: "Button to confirm delete action")
**Impact**: "Confirm" could mean "verify" or "acknowledge" - context matters for accurate translation.
### Expecting a comment to separate two meanings
A comment gives a translator context; it does **not** split a key. One English word used for two concepts is one entry with one translation, and the comments are merged:
```swift
String(localized: "High", comment: "Energy level of a track")
String(localized: "High", comment: "Playback quality: 256 kbps")
```
```json
"High" : { "comment" : "Energy level of a track\nPlayback quality: 256 kbps" }
```
`xcstringstool sync` says so — `notice: Key "High" used with multiple comments: …` — and then the catalog ships whichever translation the translator picked for both. Give each meaning its own key and keep the English in a default value:
```swift
String(localized: "energy.high", defaultValue: "High", comment: "Energy level of a track")
String(localized: "quality.high", defaultValue: "High", comment: "Playback quality: 256 kbps")
```
This bites hardest where a short word is reused across features — a picker label and a Siri phrase, say — because the wrong sense only shows up in the other feature's UI. Before renaming any enum title or button to a short common word, search the catalog for that exact key.
---
## Troubleshooting
+49 -2
View File
@@ -191,6 +191,35 @@ Built against the 27.1 SDK, navigation, toolbar, and tab bar items share one ver
- Keyboard accessory bars stay on the keyboard.
- The inner display in portrait keeps horizontal bars.
#### Host the bar in a system-managed container
Items join the vertical bar only from inside one of the system-managed containers above. The same `.bottomBar` items on a view with none of them — in a bare `fullScreenCover`, say — render as a horizontal capsule along the bottom, while `toolbarVerticalEdge` still reads `.trailing` (measured on the 27.1 Duo simulator, closed). Adding a `NavigationStack` to a custom full-screen layer brings two side effects:
- **The stack paints an opaque background** over whatever is behind it. Apply `.containerBackground(.clear, for: .navigation)` to the content *inside* the stack; applied to the `NavigationStack` itself it had no effect (both measured).
- **The stack gives its content the window's safe-area insets back**, even under an ancestor's `.ignoresSafeArea()` — measured trailing 84 pt and bottom 34 pt on the closed Duo, where the content outside the stack read zero. Re-apply `.ignoresSafeArea()` inside the stack if the layout depends on reading zero.
```swift
struct PlayerCover: View {
var body: some View {
ZStack {
ArtworkBackground()
NavigationStack {
PlayerControls()
.ignoresSafeArea()
.toolbar(.hidden, for: .navigationBar)
.containerBackground(.clear, for: .navigation) // inside the stack, not on it
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button("Shuffle", systemImage: "shuffle") { }
}
}
}
}
.ignoresSafeArea()
}
}
```
#### Order items top to bottom
1. Back (automatic in a navigation container) or a custom close. SwiftUI: `.cancellationAction`. UIKit: a leading item, with `leftItemsSupplementBackButton` left `false` (the default).
@@ -224,9 +253,16 @@ inboxItem.badge = .count(7) // iOS 26: a symbol-only item that still
- Vertical bars have no scroll-edge effect but gain a background under Reduce Transparency — keep custom content legible either way.
- A hero or background image extends under the vertical bar with `.backgroundExtensionEffect()` (SwiftUI) or `UIBackgroundExtensionView` (UIKit).
#### What your styling survives
- **System items ignore your styling.** In the vertical bar a system `Toggle` or `Button` item ignores `.foregroundStyle` and `.opacity`; a `Toggle` shows "on" as a lit disc. `.contextMenu` on a system `Toggle` item does nothing (measured on the 27.1 Duo simulator).
- **A custom view keeps it (measured).** Opt the item in with `.axisBehavior(.verticalPreferred)` and its own `.foregroundStyle`, `.opacity`, and `.contextMenu` all work in the bar — the route for on/off/unavailable states the system rendering can't show.
- **`Menu(primaryAction:)` shows no on/off state** in the bar (reported, not re-measured).
- **The overflow menu shows each item's title and icon**, taken from its `Label`, but **no on/off state and no context menu** (reported). If state matters once an item overflows, put it in the title ("Repeat One").
#### Plan for overflow
The outer display in landscape overflows most. Decide per view whether the toolbar or the tab bar compresses first — navigation-focused views keep their tabs, task-focused views keep their actions. By default the toolbar compresses first and the tabs stay; a task-focused view opts into keeping its actions (Axis, edge, compression, and the off switch). Merge your own overflow menu into the system one, keep the ellipsis for overflow only, and rank items with `visibilityPriority`: frequent actions and badged status items should collapse last. By default items overflow from the bottom up. A non-nil `additionalOverflowItems` always shows the overflow button. The keyboard and Picture in Picture in open portrait also shrink the bar.
The outer display in landscape overflows most. Decide per view whether the toolbar or the tab bar compresses first — navigation-focused views keep their tabs, task-focused views keep their actions. By default the toolbar compresses first and the tabs stay; a task-focused view opts into keeping its actions (Axis, edge, compression, and the off switch). Merge your own overflow menu into the system one, keep the ellipsis for overflow only, and rank items with `visibilityPriority`: frequent actions and badged status items should collapse last. By default items overflow from the bottom up; `.visibilityPriority(.high)` works on custom items too (measured: of ten custom items in a seven-slot bar, the last two marked `.high` stayed and the three above them overflowed). A non-nil `additionalOverflowItems` always shows the overflow button. The keyboard and Picture in Picture in open portrait also shrink the bar.
#### When to turn vertical bars off
@@ -236,6 +272,12 @@ A single-page, bottom-heavy layout like a calculator, or a sheet whose only item
The inferred axis is usually right — a title-only item stays horizontal, an item with an image goes vertical. Override it per item when a custom view, a wide control, or a symbol↔text toggle needs a specific axis. All of these are iOS 27.1; below that, items keep whatever axis the system infers, and the knobs don't exist. A build against the 27.0 SDK or earlier never sees a vertical bar at all.
`toolbarVerticalEdge` (UIKit: `traitCollection.verticalBarEdge`) reports the system's *preferred* edge "regardless of whether a vertical bar is currently visible", and is nil (`.unspecified`) where the system never places one — hardware without a vertical bar, or a size class or orientation that doesn't use it (UIKit header doc). What the value does and doesn't tell you:
- It is set before any toolbar item exists, and reads the same inside and outside a navigation container (measured).
- It is nil on the open inner display in portrait, half-folded included, and reads nil briefly during the opening transition (reported). Don't animate on a transient nil.
- A non-nil edge doesn't mean *your* items are vertical — outside a system-managed container they stay horizontal (Host the bar in a system-managed container).
```swift
// SwiftUI iOS 27.1: per-item axis override, compression order, and the edge read
@available(iOS 27.1, *)
@@ -257,7 +299,7 @@ struct BarControls: View {
}
.toolbarVerticalCompressionBehavior(.prefersToolbarItems) // tab bar compresses first
.overlay(alignment: .bottom) {
// .leading / .trailing while a bar is vertical; nil when items can't go vertical
// .leading / .trailing where the system places bars vertically; nil where it never does
Text(edge == .trailing ? "Bar: trailing" : edge == .leading ? "Bar: leading" : "No vertical bar")
.font(.caption)
}
@@ -530,6 +572,11 @@ Availability (`isAvailable`) and your on/off switch (`isEnabled`) are separate:
## Tooling and Testing
- **Device Hub** — Xcode 27.1's Device Hub drives an iPhone Duo simulator with open, close, rotate, and fold controls (111461 0:56); Apple's overview notes the Duo simulator in Device Hub requires Xcode 27.1. The device type creates against the **iOS 27.1 runtime**; the 27.0 runtime rejects it (`Incompatible device`). `iPhone Fold` is a different product.
- **The 27.1 runtime is Duo-only.** Creating iPhone 17, 17 Pro, 17e, or iPad Pro on it fails with the same `Incompatible device` (SimError 403), so keep the 27.0 runtime for every other device (measured with Xcode 27.1).
- **Poses are Device Hub-only.** No `simctl` or `devicectl` command opens, closes, or folds the simulator. `devicectl device motion hinge-angle` only *reads* the hinge, and `devicectl device orientation set` prints success and changes nothing on the Duo (measured closed; reported open). Check the window size after every pose change: closing from open-landscape has been reported landing in closed-portrait one time and closed-landscape another.
- **Screenshots default to the inner display**, which is black while the device is closed — for `simctl io … screenshot` and `devicectl device capture` alike. Capture the outer display by name: axiom-tools (skills/device-control-ref.md, Display masks and multi-display devices).
- **Name closed poses by the hinge**, never by "turned left/right". Closed and upright, the hinge is the left edge, the camera and status bar sit top-right, and the vertical bar runs down the trailing edge (`toolbarVerticalEdge == .trailing`, measured). With the hinge along the bottom the camera is top-left and the bar moves to the leading edge; hinge along the top puts the camera bottom-right and the bar trailing (reported). The bar follows the camera's edge.
- **Synthetic taps** — send a physical touch (`xcui tap` does; bare `axe tap` needs `--tap-style physical`), or the bar's items ignore the tap while it reports ✓: axiom-tools (skills/xcui-ref.md, Tap styles). The bar's overflow (⋯) button did not open under any tap style or a 0.21.0 s hold in the closed pose (measured); test overflowed actions in a pose with room for them.
- **Simulator gaps** — per the Xcode 27.1 beta release notes, StandBy is unavailable in the iPhone Duo Simulator runtime, and running and debugging most app extensions is unavailable there.
- **App Resizability** — Xcode's app-modernization agent skill, renamed "App Resizability", now covers SwiftUI and iPhone Duo (111461 9:15). See axiom-uikit (skills/uikit-modernization.md).
+15 -7
View File
@@ -34,13 +34,20 @@ xcrun simctl list devices -j | jq '.devices | to_entries[] | .value[] | select(.
# Check booted simulators
xcrun simctl list devices -j | jq '.devices | to_entries[] | .value[] | select(.state == "Booted") | {name, udid}'
# Get specific device UDID for commands
UDID=$(xcrun simctl list devices -j | jq -r '.devices | to_entries[] | .value[] | select(.state == "Booted") | .udid' | head -1)
# Get specific device UDID for commands — only when exactly ONE is booted.
# With several booted (other sessions often keep their own), choose by name from the
# list above; `head -1` silently drives whichever sorts first, and its taps still print ✓.
BOOTED=$(xcrun simctl list devices -j | jq -r '.devices | to_entries[] | .value[] | select(.state == "Booted") | .udid')
if [ "$(printf '%s\n' "$BOOTED" | grep -c .)" = 1 ]; then
UDID=$BOOTED
else
echo "Several simulators are booted — set UDID to the one you mean from the list above"
fi
# Boot if needed (get UDID first, then boot)
xcrun simctl boot "iPhone 16 Pro"
# Preflight AXe + booted sim with xcui doctor (AXe enables real HID tap/swipe/type/describe-ui)
# Preflight AXe + booted sim with xcui doctor (AXe enables tap/swipe/type/describe-ui)
if command -v axe &> /dev/null; then
echo "AXe available - UI automation enabled (tap, swipe, type, describe-ui)"
AXE_AVAILABLE=true
@@ -238,7 +245,8 @@ xcui doctor --install # installs cameroncooke/axe/axe via brew if missing
# Discover UI elements first (get accessibility identifiers)
axe describe-ui --udid $UDID
# Tap by accessibility identifier (RECOMMENDED - stable)
# Tap by accessibility identifier (RECOMMENDED - stable). xcui sends a physical
# touch; bare `axe tap` needs --tap-style physical or SwiftUI controls ignore it
xcui tap --id "loginButton" --udid $UDID
# Tap by label
@@ -247,8 +255,8 @@ xcui tap --label "Submit" --udid $UDID
# Tap at coordinates (less stable)
xcui tap -x 200 -y 400 --udid $UDID
# Long press
xcui tap -x 200 -y 400 --duration 1.0 --udid $UDID
# Long press (tap has no hold option; 1.2 s opens a .contextMenu)
xcui touch -x 200 -y 400 --down --up --delay 1.2 --udid $UDID
# Gesture presets
xcui gesture scroll-down --udid $UDID # Scroll content down
@@ -303,7 +311,7 @@ xcui a11y set --toggle reduce-transparency --value on --app com.example.App
xcui a11y set --toggle dynamic-type --value accessibility-extra-large
```
Supported `a11y set` toggles: `dynamic-type`, `increase-contrast`, `reduce-motion`, `reduce-transparency`. For taps, use `xcui tap --id <id>` (forwards to AXe's real HID touch). Full reference: `axiom-tools (skills/xcui-ref.md)`.
Supported `a11y set` toggles: `dynamic-type`, `increase-contrast`, `reduce-motion`, `reduce-transparency`, `voiceover`. For taps, use `xcui tap --id <id>` (a physical touch down/up). A tap prints ✓ whether or not anything happened, so assert the result with `xcui wait`/`xcui assert` rather than trusting the tap's output. With more than one simulator booted, pass `--udid` on every xcui command — xcui refuses to guess. Full reference: `axiom-tools (skills/xcui-ref.md)`.
### 16. Network Conditioning (low-bitrate / latency / loss)
+46 -5
View File
@@ -110,7 +110,8 @@ CI order is unchanged at the front: simctl or xcodebuild boots the sim → devic
|------------|--------------|-----|
| `device info displays` | works (verified) | bounds, pointScale, nativeSize, `framebufferMaskIdentifier` (exact JSON keys) |
| `device capture screenshot` / `screen-record` | works (verified) | PNG / H.264 `.mp4` capture, sim or device — see Screen capture below |
| `device orientation get` (also `set`, `rotate`) | works (`get` verified) | orientation without entering the app |
| `device orientation set` / `get` (also `rotate`) | works (verified) **except iPhone Duo**, see Rotating a simulator | rotate a sim headlessly; simctl has no rotate verb |
| `device motion hinge-angle` | works, read-only | streams the hinge angle (0° closed, 180° open) for 60 s by default; nothing in devicectl or simctl sets it |
| `device settings biometrics [--enable\|--disable]` | works (verified) | enroll / unenroll Face ID / Touch ID |
| `device simulate biometrics --success\|--failure` | works (verified) | drive a match / no-match |
| `device settings appearance --mode light\|dark` | works (verified) | force Dark/Light; also `--look-and-feel clear\|tinted`, text size, contrast |
@@ -143,6 +144,21 @@ ERROR: The capability "Get Lock State" is not supported by this device.
`info lockState` is confirmed device-only; `info files`, `copy`, and `profile *` are reported
device-only on simulators. In CI, treat `CoreDeviceError 1001` as "skip on simulator", not a failure.
### Rotating a simulator
`simctl` has no rotate verb, and keystroke automation of the GUI is no substitute — it reported success while rotating nothing. Rotate from the host with devicectl and verify by screenshot **dimensions**:
```bash
xcrun devicectl device orientation set -d "$SIM" landscapeLeft # portrait | portraitUpsideDown | landscapeLeft | landscapeRight
xcrun simctl io "$SIM" screenshot rotated.png # width and height must swap
xcrun devicectl device orientation set -d "$SIM" portrait
```
Verified 2026-09-19: iPhone 17 (iOS 27.0) 1206×2622 → 2622×1206, iPad Pro 11-inch (iOS 27.0) 1668×2420 → 2420×1668, and the app received the new size both times.
- **iPhone Duo ignores it.** `set` prints `New Device Orientation: landscapeLeft`, `get` still reads `portrait`, and nothing rotates — measured closed, and reported open on the inner display too. Duo poses change only through Device Hub's pose controls: axiom-swiftui (skills/iphone-duo.md, Tooling and Testing).
- **On iPad, only the host can rotate.** An app's own `requestGeometryUpdate` is refused on iPad under the windowing model (`UISceneErrorDomain` 101; axiom-uikit (skills/uikit-modernization.md)), so a test that needs landscape should rotate from the harness with the command above, not from inside the app.
## Screen capture — screenshot & video
`devicectl device capture` is the **unified** capture path: one `-d <udid>` selector across
@@ -162,7 +178,7 @@ xcrun devicectl device capture screen-record -d <udid> --destination clip.mp4 --
| Flag | screenshot | screen-record | Notes |
|------|------------|---------------|-------|
| `--destination` | `.png` only | `.mp4` only | wrong extension is a hard error, not a coercion |
| `--display-unique-id` | yes | yes | pick from `device info displays`; omit = primary display |
| `--display-unique-id` | yes | yes | the `uniqueId` from `device info displays`. Omitted, the capture is **not** always the primary display — on iPhone Duo it is the inner display, even while closed and dark |
| `--codec` | — | `h264` (default), `hevc` | |
| `--mask-policy` | — | `ignored` (default), `premultipliedAlpha`, `black` | bezel mask for non-rectangular displays |
| `--duration <s>` | — | auto-stop after N seconds | omit = record until SIGINT |
@@ -177,10 +193,33 @@ Reach for these only when devicectl capture doesn't fit — none reach a physica
| Tool | Use | Watch out |
|------|-----|-----------|
| `simctl io <udid> screenshot [--type png] <file>` | sim PNG; `-` writes to stdout | sim only |
| `simctl io <udid> recordVideo [--codec h264\|hevc] [--mask ignored\|alpha\|black] <file>` | sim video to a `.mov` | default codec is `hevc` (devicectl defaults `h264`); stop with SIGINT; sim only |
| `simctl io <udid> screenshot [--type png] [--display <port>] [--mask ignored\|alpha\|black] <file>` | sim PNG; `-` writes to stdout | sim only; `--display` takes a port UUID from `simctl io <udid> enumerate` (not devicectl's `uniqueId`) |
| `simctl io <udid> recordVideo [--codec h264\|hevc] [--mask ignored\|black] <file>` | sim video to a `.mov` | default codec is `hevc` (devicectl defaults `h264`); stop with SIGINT; sim only. `--mask alpha` is accepted but unsupported for video — it renders black |
| `axe record-video --output f.mp4` / `axe stream-video` | sim video / live preview stream (mjpeg, jpeg, ffmpeg, bgra) | sim only; `record-video` stops on Ctrl+C — see `axiom-xcode-mcp (skills/axe-ref.md)` |
### Display masks and multi-display devices
For non-rectangular displays, `simctl io … screenshot --mask` chooses what the corners hold:
| `--mask` | Corners | Use it to see |
|---|---|---|
| `alpha` | transparent (premultiplied alpha) | what the user sees on the glass |
| `ignored` | the full framebuffer rectangle | what a device screenshot captures — anything a view leaves under a corner lands in it |
| `black` | black, no alpha channel | a flat image for tools that drop alpha |
Compare `alpha` and `ignored` when judging content near a corner. The alpha channel is also a measurement: on iPhone Duo's outer display the fully transparent run along the top and bottom rows is 18 px at the left corners and 185 px at the right (iOS 27.1 simulator).
**A device with two displays needs the display named.** iPhone Duo's default capture — `simctl io` and `devicectl device capture` alike — is the inner display (2007×2853 px), which is black while the device is closed. Capture the outer display (1398×2034 px) explicitly:
```bash
xcrun simctl io "$SIM" enumerate # the outer display's port is the one 1398 wide
xcrun simctl io "$SIM" screenshot --display=<port UUID> outer.png
xcrun devicectl device info displays -d "$SIM" # outer is "LCD", inner "LCD-1"; shows which is active
xcrun devicectl device capture screenshot -d "$SIM" --display-unique-id <uniqueId> --destination outer.png
```
The two tools use different identifiers for the same display: simctl wants the **port UUID** from `enumerate`, devicectl the **`uniqueId`** from `info displays`. Guessing names fails — simctl rejects `--display=LCD-2`, and `2`, `4`, and `external` are accepted but hang without a frame.
## Status bar for screenshots
A shipping screenshot needs a clean status bar — 9:41, full bars, full battery. On a simulator
@@ -384,7 +423,9 @@ trying to kill is still running. Confirm against the process, not the exit code:
```bash
killall -9 Simulator DeviceHub
pgrep -l Simulator DeviceHub # must print NOTHING
``` CarPlay simulation moved with it, into DeviceKit's
```
CarPlay simulation moved with it, into DeviceKit's
`CarPlaySimulator.devicekitplugin`; the Xcode 26 `defaults write com.apple.iphonesimulator
CarPlayExtraOptions -bool YES` key does not exist anywhere in the 27 toolchain.
+30 -7
View File
@@ -1,6 +1,6 @@
# xcui Reference (Scriptable Simulator UI & Accessibility Testing)
xcui makes iOS-simulator UI and accessibility testing scriptable for coding harnesses. It owns the test-harness semantics AXe and simctl lack — waiting, asserting, accessibility config, dialogs, computed VoiceOver — and is also the front door for input: `xcui tap`/`type`/`swipe` forward to AXe verbatim, so you get AXe's real HID touch without having to manage its environment yourself.
xcui makes iOS-simulator UI and accessibility testing scriptable for coding harnesses. It owns the test-harness semantics AXe and simctl lack — waiting, asserting, accessibility config, dialogs, computed VoiceOver — and is also the front door for input: `xcui tap`/`type`/`swipe` forward to AXe, with a physical touch supplied for `tap` (see Tap styles), so input lands without you managing AXe's environment or flags yourself.
## Invocation
@@ -10,11 +10,13 @@ On **Codex, Pi, and MCP installs there is no bundled binary**: those install ski
If it is absent, you have two working options and should say which you took:
- Put it on PATH — clone the repo and symlink `.claude-plugin/plugins/axiom/bin/xcui` (see the Pi install guide). This is the only way to get `resize sweep`, `wait`, `assert`, `a11y`, `dialog`, and `voiceover`, which have no AXe equivalent.
- For **input only**, call AXe directly: `axe tap`/`type`/`swipe` take the same flags the `xcui` passthrough forwards, and AXe installs with `brew install cameroncooke/axe/axe`. You then own the `DEVELOPER_DIR` handling yourself — rare, since AXe 1.8.0 loads SimulatorKit under Xcode 27 unaided.
- For **input only**, call AXe directly: `axe tap`/`type`/`swipe` take the same flags the `xcui` passthrough forwards — add `--tap-style physical` to every `axe tap` (see Tap styles) — and AXe installs with `brew install cameroncooke/axe/axe`. You then own the `DEVELOPER_DIR` handling yourself — rare, since AXe 1.8.0 loads SimulatorKit under Xcode 27 unaided.
## Prerequisite: run `xcui doctor`
`xcui doctor` verifies AXe (the input/tree engine), Homebrew, Xcode, and a booted sim. If AXe is missing and brew is present, `xcui doctor --install` runs `brew install cameroncooke/axe/axe` (explicit/consented — never silent). Exit 0 = ready; exit 2 = AXe missing or no booted sim (see `problems`/`next_steps` in the JSON). When several sims are booted, every verb targets the lowest UDID deterministically; `doctor` adds a `note` listing them, and `--udid <id>` (accepted by every verb, `doctor` included) targets a specific one.
`xcui doctor` verifies AXe (the input/tree engine), Homebrew, Xcode, and a booted sim. If AXe is missing and brew is present, `xcui doctor --install` runs `brew install cameroncooke/axe/axe` (explicit/consented — never silent). Exit 0 = ready to drive; exit 2 = something would stop a device command — AXe missing, no booted sim, several booted with no `--udid`, or an AXe too old for the tap style xcui sends (AXe 1.7.0 added `--tap-style`). Read `problems`/`next_steps` in the JSON; `booted` lists every booted device, so a `--udid` can be picked from it without parsing prose.
**More than one booted sim → pass `--udid` on every command.** Without it, every device verb refuses with exit 2 and lists each booted sim's UDID, name, and runtime, so the retry is one step. It refuses rather than guessing because a guess drives the wrong device while every tap still prints ✓. With one booted sim, `--udid` is optional. `doctor` doesn't refuse — it reports the devices in `booted` and fails the gate, leaving `booted_udid` empty because nothing may be targeted in that state.
## Subcommands
@@ -27,7 +29,7 @@ If it is absent, you have two working options and should say which you took:
- `reduce-transparency``defaults write com.apple.Accessibility ReduceTransparencyEnabled`; needs relaunch (pass `--app`).
- `voiceover``devicectl device settings voiceover --enable|--disable`; `--value` is `on`/`off`. Applies live; no relaunch. The only toggle that leaves the simctl/defaults world — simctl has no VoiceOver setter. Read the state back with `xcrun devicectl device info voiceover -d <udid>`.
- `xcui a11y reset` — clear xcui-set overrides (delete the defaults keys, content_size → large, increase_contrast → disabled).
- `xcui dialog accept | dismiss [--udid <udid>]` — find the frontmost system alert and tap the right button: `accept` prefers the most-permissive standard grant (`Allow While Using App` `Allow Once` `Allow` `OK` `Open`), `dismiss` prefers the decline (`Don't Allow` `Cancel` `Not Now`). A one-button alert is tapped for either intent. Matching is case- and apostrophe-insensitive (curly `` = straight `'`). The tap delegates to `axe tap` (by id when present, else by label). Exit `0` handled, `1` no actionable alert.
- `xcui dialog accept | dismiss [--udid <udid>]` — find the frontmost system alert and tap the right button with a physical touch: `accept` prefers the most-permissive standard grant (`Allow While Using App` `Allow Once` `Allow` `OK` `Open`), `dismiss` prefers the decline (`Don't Allow` `Cancel` `Not Now`). A one-button alert is tapped for either intent. Matching is case- and apostrophe-insensitive (curly `` = straight `'`). The tap delegates to `axe tap` (by id when present, else by label). Exit `0` handled, `1` no actionable alert.
- `xcui dialog pregrant <bundle-id> <service>… [--udid <udid>]` — grant permissions ahead of time via `simctl privacy … grant`, so the dialog never appears. Services are `simctl privacy` names (`camera`, `photos`, `location`, `microphone`, `contacts`, …). Prefer this over `accept` when you control the test setup — no alert means nothing to race.
- `xcui voiceover traverse [--udid <udid>]` — emit the **computed** VoiceOver announcement sequence: walk the a11y tree in focus order (top-to-bottom, leading-to-trailing) and render each focusable element as `label, value, trait` (plus `dimmed` when disabled). Output is a `sequence` JSON array.
- `xcui voiceover assert --sequence <file> [--udid <udid>]` — compare the live announcement sequence to an expected one; the file may be a bare JSON string array **or** a saved `traverse` report (it round-trips). Reports every differing index (one entry per mismatched position, plus a length-mismatch note when counts differ); exit `1` on any mismatch.
@@ -70,16 +72,37 @@ Three `devicectl` failures are separated, because they need different fixes:
## Input — `xcui tap`, not `axe tap`
Input verbs forward to AXe verbatim: same flags, same output, same exit code. Use them instead of calling `axe` directly and the SimulatorKit/`DEVELOPER_DIR` handling comes along automatically, so guidance can't drift out of it.
Input verbs forward to AXe: the flags you pass go through untouched, with the same output and exit code. Use them instead of calling `axe` directly and the SimulatorKit/`DEVELOPER_DIR` handling and the physical tap style come along automatically, so guidance can't drift out of them.
Forwarded: `tap`, `slider`, `type`, `swipe`, `drag`, `touch`, `gesture`, `button`, `key`, `key-sequence`, `key-combo`, `screenshot`. `--udid` is injected when omitted. `xcui tap --help` shows AXe's own flags.
Forwarded: `tap`, `slider`, `type`, `swipe`, `drag`, `touch`, `gesture`, `button`, `key`, `key-sequence`, `key-combo`, `screenshot`. `--udid` is injected when omitted and exactly one sim is booted. `xcui tap --help` shows AXe's own flags.
```bash
xcui tap --id loginButton --udid <udid> # real HID touch, not pointer-hover
xcui tap --id loginButton --udid <udid> # physical touch down/up
xcui type "user@example.com" --udid <udid>
xcui touch -x 200 -y 400 --down --up --delay 1.2 --udid <udid> # long press — tap has no hold option
axe describe-ui --udid <udid> # raw a11y tree (xcui assert/wait parse this)
```
### Tap styles
AXe's `tap` takes `--tap-style automatic|simulator|physical`. AXe's default, `automatic`, sends a physical touch only to switches and toggles and FBSimulator `tapAt` to everything else — and `tapAt` activated none of the targets below:
| SwiftUI target | `automatic` | `simulator` | `physical` |
|---|---|---|---|
| `Toggle` / switch | fires (the one target `automatic` sends a physical touch to) | no effect | fires |
| `Button` | no effect | no effect | fires |
| `List` row (`NavigationLink`) | no effect | no effect | pushes |
| `Button` inside a `List` | no effect | no effect | fires |
| `Menu` | no effect | no effect | opens |
| Row of an open `Menu` | no effect | no effect | fires |
| Tab of a `TabView` (iPhone) | no effect | no effect | selects |
The switch row is AXe's documented behavior for `automatic` ("physical touch for switches/toggles and simulator tap for other targets"); the rest was measured 2026-09-19 with Xcode 27.1 and AXe 1.8.0 on iPhone 17 (iOS 27.0) and iPhone Duo (iOS 27.1) simulators, where AXe printed `✓ … completed successfully` for every no-effect cell. So `xcui tap` adds `--tap-style physical` when you pass no style (pass one to override), and `xcui dialog` taps physically. Calling `axe tap` yourself? Add `--tap-style physical`.
**Verify the effect, never the ✓.** A tap that changes nothing prints the same success line as one that works. After any tap that matters, confirm the result with `xcui wait --for-element`, `xcui assert`, or a screenshot.
**Selectors tap the accessibility frame, not the pixels.** `--id` and `--label` resolve to the element's accessibility activation point. When a view's accessibility frame is skewed — a label hidden with `.fixedSize()` plus `.frame(width: 0).clipped()` is one measured case, where the tap landed about 27 pt from the visible control — the tap misses and still prints ✓. Screenshot, measure the visible center, and tap `-x/-y` in points, or fix the view so its frame matches what it draws.
> **Still calling `axe` directly?** `describe-ui`, `stream-video`, and `record-video` stay bare — the first is what xcui itself parses, and the two streaming verbs outlive any request timeout. Those are the only calls that need a `DEVELOPER_DIR=` prefix, and only when `xcui doctor` reports an `axe_developer_dir` (rare — AXe 1.8.0 loads SimulatorKit under Xcode 27 unaided).
## Output & exit codes
@@ -147,6 +147,7 @@ You no longer own a fixed canvas — you express preferences the user and system
windowScene.sizeRestrictions?.minimumSize = CGSize(width: 400, height: 600)
```
- **Orientation lock** — a *preference*, not a guarantee, in resizable environments. Override `UIViewController.prefersInterfaceOrientationLocked` (returns `Bool`) and call `setNeedsUpdateOfPrefersInterfaceOrientationLocked()` when it changes; read the resolved state from `windowScene.effectiveGeometry.isInterfaceOrientationLocked` (iOS 26).
- **Requesting an orientation** — iPad refuses it under the windowing model. `windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .landscapeRight))` calls its `errorHandler` with `UISceneErrorDomain` code 101, "The current windowing mode does not allow for programmatic changes to interface orientation", and the scene keeps its size. The error handler is the only signal, so always pass one. The same request rotated an iPhone scene (measured on iPad Pro 11-inch and iPhone 17, iOS 27.0 simulators). On iPad, lay out for the size you're given instead of forcing an orientation.
- **Interactive vs settled resize** — `UIWindowSceneGeometry.isInteractivelyResizing` (iOS 26) is `true` while the user drags; throttle expensive work during the drag and settle when it clears. SwiftUI's equivalent is `.onInteractiveResizeChange(_:)` (see axiom-swiftui (skills/layout-ref.md)).
## iPhone Mirroring compatibility
+36 -26
View File
@@ -25,15 +25,17 @@ axe --version
axe describe-ui --udid $UDID
# 2. THEN: Tap by accessibility ID (preferred)
axe tap --id "loginButton" --udid $UDID
axe tap --id "loginButton" --tap-style physical --udid $UDID
# 3. OR: Tap by label
axe tap --label "Login" --udid $UDID
axe tap --label "Login" --tap-style physical --udid $UDID
# 4. LAST RESORT: Tap by coordinates from describe-ui output
axe tap -x 200 -y 400 --udid $UDID
axe tap -x 200 -y 400 --tap-style physical --udid $UDID
```
**Always pass `--tap-style physical`.** AXe's default style (`automatic`) sends FBSimulator `tapAt` to anything that isn't a switch, and on Xcode 27.1 + AXe 1.8.0 that activated no SwiftUI `Button`, `List` row, `Menu`, `Menu` row, or tab — while printing `✓ … completed successfully`. `physical` (touch down/up) activated all of them (measured 2026-09-19; matrix in `axiom-tools (skills/xcui-ref.md)`). `xcui tap` adds the flag for you.
**Priority order for targeting elements:**
1. `--id` (accessibilityIdentifier) - most stable
2. `--label` (accessibility label) - stable but may change with localization
@@ -45,50 +47,54 @@ axe tap -x 200 -y 400 --udid $UDID
```bash
# Coordinate-based (fragile - breaks with layout changes)
axe tap -x 200 -y 400 --udid $UDID
axe tap -x 200 -y 400 --tap-style physical --udid $UDID
# Accessibility-based (stable - survives UI changes)
axe tap --id "loginButton" --udid $UDID
axe tap --label "Login" --udid $UDID
axe tap --id "loginButton" --tap-style physical --udid $UDID
axe tap --label "Login" --tap-style physical --udid $UDID
```
**Always prefer `--id` or `--label` over coordinates.**
**Always prefer `--id` or `--label` over coordinates** — with one caveat: selectors tap the element's accessibility activation point, not its pixels. A view whose accessibility frame is skewed (a label hidden with `.fixedSize()` + `.frame(width: 0).clipped()`) gets tapped off target and the tap still prints ✓. When a selector tap changes nothing, screenshot, measure the visible center, and tap `-x/-y`.
## Getting the Simulator UDID
AXe requires the simulator UDID for most commands:
```bash
# Get booted simulator UDID
UDID=$(xcrun simctl list devices -j | jq -r '.devices | to_entries[] | .value[] | select(.state == "Booted") | .udid' | head -1)
# List booted simulators, then pick one by name — never `head -1`
xcrun simctl list devices booted
UDID=<UDID of the device you mean>
# List all simulators
axe list-simulators
```
With more than one simulator booted, a `head -1` pick drives whichever device sorts first, and every tap on it still prints ✓. `xcui` refuses to guess in that case; direct AXe calls need you to choose.
## Touch & Tap Commands
### Tap by Accessibility Identifier (Recommended)
```bash
# Tap element with accessibilityIdentifier
axe tap --id "loginButton" --udid $UDID
axe tap --id "loginButton" --tap-style physical --udid $UDID
# Tap element with accessibility label
axe tap --label "Submit" --udid $UDID
axe tap --label "Submit" --tap-style physical --udid $UDID
```
### Tap by Coordinates
```bash
# Basic tap
axe tap -x 200 -y 400 --udid $UDID
axe tap -x 200 -y 400 --tap-style physical --udid $UDID
# Tap with timing controls
axe tap -x 200 -y 400 --pre-delay 0.5 --post-delay 0.3 --udid $UDID
axe tap -x 200 -y 400 --tap-style physical --pre-delay 0.5 --post-delay 0.3 --udid $UDID
# Long press: use touch with --down --up --delay (tap has no hold option)
axe touch -x 200 -y 400 --down --up --delay 1.0 --udid $UDID
# Long press: use touch with --down --up --delay (tap has no hold option);
# a 1.2 s hold opened a SwiftUI .contextMenu
axe touch -x 200 -y 400 --down --up --delay 1.2 --udid $UDID
```
### Low-Level Touch Events
@@ -287,15 +293,15 @@ axe describe-ui --point 200,400 --udid $UDID
UDID=$(xcrun simctl list devices -j | jq -r '.devices | to_entries[] | .value[] | select(.state == "Booted") | .udid' | head -1)
# Tap email field and type
axe tap --id "emailTextField" --udid $UDID
axe tap --id "emailTextField" --tap-style physical --udid $UDID
axe type "user@example.com" --udid $UDID
# Tap password field and type
axe tap --id "passwordTextField" --udid $UDID
axe tap --id "passwordTextField" --tap-style physical --udid $UDID
axe type "password123" --udid $UDID
# Tap login button
axe tap --id "loginButton" --udid $UDID
axe tap --id "loginButton" --tap-style physical --udid $UDID
# Wait and screenshot
sleep 2
@@ -312,7 +318,7 @@ axe describe-ui --udid $UDID > /tmp/ui-tree.json
cat /tmp/ui-tree.json | jq '.[] | select(.identifier != null) | {identifier, label, type}'
# 3. Use discovered identifiers in automation
axe tap --id "discoveredIdentifier" --udid $UDID
axe tap --id "discoveredIdentifier" --tap-style physical --udid $UDID
```
### Scroll to Find Element
@@ -321,7 +327,7 @@ axe tap --id "discoveredIdentifier" --udid $UDID
# Scroll down until element appears (pseudo-code pattern)
for i in {1..5}; do
if axe describe-ui --udid $UDID | grep -q "targetElement"; then
axe tap --id "targetElement" --udid $UDID
axe tap --id "targetElement" --tap-style physical --udid $UDID
break
fi
axe gesture scroll-down --udid $UDID
@@ -333,7 +339,7 @@ done
```bash
# Automation with error capture
if ! axe tap --id "submitButton" --udid $UDID; then
if ! axe tap --id "submitButton" --tap-style physical --udid $UDID; then
axe screenshot --output /tmp/error-state.png --udid $UDID
axe describe-ui --udid $UDID > /tmp/error-ui-tree.json
echo "Failed to tap submitButton - see error-state.png"
@@ -353,7 +359,7 @@ Most commands support timing options:
```bash
# Example with full timing control
axe tap --id "button" --pre-delay 0.5 --post-delay 0.3 --udid $UDID
axe tap --id "button" --pre-delay 0.5 --post-delay 0.3 --tap-style physical --udid $UDID
```
## AXe vs simctl
@@ -385,13 +391,17 @@ axe tap --id "button" --pre-delay 0.5 --post-delay 0.3 --udid $UDID
### Tap Doesn't Work
1. Check element is enabled (`"enabled": true` in describe-ui)
2. Try adding `--pre-delay 0.5` for slow-loading UI
3. Verify correct UDID with `axe list-simulators`
A tap that changes nothing still prints `✓ … completed successfully`, so check in this order:
1. **Tap style** — add `--tap-style physical`. The default sends `tapAt`, which SwiftUI controls ignored in every case measured.
2. **Wrong device** — with more than one simulator booted, confirm `--udid` names the one on screen (`xcrun simctl list devices booted`).
3. **Skewed accessibility frame** — the selector resolved, but to a point off the visible control; tap measured `-x/-y` instead.
4. Check element is enabled (`"enabled": true` in describe-ui)
5. Try adding `--pre-delay 0.5` for slow-loading UI
### Type Not Working
1. Ensure text field is focused first: `axe tap --id "textField"`
1. Ensure text field is focused first: `axe tap --id "textField" --tap-style physical`
2. Check keyboard is visible
3. For unreliable input, split long strings into multiple `axe type` calls, or use `--stdin`/`--file`
+15 -7
View File
@@ -52,13 +52,20 @@ xcrun simctl list devices -j | jq '.devices | to_entries[] | .value[] | select(.
# Check booted simulators
xcrun simctl list devices -j | jq '.devices | to_entries[] | .value[] | select(.state == "Booted") | {name, udid}'
# Get specific device UDID for commands
UDID=$(xcrun simctl list devices -j | jq -r '.devices | to_entries[] | .value[] | select(.state == "Booted") | .udid' | head -1)
# Get specific device UDID for commands — only when exactly ONE is booted.
# With several booted (other sessions often keep their own), choose by name from the
# list above; `head -1` silently drives whichever sorts first, and its taps still print ✓.
BOOTED=$(xcrun simctl list devices -j | jq -r '.devices | to_entries[] | .value[] | select(.state == "Booted") | .udid')
if [ "$(printf '%s\n' "$BOOTED" | grep -c .)" = 1 ]; then
UDID=$BOOTED
else
echo "Several simulators are booted — set UDID to the one you mean from the list above"
fi
# Boot if needed (get UDID first, then boot)
xcrun simctl boot "iPhone 16 Pro"
# Preflight AXe + booted sim with xcui doctor (AXe enables real HID tap/swipe/type/describe-ui)
# Preflight AXe + booted sim with xcui doctor (AXe enables tap/swipe/type/describe-ui)
if command -v axe &> /dev/null; then
echo "AXe available - UI automation enabled (tap, swipe, type, describe-ui)"
AXE_AVAILABLE=true
@@ -256,7 +263,8 @@ xcui doctor --install # installs cameroncooke/axe/axe via brew if missing
# Discover UI elements first (get accessibility identifiers)
axe describe-ui --udid $UDID
# Tap by accessibility identifier (RECOMMENDED - stable)
# Tap by accessibility identifier (RECOMMENDED - stable). xcui sends a physical
# touch; bare `axe tap` needs --tap-style physical or SwiftUI controls ignore it
xcui tap --id "loginButton" --udid $UDID
# Tap by label
@@ -265,8 +273,8 @@ xcui tap --label "Submit" --udid $UDID
# Tap at coordinates (less stable)
xcui tap -x 200 -y 400 --udid $UDID
# Long press
xcui tap -x 200 -y 400 --duration 1.0 --udid $UDID
# Long press (tap has no hold option; 1.2 s opens a .contextMenu)
xcui touch -x 200 -y 400 --down --up --delay 1.2 --udid $UDID
# Gesture presets
xcui gesture scroll-down --udid $UDID # Scroll content down
@@ -321,7 +329,7 @@ xcui a11y set --toggle reduce-transparency --value on --app com.example.App
xcui a11y set --toggle dynamic-type --value accessibility-extra-large
```
Supported `a11y set` toggles: `dynamic-type`, `increase-contrast`, `reduce-motion`, `reduce-transparency`. For taps, use `xcui tap --id <id>` (forwards to AXe's real HID touch). Full reference: `axiom-tools (skills/xcui-ref.md)`.
Supported `a11y set` toggles: `dynamic-type`, `increase-contrast`, `reduce-motion`, `reduce-transparency`, `voiceover`. For taps, use `xcui tap --id <id>` (a physical touch down/up). A tap prints ✓ whether or not anything happened, so assert the result with `xcui wait`/`xcui assert` rather than trusting the tap's output. With more than one simulator booted, pass `--udid` on every xcui command — xcui refuses to guess. Full reference: `axiom-tools (skills/xcui-ref.md)`.
### 16. Network Conditioning (low-bitrate / latency / loss)
+4 -2
View File
@@ -26,7 +26,9 @@ You are an expert at corpus-level production crash and hang triage. You fetch gr
## Single-Crash Escape Hatch
If the user has a **single** crash file (.ips, MetricKit, .crash, .xccrashpoint, or pasted text) rather than a corpus from an aggregator, defer to the `crash-analyzer` agent: it runs the single-file the `axiom_xcsym_crash` MCP tool pipeline with dSYM discovery and symbolication. This agent is for corpus triage from Sentry / ASC only.
If the user has a **single** crash file (.ips, MetricKit, .crash, .xccrashpoint, or pasted text) rather than a corpus from an aggregator, defer to the `crash-analyzer` agent: it runs the single-file the `axiom_xcsym_crash` MCP tool pipeline with dSYM discovery and symbolication.
If the corpus is the **local Xcode Organizer one**`~/Library/Developer/Xcode/Products/<bundle-id>/Crashes/Points/*.xccrashpoint` — read `axiom-shipping (skills/testflight-triage.md)`, "The On-Disk Organizer Corpus". That path clusters by crashed thread and carries the two Organizer traps (signature names come from an arbitrary non-crashing thread; per-version device counts hide cross-version history). This agent is for corpus triage from Sentry / ASC only.
## Workflow
@@ -172,4 +174,4 @@ Explicit command: `/axiom-triage` [sentry|asc]
## Scope
Fetches unresolved issues, normalizes them, runs the `axiom_xcsym_triage` MCP tool to classify + cluster + flag suspension/idle-runloop noise, merges clusters into root-cause families, and produces a ranked report. For a **single crash file** (.ips, MetricKit, .crash, .xccrashpoint), use the crash-analyzer agent instead.
Fetches unresolved issues, normalizes them, runs the `axiom_xcsym_triage` MCP tool to classify + cluster + flag suspension/idle-runloop noise, merges clusters into root-cause families, and produces a ranked report. For a **single crash file** (.ips, MetricKit, .crash, .xccrashpoint), use the crash-analyzer agent instead; for the **local Organizer corpus** of `.xccrashpoint` bundles, use `axiom-shipping (skills/testflight-triage.md)`.
+19 -19
View File
@@ -133,8 +133,8 @@
},
{
"path": "agents/simulator-tester.md",
"sha256": "c140fa74f8286c1c2647e45513d20fe2deafb2991e84b3cce1c83aaa8fa9b71c",
"bytes": 21093
"sha256": "483a9c71738c1d194f7fa012a9d52f3f9a1a6f2455612a60efdc01b0ec308462",
"bytes": 21879
},
{
"path": "agents/spm-conflict-resolver.md",
@@ -213,8 +213,8 @@
},
{
"path": "agents/triage-analyzer.md",
"sha256": "0e51e4c59067e5b474314f21ac89d099832d3221c1f7e843fceafa480994b34d",
"bytes": 11205
"sha256": "ce8f4b35dfce8cb68e5fedb8b4da4929b5dc892f9fa93aeb36c9851c980cea45",
"bytes": 11733
},
{
"path": "agents/ux-flow-auditor.md",
@@ -978,13 +978,13 @@
},
{
"path": "skills/axiom-integration/skills/localization-research-ref.md",
"sha256": "5015b9224e965d04313ac382148de907cdd1f18ef214050ab8aa68ace332844e",
"bytes": 16589
"sha256": "2ca978fcc4ea1c736dba0f5de8f29a8fbb46c6707a0d08a181ae6e5b162a7d9e",
"bytes": 17999
},
{
"path": "skills/axiom-integration/skills/localization.md",
"sha256": "c60212fde0d8dfce4906a9cba9354b5933d2cedd28ed3e665f9e472beb1d5888",
"bytes": 34900
"sha256": "adc484b65485ada0c775d33e8c780e7cd9e69d6aea5e73e3d7018103dbac5054",
"bytes": 40365
},
{
"path": "skills/axiom-integration/skills/privacy-ux.md",
@@ -1613,8 +1613,8 @@
},
{
"path": "skills/axiom-swiftui/skills/iphone-duo.md",
"sha256": "e33bab2539cc6a9620c0f7ed3ce185bf4583549cdf0864a238297ed3651cb8fb",
"bytes": 35550
"sha256": "3d06b19fe4fc9b545efb6440ea3ac944ee01be9d5e4309146c67c48626952d4c",
"bytes": 41762
},
{
"path": "skills/axiom-swiftui/skills/layout-ref.md",
@@ -1708,8 +1708,8 @@
},
{
"path": "skills/axiom-tools/skills/device-control-ref.md",
"sha256": "f37e2904e862fa65ee49273f6befbc08bc538f6c2944e94f464d67a48641774f",
"bytes": 25848
"sha256": "e4a480ed405551c7fa865a1332c0c8a9de092cdba3a3acea58766416a28bb3ac",
"bytes": 29447
},
{
"path": "skills/axiom-tools/skills/getting-started.md",
@@ -1733,8 +1733,8 @@
},
{
"path": "skills/axiom-tools/skills/xcui-ref.md",
"sha256": "fdacecb2394054b8ba58234ea06f98fc8e8a3e2722ce2d61964f46dfb9620d7b",
"bytes": 11629
"sha256": "145d1f3934c0ea47b94295d4fd5c33e4585abf521632e1d3e954c4a19ccdd311",
"bytes": 14511
},
{
"path": "skills/axiom-uikit/SKILL.md",
@@ -1783,8 +1783,8 @@
},
{
"path": "skills/axiom-uikit/skills/uikit-modernization.md",
"sha256": "a74552e777e484862d59ef5774551788c353e8c8503ac1da0c73a286847a5f12",
"bytes": 26333
"sha256": "83b6fccada55a978e76e3ca7b99946d7470b922eb7e4b6472d58d151198b3b0a",
"bytes": 26924
},
{
"path": "skills/axiom-vision/SKILL.md",
@@ -1858,8 +1858,8 @@
},
{
"path": "skills/axiom-xcode-mcp/skills/axe-ref.md",
"sha256": "47b199c7d961758f36ec06f26a4f21b3a8c7b71d3f093432b41b64c2d5a8d6f6",
"bytes": 11562
"sha256": "b7cb79c3382494e787ab0db0054c8566c8e5791a9357c53b5da49cd0c27e0de4",
"bytes": 13419
},
{
"path": "skills/axiom-xcode-mcp/skills/xcode-mcp-ref.md",
@@ -1879,7 +1879,7 @@
],
"totals": {
"files": 375,
"bytes": 7589185
"bytes": 7612515
},
"excludedMirrors": 30,
"classes": {
@@ -38,9 +38,29 @@ Do NOT use this skill for:
Users expect "Shuffle," "Up Next," "Now Playing," "Smart Playlist" to mean exactly what they mean in Apple Music. Diverging from Apple's canonical translations feels wrong in every language — even if your translation is technically correct.
### Apple's own translations, on disk (most exact)
Every installed simulator runtime ships Apple's shipped `.strings` for its bundled apps — the same text users read in Music, Mail, and Settings. `.strings` there are binary plists, so read them with `plutil`:
```bash
RT=$(xcrun simctl list runtimes -j | python3 -c 'import json,sys; print([r["runtimeRoot"] for r in json.load(sys.stdin)["runtimes"] if r["platform"]=="iOS"][-1])')
ls "$RT/Applications/Music.app" | grep lproj # 56 locales in the 27.0 and 27.1 runtimes
plutil -p "$RT/Applications/Music.app/fr.lproj/Localizable.strings" | grep -i '"Songs"'
```
Music.app's keys are the English text, so grep the key and read the value: `Songs` is `Morceaux` in French and `노래` in Korean; `Shuffle` is `Aléatoire` and `임의 재생`. Some apps ship no `en.lproj` for exactly that reason — the key is the English.
The same runtime holds hundreds of **`AppShortcuts.strings`** files (753 in the iOS 27.0 runtime), which are Apple's own registered Siri phrases — the precedent to follow for phrase *structure*, not just vocabulary:
```bash
find "$RT" -name AppShortcuts.strings | head
```
Use this first when you need the exact form of a UI string. Use the Support pages below when you need what a user *says* out loud, which is a different register — the button may be `Lire` while the Siri verb is `mets`.
### Primary Sanity Check (Authoritative)
**Apple Support multi-locale pages** the authoritative source. Apple's help articles ship in every supported locale with hand-translated terminology. Fetch the same article across locales and compare:
**Apple Support multi-locale pages** — authoritative for spoken and user-facing phrasing. Apple's help articles ship in every supported locale with hand-translated terminology. Fetch the same article across locales and compare:
| Locale | URL pattern |
|--------|-------------|
@@ -90,6 +90,57 @@ Each entry has:
}
```
### Two key styles — and what a tool must read
An entry's key is either the English source text itself or a symbolic identifier. The first entry below has the English as its key; the second uses a symbolic key and carries the English in an explicit source unit.
```json
"Songs" : {
},
"quality.high" : {
"comment" : "Playback quality: 256 kbps",
"extractionState" : "extracted_with_value",
"localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "High" } } }
}
```
`String(localized: "Songs")` produces the first; `String(localized: "quality.high", defaultValue: "High")` produces the second, with `extracted_with_value` and an explicit source unit. So **the source string is `localizations[sourceLanguage]` if present, else the key** — a tool has to handle both, not pick one. (`sourceLanguage` is a top-level field and is not always `en`: in a catalog whose source is German the unit lands under `de`, so a tool that hardcodes `en` reads a translation as if it were the source.) A tool that reads only the source localization skips every key-as-English entry, and a search over keys alone misses every symbolic one.
### Writing a catalog from a tool
Xcode has one byte layout, and a generic JSON writer rewrites the whole file — measured on a real 970-key catalog, `json.dump(..., indent=2)` produced a **26,531-line diff** that buried the actual translation. The layout:
| Rule | Value |
|---|---|
| Indent | 2 spaces |
| Key separator | `" : "` — a space *before* the colon |
| Encoding | literal UTF-8, never `\u` escapes |
| Trailing newline | none |
| Empty entry | `{`, a blank line, then `}` at the entry's own indent |
| Keys inside an entry | sorted by code point |
| Keys of the top-level `strings` map | two writers, two orders — see below |
**The two Apple writers order the top-level keys differently.** `xcstringstool` sorts by code point. The Xcode editor's order matches Foundation's `localizedStandardCompare`, which is punctuation-aware: measured across two editor-written revisions of the same 1,086- and 1,447-key catalog, that comparison had 0 violations while plain code-point order had 139 and 212. So running `xcstringstool sync` over an editor-maintained catalog re-sorts every key — a 16,005-line diff on a file whose meaning did not change. Match whichever order the file already uses.
**`xcstringstool` ships with Xcode** and is the headless path in and out of a catalog. It is not on `PATH`; run it through `xcrun`:
```bash
xcrun xcstringstool extract --SwiftUI --modern-localizable-strings -o /tmp/sd $(find Sources -name '*.swift')
xcrun xcstringstool sync Localizable.xcstrings --stringsdata /tmp/sd/*.stringsdata --skip-marking-strings-stale
xcrun xcstringstool print Localizable.xcstrings # every key
```
(`Sources/**/*.swift` is not a substitute for that `find`: macOS ships bash 3.2, which has no `globstar`, so `**` silently matches one directory level.)
**`sync` prunes.** It is not an additive merge: every entry missing from the `.stringsdata` is marked `extractionState: stale` if it has translations and **deleted outright if it has none** — exit 0, no warning. Measured on a two-entry catalog: the untranslated entry vanished and the translated one came back stale. `--skip-marking-strings-stale` is the guard, and with it a 1,447-key catalog synced intact (122 untranslated entries all survived).
That makes the filename matter twice over: `sync` matches a `.stringsdata` table to the catalog **by filename**, so `Localizable.xcstrings` takes the `Localizable` table. Point it at `Strings.xcstrings` and nothing matches — so nothing is added, and without the guard flag every entry is pruned or staled.
`sync` re-emits the canonical layout only when it has something to write; a pure reformat with no source change is left exactly as it is, so it is not a repair tool for a bad write.
To write translations — which `sync` does not do — parse, mutate only the entries you mean to touch, and re-emit in the layout above, ordering the top-level keys the way the file already orders them. In JavaScript, `JSON.parse` also hoists integer-like keys (`"30"`) to the front, so a parse/stringify round-trip reorders the file on its own; parse into an order-preserving structure instead.
### Translation States
Xcode tracks state for each translation:
@@ -796,6 +847,28 @@ String(localized: "Confirm", comment: "Button to confirm delete action")
**Impact**: "Confirm" could mean "verify" or "acknowledge" - context matters for accurate translation.
### Expecting a comment to separate two meanings
A comment gives a translator context; it does **not** split a key. One English word used for two concepts is one entry with one translation, and the comments are merged:
```swift
String(localized: "High", comment: "Energy level of a track")
String(localized: "High", comment: "Playback quality: 256 kbps")
```
```json
"High" : { "comment" : "Energy level of a track\nPlayback quality: 256 kbps" }
```
`xcstringstool sync` says so — `notice: Key "High" used with multiple comments: …` — and then the catalog ships whichever translation the translator picked for both. Give each meaning its own key and keep the English in a default value:
```swift
String(localized: "energy.high", defaultValue: "High", comment: "Energy level of a track")
String(localized: "quality.high", defaultValue: "High", comment: "Playback quality: 256 kbps")
```
This bites hardest where a short word is reused across features — a picker label and a Siri phrase, say — because the wrong sense only shows up in the other feature's UI. Before renaming any enum title or button to a short common word, search the catalog for that exact key.
---
## Troubleshooting
+53 -2
View File
@@ -1,3 +1,7 @@
## Cursor UI Tool Availability
`xcui` is an external tool and is not bundled with the Cursor plugin; it has no Axiom MCP wrapper. Before UI automation, check `command -v xcui`. If it is absent, AXe fallback is limited to compatible input verbs: `tap`, `slider`, `type`, `swipe`, `drag`, `touch`, `gesture`, `button`, `key`, `key-sequence`, `key-combo`, and `screenshot`. Then check `command -v axe` before that fallback and handle `DEVELOPER_DIR` explicitly if AXe reports a SimulatorKit loading error. AXe cannot replace `wait`, `assert`, `a11y`, `dialog`, `voiceover`, `resize`, or `doctor`. If neither tool is available, stop UI automation, explain the external setup requirement, and continue only with non-UI simulator and log checks. If AXe exists but the requested workflow requires an xcui-only capability, stop that UI workflow and report the limitation.
# iPhone Duo
iPhone Duo is Apple's first two-display iPhone: an outer display, and a larger inner display that folds along a hinge. This skill covers adapting an app to it — the device model, what changes with the SDK you build against, vertical bars, the fold, and the new layout, hinge, and scene tools. SwiftUI comes first; the UIKit equivalent sits beside it.
@@ -191,6 +195,35 @@ Built against the 27.1 SDK, navigation, toolbar, and tab bar items share one ver
- Keyboard accessory bars stay on the keyboard.
- The inner display in portrait keeps horizontal bars.
#### Host the bar in a system-managed container
Items join the vertical bar only from inside one of the system-managed containers above. The same `.bottomBar` items on a view with none of them — in a bare `fullScreenCover`, say — render as a horizontal capsule along the bottom, while `toolbarVerticalEdge` still reads `.trailing` (measured on the 27.1 Duo simulator, closed). Adding a `NavigationStack` to a custom full-screen layer brings two side effects:
- **The stack paints an opaque background** over whatever is behind it. Apply `.containerBackground(.clear, for: .navigation)` to the content *inside* the stack; applied to the `NavigationStack` itself it had no effect (both measured).
- **The stack gives its content the window's safe-area insets back**, even under an ancestor's `.ignoresSafeArea()` — measured trailing 84 pt and bottom 34 pt on the closed Duo, where the content outside the stack read zero. Re-apply `.ignoresSafeArea()` inside the stack if the layout depends on reading zero.
```swift
struct PlayerCover: View {
var body: some View {
ZStack {
ArtworkBackground()
NavigationStack {
PlayerControls()
.ignoresSafeArea()
.toolbar(.hidden, for: .navigationBar)
.containerBackground(.clear, for: .navigation) // inside the stack, not on it
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button("Shuffle", systemImage: "shuffle") { }
}
}
}
}
.ignoresSafeArea()
}
}
```
#### Order items top to bottom
1. Back (automatic in a navigation container) or a custom close. SwiftUI: `.cancellationAction`. UIKit: a leading item, with `leftItemsSupplementBackButton` left `false` (the default).
@@ -224,9 +257,16 @@ inboxItem.badge = .count(7) // iOS 26: a symbol-only item that still
- Vertical bars have no scroll-edge effect but gain a background under Reduce Transparency — keep custom content legible either way.
- A hero or background image extends under the vertical bar with `.backgroundExtensionEffect()` (SwiftUI) or `UIBackgroundExtensionView` (UIKit).
#### What your styling survives
- **System items ignore your styling.** In the vertical bar a system `Toggle` or `Button` item ignores `.foregroundStyle` and `.opacity`; a `Toggle` shows "on" as a lit disc. `.contextMenu` on a system `Toggle` item does nothing (measured on the 27.1 Duo simulator).
- **A custom view keeps it (measured).** Opt the item in with `.axisBehavior(.verticalPreferred)` and its own `.foregroundStyle`, `.opacity`, and `.contextMenu` all work in the bar — the route for on/off/unavailable states the system rendering can't show.
- **`Menu(primaryAction:)` shows no on/off state** in the bar (reported, not re-measured).
- **The overflow menu shows each item's title and icon**, taken from its `Label`, but **no on/off state and no context menu** (reported). If state matters once an item overflows, put it in the title ("Repeat One").
#### Plan for overflow
The outer display in landscape overflows most. Decide per view whether the toolbar or the tab bar compresses first — navigation-focused views keep their tabs, task-focused views keep their actions. By default the toolbar compresses first and the tabs stay; a task-focused view opts into keeping its actions (Axis, edge, compression, and the off switch). Merge your own overflow menu into the system one, keep the ellipsis for overflow only, and rank items with `visibilityPriority`: frequent actions and badged status items should collapse last. By default items overflow from the bottom up. A non-nil `additionalOverflowItems` always shows the overflow button. The keyboard and Picture in Picture in open portrait also shrink the bar.
The outer display in landscape overflows most. Decide per view whether the toolbar or the tab bar compresses first — navigation-focused views keep their tabs, task-focused views keep their actions. By default the toolbar compresses first and the tabs stay; a task-focused view opts into keeping its actions (Axis, edge, compression, and the off switch). Merge your own overflow menu into the system one, keep the ellipsis for overflow only, and rank items with `visibilityPriority`: frequent actions and badged status items should collapse last. By default items overflow from the bottom up; `.visibilityPriority(.high)` works on custom items too (measured: of ten custom items in a seven-slot bar, the last two marked `.high` stayed and the three above them overflowed). A non-nil `additionalOverflowItems` always shows the overflow button. The keyboard and Picture in Picture in open portrait also shrink the bar.
#### When to turn vertical bars off
@@ -236,6 +276,12 @@ A single-page, bottom-heavy layout like a calculator, or a sheet whose only item
The inferred axis is usually right — a title-only item stays horizontal, an item with an image goes vertical. Override it per item when a custom view, a wide control, or a symbol↔text toggle needs a specific axis. All of these are iOS 27.1; below that, items keep whatever axis the system infers, and the knobs don't exist. A build against the 27.0 SDK or earlier never sees a vertical bar at all.
`toolbarVerticalEdge` (UIKit: `traitCollection.verticalBarEdge`) reports the system's *preferred* edge "regardless of whether a vertical bar is currently visible", and is nil (`.unspecified`) where the system never places one — hardware without a vertical bar, or a size class or orientation that doesn't use it (UIKit header doc). What the value does and doesn't tell you:
- It is set before any toolbar item exists, and reads the same inside and outside a navigation container (measured).
- It is nil on the open inner display in portrait, half-folded included, and reads nil briefly during the opening transition (reported). Don't animate on a transient nil.
- A non-nil edge doesn't mean *your* items are vertical — outside a system-managed container they stay horizontal (Host the bar in a system-managed container).
```swift
// SwiftUI iOS 27.1: per-item axis override, compression order, and the edge read
@available(iOS 27.1, *)
@@ -257,7 +303,7 @@ struct BarControls: View {
}
.toolbarVerticalCompressionBehavior(.prefersToolbarItems) // tab bar compresses first
.overlay(alignment: .bottom) {
// .leading / .trailing while a bar is vertical; nil when items can't go vertical
// .leading / .trailing where the system places bars vertically; nil where it never does
Text(edge == .trailing ? "Bar: trailing" : edge == .leading ? "Bar: leading" : "No vertical bar")
.font(.caption)
}
@@ -530,6 +576,11 @@ Availability (`isAvailable`) and your on/off switch (`isEnabled`) are separate:
## Tooling and Testing
- **Device Hub** — Xcode 27.1's Device Hub drives an iPhone Duo simulator with open, close, rotate, and fold controls (111461 0:56); Apple's overview notes the Duo simulator in Device Hub requires Xcode 27.1. The device type creates against the **iOS 27.1 runtime**; the 27.0 runtime rejects it (`Incompatible device`). `iPhone Fold` is a different product.
- **The 27.1 runtime is Duo-only.** Creating iPhone 17, 17 Pro, 17e, or iPad Pro on it fails with the same `Incompatible device` (SimError 403), so keep the 27.0 runtime for every other device (measured with Xcode 27.1).
- **Poses are Device Hub-only.** No `simctl` or `devicectl` command opens, closes, or folds the simulator. `devicectl device motion hinge-angle` only *reads* the hinge, and `devicectl device orientation set` prints success and changes nothing on the Duo (measured closed; reported open). Check the window size after every pose change: closing from open-landscape has been reported landing in closed-portrait one time and closed-landscape another.
- **Screenshots default to the inner display**, which is black while the device is closed — for `simctl io … screenshot` and `devicectl device capture` alike. Capture the outer display by name: axiom-tools (skills/device-control-ref.md, Display masks and multi-display devices).
- **Name closed poses by the hinge**, never by "turned left/right". Closed and upright, the hinge is the left edge, the camera and status bar sit top-right, and the vertical bar runs down the trailing edge (`toolbarVerticalEdge == .trailing`, measured). With the hinge along the bottom the camera is top-left and the bar moves to the leading edge; hinge along the top puts the camera bottom-right and the bar trailing (reported). The bar follows the camera's edge.
- **Synthetic taps** — send a physical touch (`xcui tap` does; bare `axe tap` needs `--tap-style physical`), or the bar's items ignore the tap while it reports ✓: axiom-tools (skills/xcui-ref.md, Tap styles). The bar's overflow (⋯) button did not open under any tap style or a 0.21.0 s hold in the closed pose (measured); test overflowed actions in a pose with room for them.
- **Simulator gaps** — per the Xcode 27.1 beta release notes, StandBy is unavailable in the iPhone Duo Simulator runtime, and running and debugging most app extensions is unavailable there.
- **App Resizability** — Xcode's app-modernization agent skill, renamed "App Resizability", now covers SwiftUI and iPhone Duo (111461 9:15). See axiom-uikit (skills/uikit-modernization.md).
+46 -5
View File
@@ -118,7 +118,8 @@ CI order is unchanged at the front: simctl or xcodebuild boots the sim → devic
|------------|--------------|-----|
| `device info displays` | works (verified) | bounds, pointScale, nativeSize, `framebufferMaskIdentifier` (exact JSON keys) |
| `device capture screenshot` / `screen-record` | works (verified) | PNG / H.264 `.mp4` capture, sim or device — see Screen capture below |
| `device orientation get` (also `set`, `rotate`) | works (`get` verified) | orientation without entering the app |
| `device orientation set` / `get` (also `rotate`) | works (verified) **except iPhone Duo**, see Rotating a simulator | rotate a sim headlessly; simctl has no rotate verb |
| `device motion hinge-angle` | works, read-only | streams the hinge angle (0° closed, 180° open) for 60 s by default; nothing in devicectl or simctl sets it |
| `device settings biometrics [--enable\|--disable]` | works (verified) | enroll / unenroll Face ID / Touch ID |
| `device simulate biometrics --success\|--failure` | works (verified) | drive a match / no-match |
| `device settings appearance --mode light\|dark` | works (verified) | force Dark/Light; also `--look-and-feel clear\|tinted`, text size, contrast |
@@ -151,6 +152,21 @@ ERROR: The capability "Get Lock State" is not supported by this device.
`info lockState` is confirmed device-only; `info files`, `copy`, and `profile *` are reported
device-only on simulators. In CI, treat `CoreDeviceError 1001` as "skip on simulator", not a failure.
### Rotating a simulator
`simctl` has no rotate verb, and keystroke automation of the GUI is no substitute — it reported success while rotating nothing. Rotate from the host with devicectl and verify by screenshot **dimensions**:
```bash
xcrun devicectl device orientation set -d "$SIM" landscapeLeft # portrait | portraitUpsideDown | landscapeLeft | landscapeRight
xcrun simctl io "$SIM" screenshot rotated.png # width and height must swap
xcrun devicectl device orientation set -d "$SIM" portrait
```
Verified 2026-09-19: iPhone 17 (iOS 27.0) 1206×2622 → 2622×1206, iPad Pro 11-inch (iOS 27.0) 1668×2420 → 2420×1668, and the app received the new size both times.
- **iPhone Duo ignores it.** `set` prints `New Device Orientation: landscapeLeft`, `get` still reads `portrait`, and nothing rotates — measured closed, and reported open on the inner display too. Duo poses change only through Device Hub's pose controls: axiom-swiftui (skills/iphone-duo.md, Tooling and Testing).
- **On iPad, only the host can rotate.** An app's own `requestGeometryUpdate` is refused on iPad under the windowing model (`UISceneErrorDomain` 101; axiom-uikit (skills/uikit-modernization.md)), so a test that needs landscape should rotate from the harness with the command above, not from inside the app.
## Screen capture — screenshot & video
`devicectl device capture` is the **unified** capture path: one `-d <udid>` selector across
@@ -170,7 +186,7 @@ xcrun devicectl device capture screen-record -d <udid> --destination clip.mp4 --
| Flag | screenshot | screen-record | Notes |
|------|------------|---------------|-------|
| `--destination` | `.png` only | `.mp4` only | wrong extension is a hard error, not a coercion |
| `--display-unique-id` | yes | yes | pick from `device info displays`; omit = primary display |
| `--display-unique-id` | yes | yes | the `uniqueId` from `device info displays`. Omitted, the capture is **not** always the primary display — on iPhone Duo it is the inner display, even while closed and dark |
| `--codec` | — | `h264` (default), `hevc` | |
| `--mask-policy` | — | `ignored` (default), `premultipliedAlpha`, `black` | bezel mask for non-rectangular displays |
| `--duration <s>` | — | auto-stop after N seconds | omit = record until SIGINT |
@@ -185,10 +201,33 @@ Reach for these only when devicectl capture doesn't fit — none reach a physica
| Tool | Use | Watch out |
|------|-----|-----------|
| `simctl io <udid> screenshot [--type png] <file>` | sim PNG; `-` writes to stdout | sim only |
| `simctl io <udid> recordVideo [--codec h264\|hevc] [--mask ignored\|alpha\|black] <file>` | sim video to a `.mov` | default codec is `hevc` (devicectl defaults `h264`); stop with SIGINT; sim only |
| `simctl io <udid> screenshot [--type png] [--display <port>] [--mask ignored\|alpha\|black] <file>` | sim PNG; `-` writes to stdout | sim only; `--display` takes a port UUID from `simctl io <udid> enumerate` (not devicectl's `uniqueId`) |
| `simctl io <udid> recordVideo [--codec h264\|hevc] [--mask ignored\|black] <file>` | sim video to a `.mov` | default codec is `hevc` (devicectl defaults `h264`); stop with SIGINT; sim only. `--mask alpha` is accepted but unsupported for video — it renders black |
| `axe record-video --output f.mp4` / `axe stream-video` | sim video / live preview stream (mjpeg, jpeg, ffmpeg, bgra) | sim only; `record-video` stops on Ctrl+C — see `axiom-xcode-mcp (skills/axe-ref.md)` |
### Display masks and multi-display devices
For non-rectangular displays, `simctl io … screenshot --mask` chooses what the corners hold:
| `--mask` | Corners | Use it to see |
|---|---|---|
| `alpha` | transparent (premultiplied alpha) | what the user sees on the glass |
| `ignored` | the full framebuffer rectangle | what a device screenshot captures — anything a view leaves under a corner lands in it |
| `black` | black, no alpha channel | a flat image for tools that drop alpha |
Compare `alpha` and `ignored` when judging content near a corner. The alpha channel is also a measurement: on iPhone Duo's outer display the fully transparent run along the top and bottom rows is 18 px at the left corners and 185 px at the right (iOS 27.1 simulator).
**A device with two displays needs the display named.** iPhone Duo's default capture — `simctl io` and `devicectl device capture` alike — is the inner display (2007×2853 px), which is black while the device is closed. Capture the outer display (1398×2034 px) explicitly:
```bash
xcrun simctl io "$SIM" enumerate # the outer display's port is the one 1398 wide
xcrun simctl io "$SIM" screenshot --display=<port UUID> outer.png
xcrun devicectl device info displays -d "$SIM" # outer is "LCD", inner "LCD-1"; shows which is active
xcrun devicectl device capture screenshot -d "$SIM" --display-unique-id <uniqueId> --destination outer.png
```
The two tools use different identifiers for the same display: simctl wants the **port UUID** from `enumerate`, devicectl the **`uniqueId`** from `info displays`. Guessing names fails — simctl rejects `--display=LCD-2`, and `2`, `4`, and `external` are accepted but hang without a frame.
## Status bar for screenshots
A shipping screenshot needs a clean status bar — 9:41, full bars, full battery. On a simulator
@@ -392,7 +431,9 @@ trying to kill is still running. Confirm against the process, not the exit code:
```bash
killall -9 Simulator DeviceHub
pgrep -l Simulator DeviceHub # must print NOTHING
``` CarPlay simulation moved with it, into DeviceKit's
```
CarPlay simulation moved with it, into DeviceKit's
`CarPlaySimulator.devicekitplugin`; the Xcode 26 `defaults write com.apple.iphonesimulator
CarPlayExtraOptions -bool YES` key does not exist anywhere in the 27 toolchain.
+30 -7
View File
@@ -4,7 +4,7 @@
# xcui Reference (Scriptable Simulator UI & Accessibility Testing)
xcui makes iOS-simulator UI and accessibility testing scriptable for coding harnesses. It owns the test-harness semantics AXe and simctl lack — waiting, asserting, accessibility config, dialogs, computed VoiceOver — and is also the front door for input: `xcui tap`/`type`/`swipe` forward to AXe verbatim, so you get AXe's real HID touch without having to manage its environment yourself.
xcui makes iOS-simulator UI and accessibility testing scriptable for coding harnesses. It owns the test-harness semantics AXe and simctl lack — waiting, asserting, accessibility config, dialogs, computed VoiceOver — and is also the front door for input: `xcui tap`/`type`/`swipe` forward to AXe, with a physical touch supplied for `tap` (see Tap styles), so input lands without you managing AXe's environment or flags yourself.
## Invocation
@@ -12,11 +12,13 @@ In Cursor, `xcui` is external and is not placed on `PATH` by the plugin. Check `
If it is absent, you have two working options and should say which you took:
- Put it on PATH — clone the repo and symlink `.claude-plugin/plugins/axiom/bin/xcui` (see the Pi install guide). This is the only way to get `resize sweep`, `wait`, `assert`, `a11y`, `dialog`, and `voiceover`, which have no AXe equivalent.
- For **input only**, call AXe directly: `axe tap`/`type`/`swipe` take the same flags the `xcui` passthrough forwards, and AXe installs with `brew install cameroncooke/axe/axe`. You then own the `DEVELOPER_DIR` handling yourself — rare, since AXe 1.8.0 loads SimulatorKit under Xcode 27 unaided.
- For **input only**, call AXe directly: `axe tap`/`type`/`swipe` take the same flags the `xcui` passthrough forwards — add `--tap-style physical` to every `axe tap` (see Tap styles) — and AXe installs with `brew install cameroncooke/axe/axe`. You then own the `DEVELOPER_DIR` handling yourself — rare, since AXe 1.8.0 loads SimulatorKit under Xcode 27 unaided.
## Prerequisite: run `xcui doctor`
`xcui doctor` verifies AXe (the input/tree engine), Homebrew, Xcode, and a booted sim. If AXe is missing and brew is present, `xcui doctor --install` runs `brew install cameroncooke/axe/axe` (explicit/consented — never silent). Exit 0 = ready; exit 2 = AXe missing or no booted sim (see `problems`/`next_steps` in the JSON). When several sims are booted, every verb targets the lowest UDID deterministically; `doctor` adds a `note` listing them, and `--udid <id>` (accepted by every verb, `doctor` included) targets a specific one.
`xcui doctor` verifies AXe (the input/tree engine), Homebrew, Xcode, and a booted sim. If AXe is missing and brew is present, `xcui doctor --install` runs `brew install cameroncooke/axe/axe` (explicit/consented — never silent). Exit 0 = ready to drive; exit 2 = something would stop a device command — AXe missing, no booted sim, several booted with no `--udid`, or an AXe too old for the tap style xcui sends (AXe 1.7.0 added `--tap-style`). Read `problems`/`next_steps` in the JSON; `booted` lists every booted device, so a `--udid` can be picked from it without parsing prose.
**More than one booted sim → pass `--udid` on every command.** Without it, every device verb refuses with exit 2 and lists each booted sim's UDID, name, and runtime, so the retry is one step. It refuses rather than guessing because a guess drives the wrong device while every tap still prints ✓. With one booted sim, `--udid` is optional. `doctor` doesn't refuse — it reports the devices in `booted` and fails the gate, leaving `booted_udid` empty because nothing may be targeted in that state.
## Subcommands
@@ -29,7 +31,7 @@ If it is absent, you have two working options and should say which you took:
- `reduce-transparency``defaults write com.apple.Accessibility ReduceTransparencyEnabled`; needs relaunch (pass `--app`).
- `voiceover``devicectl device settings voiceover --enable|--disable`; `--value` is `on`/`off`. Applies live; no relaunch. The only toggle that leaves the simctl/defaults world — simctl has no VoiceOver setter. Read the state back with `xcrun devicectl device info voiceover -d <udid>`.
- `xcui a11y reset` — clear xcui-set overrides (delete the defaults keys, content_size → large, increase_contrast → disabled).
- `xcui dialog accept | dismiss [--udid <udid>]` — find the frontmost system alert and tap the right button: `accept` prefers the most-permissive standard grant (`Allow While Using App` `Allow Once` `Allow` `OK` `Open`), `dismiss` prefers the decline (`Don't Allow` `Cancel` `Not Now`). A one-button alert is tapped for either intent. Matching is case- and apostrophe-insensitive (curly `` = straight `'`). The tap delegates to `axe tap` (by id when present, else by label). Exit `0` handled, `1` no actionable alert.
- `xcui dialog accept | dismiss [--udid <udid>]` — find the frontmost system alert and tap the right button with a physical touch: `accept` prefers the most-permissive standard grant (`Allow While Using App` `Allow Once` `Allow` `OK` `Open`), `dismiss` prefers the decline (`Don't Allow` `Cancel` `Not Now`). A one-button alert is tapped for either intent. Matching is case- and apostrophe-insensitive (curly `` = straight `'`). The tap delegates to `axe tap` (by id when present, else by label). Exit `0` handled, `1` no actionable alert.
- `xcui dialog pregrant <bundle-id> <service>… [--udid <udid>]` — grant permissions ahead of time via `simctl privacy … grant`, so the dialog never appears. Services are `simctl privacy` names (`camera`, `photos`, `location`, `microphone`, `contacts`, …). Prefer this over `accept` when you control the test setup — no alert means nothing to race.
- `xcui voiceover traverse [--udid <udid>]` — emit the **computed** VoiceOver announcement sequence: walk the a11y tree in focus order (top-to-bottom, leading-to-trailing) and render each focusable element as `label, value, trait` (plus `dimmed` when disabled). Output is a `sequence` JSON array.
- `xcui voiceover assert --sequence <file> [--udid <udid>]` — compare the live announcement sequence to an expected one; the file may be a bare JSON string array **or** a saved `traverse` report (it round-trips). Reports every differing index (one entry per mismatched position, plus a length-mismatch note when counts differ); exit `1` on any mismatch.
@@ -72,16 +74,37 @@ Three `devicectl` failures are separated, because they need different fixes:
## Input — `xcui tap`, not `axe tap`
Input verbs forward to AXe verbatim: same flags, same output, same exit code. Use them instead of calling `axe` directly and the SimulatorKit/`DEVELOPER_DIR` handling comes along automatically, so guidance can't drift out of it.
Input verbs forward to AXe: the flags you pass go through untouched, with the same output and exit code. Use them instead of calling `axe` directly and the SimulatorKit/`DEVELOPER_DIR` handling and the physical tap style come along automatically, so guidance can't drift out of them.
Forwarded: `tap`, `slider`, `type`, `swipe`, `drag`, `touch`, `gesture`, `button`, `key`, `key-sequence`, `key-combo`, `screenshot`. `--udid` is injected when omitted. `xcui tap --help` shows AXe's own flags.
Forwarded: `tap`, `slider`, `type`, `swipe`, `drag`, `touch`, `gesture`, `button`, `key`, `key-sequence`, `key-combo`, `screenshot`. `--udid` is injected when omitted and exactly one sim is booted. `xcui tap --help` shows AXe's own flags.
```bash
xcui tap --id loginButton --udid <udid> # real HID touch, not pointer-hover
xcui tap --id loginButton --udid <udid> # physical touch down/up
xcui type "user@example.com" --udid <udid>
xcui touch -x 200 -y 400 --down --up --delay 1.2 --udid <udid> # long press — tap has no hold option
axe describe-ui --udid <udid> # raw a11y tree (xcui assert/wait parse this)
```
### Tap styles
AXe's `tap` takes `--tap-style automatic|simulator|physical`. AXe's default, `automatic`, sends a physical touch only to switches and toggles and FBSimulator `tapAt` to everything else — and `tapAt` activated none of the targets below:
| SwiftUI target | `automatic` | `simulator` | `physical` |
|---|---|---|---|
| `Toggle` / switch | fires (the one target `automatic` sends a physical touch to) | no effect | fires |
| `Button` | no effect | no effect | fires |
| `List` row (`NavigationLink`) | no effect | no effect | pushes |
| `Button` inside a `List` | no effect | no effect | fires |
| `Menu` | no effect | no effect | opens |
| Row of an open `Menu` | no effect | no effect | fires |
| Tab of a `TabView` (iPhone) | no effect | no effect | selects |
The switch row is AXe's documented behavior for `automatic` ("physical touch for switches/toggles and simulator tap for other targets"); the rest was measured 2026-09-19 with Xcode 27.1 and AXe 1.8.0 on iPhone 17 (iOS 27.0) and iPhone Duo (iOS 27.1) simulators, where AXe printed `✓ … completed successfully` for every no-effect cell. So `xcui tap` adds `--tap-style physical` when you pass no style (pass one to override), and `xcui dialog` taps physically. Calling `axe tap` yourself? Add `--tap-style physical`.
**Verify the effect, never the ✓.** A tap that changes nothing prints the same success line as one that works. After any tap that matters, confirm the result with `xcui wait --for-element`, `xcui assert`, or a screenshot.
**Selectors tap the accessibility frame, not the pixels.** `--id` and `--label` resolve to the element's accessibility activation point. When a view's accessibility frame is skewed — a label hidden with `.fixedSize()` plus `.frame(width: 0).clipped()` is one measured case, where the tap landed about 27 pt from the visible control — the tap misses and still prints ✓. Screenshot, measure the visible center, and tap `-x/-y` in points, or fix the view so its frame matches what it draws.
> **Still calling `axe` directly?** `describe-ui`, `stream-video`, and `record-video` stay bare — the first is what xcui itself parses, and the two streaming verbs outlive any request timeout. Those are the only calls that need a `DEVELOPER_DIR=` prefix, and only when `xcui doctor` reports an `axe_developer_dir` (rare — AXe 1.8.0 loads SimulatorKit under Xcode 27 unaided).
## Output & exit codes
@@ -147,6 +147,7 @@ You no longer own a fixed canvas — you express preferences the user and system
windowScene.sizeRestrictions?.minimumSize = CGSize(width: 400, height: 600)
```
- **Orientation lock** — a *preference*, not a guarantee, in resizable environments. Override `UIViewController.prefersInterfaceOrientationLocked` (returns `Bool`) and call `setNeedsUpdateOfPrefersInterfaceOrientationLocked()` when it changes; read the resolved state from `windowScene.effectiveGeometry.isInterfaceOrientationLocked` (iOS 26).
- **Requesting an orientation** — iPad refuses it under the windowing model. `windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .landscapeRight))` calls its `errorHandler` with `UISceneErrorDomain` code 101, "The current windowing mode does not allow for programmatic changes to interface orientation", and the scene keeps its size. The error handler is the only signal, so always pass one. The same request rotated an iPhone scene (measured on iPad Pro 11-inch and iPhone 17, iOS 27.0 simulators). On iPad, lay out for the size you're given instead of forcing an orientation.
- **Interactive vs settled resize**`UIWindowSceneGeometry.isInteractivelyResizing` (iOS 26) is `true` while the user drags; throttle expensive work during the drag and settle when it clears. SwiftUI's equivalent is `.onInteractiveResizeChange(_:)` (see axiom-swiftui (skills/layout-ref.md)).
## iPhone Mirroring compatibility
+36 -26
View File
@@ -29,15 +29,17 @@ axe --version
axe describe-ui --udid $UDID
# 2. THEN: Tap by accessibility ID (preferred)
axe tap --id "loginButton" --udid $UDID
axe tap --id "loginButton" --tap-style physical --udid $UDID
# 3. OR: Tap by label
axe tap --label "Login" --udid $UDID
axe tap --label "Login" --tap-style physical --udid $UDID
# 4. LAST RESORT: Tap by coordinates from describe-ui output
axe tap -x 200 -y 400 --udid $UDID
axe tap -x 200 -y 400 --tap-style physical --udid $UDID
```
**Always pass `--tap-style physical`.** AXe's default style (`automatic`) sends FBSimulator `tapAt` to anything that isn't a switch, and on Xcode 27.1 + AXe 1.8.0 that activated no SwiftUI `Button`, `List` row, `Menu`, `Menu` row, or tab — while printing `✓ … completed successfully`. `physical` (touch down/up) activated all of them (measured 2026-09-19; matrix in `axiom-tools (skills/xcui-ref.md)`). `xcui tap` adds the flag for you.
**Priority order for targeting elements:**
1. `--id` (accessibilityIdentifier) - most stable
2. `--label` (accessibility label) - stable but may change with localization
@@ -49,50 +51,54 @@ axe tap -x 200 -y 400 --udid $UDID
```bash
# Coordinate-based (fragile - breaks with layout changes)
axe tap -x 200 -y 400 --udid $UDID
axe tap -x 200 -y 400 --tap-style physical --udid $UDID
# Accessibility-based (stable - survives UI changes)
axe tap --id "loginButton" --udid $UDID
axe tap --label "Login" --udid $UDID
axe tap --id "loginButton" --tap-style physical --udid $UDID
axe tap --label "Login" --tap-style physical --udid $UDID
```
**Always prefer `--id` or `--label` over coordinates.**
**Always prefer `--id` or `--label` over coordinates** — with one caveat: selectors tap the element's accessibility activation point, not its pixels. A view whose accessibility frame is skewed (a label hidden with `.fixedSize()` + `.frame(width: 0).clipped()`) gets tapped off target and the tap still prints ✓. When a selector tap changes nothing, screenshot, measure the visible center, and tap `-x/-y`.
## Getting the Simulator UDID
AXe requires the simulator UDID for most commands:
```bash
# Get booted simulator UDID
UDID=$(xcrun simctl list devices -j | jq -r '.devices | to_entries[] | .value[] | select(.state == "Booted") | .udid' | head -1)
# List booted simulators, then pick one by name — never `head -1`
xcrun simctl list devices booted
UDID=<UDID of the device you mean>
# List all simulators
axe list-simulators
```
With more than one simulator booted, a `head -1` pick drives whichever device sorts first, and every tap on it still prints ✓. `xcui` refuses to guess in that case; direct AXe calls need you to choose.
## Touch & Tap Commands
### Tap by Accessibility Identifier (Recommended)
```bash
# Tap element with accessibilityIdentifier
axe tap --id "loginButton" --udid $UDID
axe tap --id "loginButton" --tap-style physical --udid $UDID
# Tap element with accessibility label
axe tap --label "Submit" --udid $UDID
axe tap --label "Submit" --tap-style physical --udid $UDID
```
### Tap by Coordinates
```bash
# Basic tap
axe tap -x 200 -y 400 --udid $UDID
axe tap -x 200 -y 400 --tap-style physical --udid $UDID
# Tap with timing controls
axe tap -x 200 -y 400 --pre-delay 0.5 --post-delay 0.3 --udid $UDID
axe tap -x 200 -y 400 --tap-style physical --pre-delay 0.5 --post-delay 0.3 --udid $UDID
# Long press: use touch with --down --up --delay (tap has no hold option)
axe touch -x 200 -y 400 --down --up --delay 1.0 --udid $UDID
# Long press: use touch with --down --up --delay (tap has no hold option);
# a 1.2 s hold opened a SwiftUI .contextMenu
axe touch -x 200 -y 400 --down --up --delay 1.2 --udid $UDID
```
### Low-Level Touch Events
@@ -291,15 +297,15 @@ axe describe-ui --point 200,400 --udid $UDID
UDID=$(xcrun simctl list devices -j | jq -r '.devices | to_entries[] | .value[] | select(.state == "Booted") | .udid' | head -1)
# Tap email field and type
axe tap --id "emailTextField" --udid $UDID
axe tap --id "emailTextField" --tap-style physical --udid $UDID
axe type "user@example.com" --udid $UDID
# Tap password field and type
axe tap --id "passwordTextField" --udid $UDID
axe tap --id "passwordTextField" --tap-style physical --udid $UDID
axe type "password123" --udid $UDID
# Tap login button
axe tap --id "loginButton" --udid $UDID
axe tap --id "loginButton" --tap-style physical --udid $UDID
# Wait and screenshot
sleep 2
@@ -316,7 +322,7 @@ axe describe-ui --udid $UDID > /tmp/ui-tree.json
cat /tmp/ui-tree.json | jq '.[] | select(.identifier != null) | {identifier, label, type}'
# 3. Use discovered identifiers in automation
axe tap --id "discoveredIdentifier" --udid $UDID
axe tap --id "discoveredIdentifier" --tap-style physical --udid $UDID
```
### Scroll to Find Element
@@ -325,7 +331,7 @@ axe tap --id "discoveredIdentifier" --udid $UDID
# Scroll down until element appears (pseudo-code pattern)
for i in {1..5}; do
if axe describe-ui --udid $UDID | grep -q "targetElement"; then
axe tap --id "targetElement" --udid $UDID
axe tap --id "targetElement" --tap-style physical --udid $UDID
break
fi
axe gesture scroll-down --udid $UDID
@@ -337,7 +343,7 @@ done
```bash
# Automation with error capture
if ! axe tap --id "submitButton" --udid $UDID; then
if ! axe tap --id "submitButton" --tap-style physical --udid $UDID; then
axe screenshot --output /tmp/error-state.png --udid $UDID
axe describe-ui --udid $UDID > /tmp/error-ui-tree.json
echo "Failed to tap submitButton - see error-state.png"
@@ -357,7 +363,7 @@ Most commands support timing options:
```bash
# Example with full timing control
axe tap --id "button" --pre-delay 0.5 --post-delay 0.3 --udid $UDID
axe tap --id "button" --pre-delay 0.5 --post-delay 0.3 --tap-style physical --udid $UDID
```
## AXe vs simctl
@@ -389,13 +395,17 @@ axe tap --id "button" --pre-delay 0.5 --post-delay 0.3 --udid $UDID
### Tap Doesn't Work
1. Check element is enabled (`"enabled": true` in describe-ui)
2. Try adding `--pre-delay 0.5` for slow-loading UI
3. Verify correct UDID with `axe list-simulators`
A tap that changes nothing still prints `✓ … completed successfully`, so check in this order:
1. **Tap style** — add `--tap-style physical`. The default sends `tapAt`, which SwiftUI controls ignored in every case measured.
2. **Wrong device** — with more than one simulator booted, confirm `--udid` names the one on screen (`xcrun simctl list devices booted`).
3. **Skewed accessibility frame** — the selector resolved, but to a point off the visible control; tap measured `-x/-y` instead.
4. Check element is enabled (`"enabled": true` in describe-ui)
5. Try adding `--pre-delay 0.5` for slow-loading UI
### Type Not Working
1. Ensure text field is focused first: `axe tap --id "textField"`
1. Ensure text field is focused first: `axe tap --id "textField" --tap-style physical`
2. Check keyboard is visible
3. For unreliable input, split long strings into multiple `axe type` calls, or use `--stdin`/`--file`
+6 -6
View File
@@ -1,19 +1,19 @@
{
"totalBytes": 13783901,
"totalBytes": 13822076,
"skills": {
"count": 303,
"bytes": 7542485
"bytes": 7564177
},
"commands": {
"count": 17,
"bytes": 48591
"bytes": 49119
},
"agents": {
"count": 42,
"bytes": 700092
"bytes": 701425
},
"searchIndex": {
"bytes": 5492733
"bytes": 5507355
},
"generatedAt": "2026-09-19T17:39:49.204Z"
"generatedAt": "2026-09-19T22:27:11.517Z"
}
+3348 -1431
View File
File diff suppressed because one or more lines are too long