| name | simulator-control |
|---|---|
| description | Drive and inspect SwiftExampleApp on the booted iOS simulator end-to-end — tap, swipe, type, screenshot, read SwiftData, stream logs, dump the accessibility tree. Use when the user reports a UI bug, asks "why is X stuck?", wants a UAT run automated, or you need to verify the app's persisted state against what the UI shows. Covers both inspection (read-only via SwiftData + screenshots) AND control (UI automation via idb). |
| argument-hint | [describe | screenshot | tap-label <label> | tap-coord <x> <y> | type <text> | back | inspect <slot>] |
When the user is testing SwiftExampleApp in the iOS simulator, you can do everything they could do: tap buttons by label, type text, swipe, dump the accessibility tree, screenshot the screen, read the SwiftData store directly for ground truth, cross-check chain state against testnet. Use this together with the human-in-the-loop, not against them — confirm with them before destructive actions.
| Tool | Install | Why |
|---|---|---|
xcrun simctl |
Xcode CLT (already installed for iOS dev) | Screenshot, app container, openurl, logs |
sqlite3 |
macOS default | Read SwiftData default.store |
idb + idb_companion |
brew install facebook/fb/idb-companion + pipx install --python /opt/homebrew/bin/python3.12 fb-idb (fb-idb 1.1.7 uses asyncio.get_event_loop() which Python 3.14 dropped — must pin 3.12) |
Tap / swipe / type / accessibility tree |
curl + WebFetch |
builtin | Cross-check chain state via insight.testnet API |
Without idb the inspection workflows (screenshot + SwiftData + logs) still work; only control workflows are blocked.
# === Setup (once per session) ===
export PATH="$HOME/.local/bin:$PATH"
UDID=$(xcrun simctl list devices booted | awk -F'[()]' '/Booted/ {print $2}')
idb connect $UDID # starts idb_companion alongside the running sim
BUNDLE=org.dashfoundation.DashDeveloperPro
# === INSPECT ===
xcrun simctl io booted screenshot /tmp/sim.png # screenshot
idb ui describe-all --udid $UDID # accessibility tree (JSON)
idb ui describe-point --udid $UDID X Y # element under coord
# === CONTROL ===
idb ui tap --udid $UDID X Y # tap at coord
idb ui swipe --udid $UDID X1 Y1 X2 Y2 --duration 0.3 # swipe
idb ui text --udid $UDID "hello" # type text into focused field
idb ui key --udid $UDID 40 # short press keycode (40 = return)
idb ui button --udid $UDID HOME # hardware buttons: HOME, LOCK, SIRI, SIDE_BUTTON
# === APP ===
xcrun simctl launch booted $BUNDLE
xcrun simctl terminate booted $BUNDLE
xcrun simctl openurl booted "dashplatform://identity/abc123"
# === DATA ===
DATA=$(xcrun simctl get_app_container booted $BUNDLE data)
STORE="$DATA/Library/Application Support/default.store"
sqlite3 "$STORE" "SELECT ..."
# === LOGS ===
xcrun simctl spawn booted log show --last 60s --info \
--predicate 'processImagePath CONTAINS "SwiftExampleApp"'Don't hardcode pixel coords. Dump the accessibility tree, filter by AXLabel (exact or substring), tap the frame center. Works across iPhone models / orientations / SwiftUI layout tweaks.
tap_label() {
local label="$1"
local udid=$(xcrun simctl list devices booted | awk -F'[()]' '/Booted/ {print $2}')
# Tree → /tmp file, then python heredoc reads it. Heredoc avoids the
# env-var-propagation pitfall noted below; we still pass LABEL+UDID
# via the shell environment because heredoc doesn't take stdin.
"$HOME/.local/bin/idb" ui describe-all --udid "$udid" > /tmp/tap-label-tree.json 2>/dev/null
LABEL="$label" UDID="$udid" python3 << 'PY'
import json, os, subprocess
with open('/tmp/tap-label-tree.json') as f:
items = json.load(f)
label = os.environ['LABEL']
# Exact match first, fall back to substring.
match = next((it for it in items if it.get('AXLabel') == label and it.get('enabled')), None)
if not match:
match = next((it for it in items if label in (it.get('AXLabel') or '') and it.get('enabled')), None)
if not match:
raise SystemExit(f'no enabled element matching {label!r}')
fr = match['frame']
x, y = int(fr['x'] + fr['width']/2), int(fr['y'] + fr['height']/2)
subprocess.run(
[os.path.expanduser('~/.local/bin/idb'), 'ui', 'tap', '--udid', os.environ['UDID'], str(x), str(y)],
check=True,
)
print(f"tapped {match.get('AXLabel')!r} ({match.get('type')}) at ({x},{y})")
PY
}
tap_label "Resume"Use AXUniqueId instead of AXLabel when the UI sets one (more stable across localization). The back-navigation button in this app, for example, has AXUniqueId: "BackButton".
The app's default.store is a Core Data SQLite database. Tables are prefixed ZPERSISTENT* and columns Z*. Get the full list via sqlite3 "$STORE" ".tables". Most relevant:
| Table | Key columns | Purpose |
|---|---|---|
ZPERSISTENTASSETLOCK |
ZSTATUSRAW, ZIDENTITYINDEXRAW, ZOUTPOINTHEX, ZPROOFBYTES, ZWALLETID, ZAMOUNTDUFFS |
Tracked asset locks |
ZPERSISTENTIDENTITY |
ZIDENTITYINDEX, ZIDENTITYID, ZNETWORKRAW, ZWALLET |
Registered platform identities |
ZPERSISTENTWALLET |
ZWALLETID, ZNAME, ZNETWORKRAW, ZLASTAPPLIEDCHAINLOCKBYTES |
Local wallets; ZLASTAPPLIEDCHAINLOCKBYTES is the bincode-encoded ChainLock that powers the asset-lock-resume CL-from-metadata fast path on cold restart |
ZPERSISTENTACCOUNT |
ZACCOUNTTYPE, ZWALLET |
Per-wallet accounts |
ZPERSISTENTTXO |
ZWALLETID, ZTRANSACTION, ZSPENDINGTRANSACTION |
UTXOs, source of TransactionListView |
ZPERSISTENTTRANSACTION |
ZTXID, ZCONTEXT, ZTRANSACTIONTYPEKIND, ZFIRSTSEEN, ZBLOCKHEIGHT |
TXs; ZTRANSACTIONTYPEKIND is the typed discriminant byte (use this, NOT the parallel human-string ZTRANSACTIONTYPE, which is a #[derive(Debug)] repr and not a stable contract) |
ZPERSISTENTDOCUMENT |
ZDOCUMENTID, ZDATACONTRACT |
Documents |
Discriminants (mirror Rust enums):
ZSTATUSRAWon asset lock:0=Built,1=Broadcast,2=InstantSendLocked,3=ChainLocked,4=Consumed (terminal; the persisted row is retained for historical lookup but no longer fundable)ZCONTEXTon transaction:0=mempool,1=instantSend,2=inBlock,3=inChainLockedBlockZTRANSACTIONTYPEKINDon transaction:0=Standard,1=CoinJoin,2=ProviderRegistration,3=ProviderUpdateRegistrar,4=ProviderUpdateService,5=ProviderUpdateRevocation,6=AssetLock,7=AssetUnlock,8=Coinbase,9=Ignored,255(0xFF)=pre-feature sentinel (SwiftData default for rows persisted before the discriminant column was added; SPV's next upsert round backfills the real byte on touch)
Z_PK columns are integer foreign keys to the related table's primary key — stable for the install lifetime but NOT across re-installs. Quote ZIDENTITYID / ZOUTPOINTHEX / ZWALLETID blobs in any long-lived reference.
sqlite3 "$STORE" -header -column "
SELECT ZIDENTITYINDEXRAW AS slot, ZSTATUSRAW AS status,
ZAMOUNTDUFFS AS duffs, length(ZPROOFBYTES) AS proof_len,
length(ZTRANSACTIONBYTES) AS tx_len, ZOUTPOINTHEX
FROM ZPERSISTENTASSETLOCK
WHERE ZIDENTITYINDEXRAW = 10;"Then cross-check chain state — strip the :vout suffix to get the txid:
TXID=$(sqlite3 "$STORE" "SELECT substr(ZOUTPOINTHEX, 1, 64) FROM ZPERSISTENTASSETLOCK WHERE ZIDENTITYINDEXRAW = 10;")
curl -s "https://insight.testnet.networks.dash.org/insight-api/tx/$TXID" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print(f'block={d.get(\"blockheight\")} conf={d.get(\"confirmations\")} txlock={d.get(\"txlock\")}')"| SwiftData | On chain | Diagnosis |
|---|---|---|
| status 1, no proof | mined + txlock | SPV catch-up gap — signatures exist but the wallet hasn't backfilled them on app load |
| status 1, no proof | mined, no txlock | Pure timing — waiting for masternodes |
| status 1, no proof | not found / not mined | TX dropped or never confirmed |
| status 2/3, proof present | anything | UI should already be showing Resume |
| status 2/3, proof present, UI shows "Waiting…" | anything | UI reactivity bug — @Query not picking up update |
# 1. Snapshot SwiftData state
sqlite3 "$STORE" "SELECT ZIDENTITYINDEXRAW, ZSTATUSRAW FROM ZPERSISTENTASSETLOCK ORDER BY ZIDENTITYINDEXRAW;"
# 2. Force-quit + relaunch (simulates a crash)
xcrun simctl terminate booted $BUNDLE
xcrun simctl launch booted $BUNDLE
sleep 3
# 3. Take the Identities tab → Resumable Registrations → Resume row
xcrun simctl io booted screenshot /tmp/after-launch.png
tap_label "Identities"
sleep 1
tap_label "Resume" # taps the first Resume button in the visible accessibility tree
# 4. Verify the resume submit fires — read SwiftData a few times until ZSTATUSRAW goes 1->2->identity row appearsThe accessibility tree exposes truncated UI strings AND full underlying labels for Text(verbatim:) content. Use a substring match to find a row whose visible txid prefix is known:
LABEL="780ea9931" tap_label "$LABEL"idb ui describe-point --udid $UDID 200 400Returns the element at that coordinate — useful when an interactive area isn't where the visible layout suggests (e.g. SwiftUI Form hit-test boundaries on iOS 26).
# Tap the field first to focus it, then type.
tap_label "Amount"
idb ui text --udid $UDID "0.0025"
idb ui key --udid $UDID 40 # returniOS keycodes: 40=return, 42=backspace, 43=tab, 44=space, see Apple's HIDKeyboardKey table.
idb ui button --udid $UDID HOME # go to springboard
idb ui button --udid $UDID LOCK # lock screen
idb ui button --udid $UDID SIDE_BUTTON # side button
idb ui button --udid $UDID SIRI # invoke Sirixcrun simctl io booted screenshot /tmp/before.png
tap_label "Resume"
sleep 1
xcrun simctl io booted screenshot /tmp/after.png
# Compare with magick or by reading both images into Claude.xcrun simctl spawn booted log stream --info \
--predicate 'processImagePath CONTAINS "SwiftExampleApp"' > /tmp/applog.txt 2>&1 &
LOG_PID=$!
# ... drive UI via idb / let the user act ...
sleep 5
kill $LOG_PID
grep -iE "error|panic|fatal|💥|⚠️" /tmp/applog.txtWhen you've kicked off an async operation and want to wait for the UI/SwiftData to confirm it:
for i in {1..30}; do
status=$(sqlite3 "$STORE" "SELECT ZSTATUSRAW FROM ZPERSISTENTASSETLOCK WHERE ZIDENTITYINDEXRAW = 10;")
echo "[$i] status=$status"
[ "$status" -ge 2 ] && break
sleep 2
doneDon't always wipe before testing a bug fix. Rows created by the buggy version of the code are direct evidence of the pre-fix state. Run the user-facing flow once after the fix and contrast the new row against the existing ones — that's a stronger proof than a clean-slate happy path.
Pattern (worked end-to-end on this PR's R2/R4 fix — consume_asset_lock not persisting Consumed):
# 1. Snapshot the histogram before the test
sqlite3 "$STORE" "SELECT ZSTATUSRAW, COUNT(*) FROM ZPERSISTENTASSETLOCK GROUP BY ZSTATUSRAW;"
# → e.g. 20 rows at status 2/3, 0 at status 4 — visible evidence of the old bug.
# 2. Drive ONE registration through the fresh UI
# (idb taps … see Workflow B)
# 3. Snapshot again, contrast within the same store
sqlite3 "$STORE" -header -column "
SELECT ZIDENTITYINDEXRAW AS slot, ZSTATUSRAW AS status
FROM ZPERSISTENTASSETLOCK
ORDER BY ZIDENTITYINDEXRAW;"
# → the new slot should be the only row at the post-fix status.Within-store contrast eliminates a class of "did I really install the new build?" doubts — if the histogram changed for the row you just created but not for the 20 pre-existing ones, the new code is provably running.
Run before any session that needs UI control:
export PATH="$HOME/.local/bin:$PATH"
which idb || { echo "install: brew install facebook/fb/idb-companion && pipx install --python /opt/homebrew/bin/python3.12 fb-idb"; exit 1; }
UDID=$(xcrun simctl list devices booted | awk -F'[()]' '/Booted/ {print $2}')
[ -z "$UDID" ] && { echo "no booted sim — boot one in Xcode or via 'xcrun simctl boot <udid>'"; exit 1; }
idb connect $UDID 2>&1 | grep -q "udid:" || { echo "idb companion not reachable"; exit 1; }
echo "ready: UDID=$UDID"If idb connect hangs, clear stale companion processes: pkill -f idb_companion then re-run.
If idb connect succeeds but idb ui describe-all returns a single root element with empty bounds ({{0, 0}, {0, 0}}) — companion is connected but desynced from the simulator UI tree. Same fix as the hang case: pkill -f idb_companion && idb connect $UDID. A successful re-connection shows the real app frame (e.g. {{0, 0}, {402, 874}} for iPhone 17 Pro) as the root element.
The skill assumes the binary on the simulator is current. It's not, if you've built but forgotten to install. After every ./build_ios.sh --target sim (or any code change), push the fresh artifact:
BUNDLE=org.dashfoundation.DashDeveloperPro
# The .app on disk is named after PRODUCT_NAME (still "SwiftExampleApp"), which
# differs from the bundle id — find by the product name, launch by the bundle id.
APP=$(find ~/Library/Developer/Xcode/DerivedData -name "SwiftExampleApp.app" -path "*Debug-iphonesimulator*" -not -path "*Index.noindex*" 2>/dev/null | head -1)
xcrun simctl install booted "$APP"
xcrun simctl launch booted "$BUNDLE" # or terminate-then-launch to force a fresh processWithout this step, idb taps still hit the OLD binary's UI and your verification is meaningless. Pair with a clean git status + git log -1 check before running any post-fix manual test pass.
- Data container path changes per install. Always use
xcrun simctl get_app_containerto locate the SwiftData store — never hardcode the UUID. - fb-idb 1.1.7 + Python 3.14 = broken. Pin to Python 3.12 via
pipx install --python /opt/homebrew/bin/python3.12 fb-idb. The error isRuntimeError: There is no current event loop in thread 'MainThread'. getpwuid_r did not find a matchstderr noise fromxcrun simctl spawn booted log ...is harmless; logs still stream.- Multiple booted simulators — pass
--udidexplicitly.xcrun simctl list devices bootedmay pick a different one than the user expects. describe-allreturns disabled / non-interactive elements too — filter onenabled == trueandrole in {AXButton, AXTextField, AXLink}for actions, ortype == "Cell"for list rows.AXLabelis not unique — multiple "Confirmed" badges, multiple chevrons. When ambiguous, narrow byframe.yrange or by walking the tree near a known parent. PreferAXUniqueIdwhen set.- Tap coordinates are in points, not pixels —
describe-all's frames matchxcrun simctl io screenshotcoords directly (no scaling). - Sheet / modal presentations can change the accessibility tree drastically — always
describe-allagain after a navigation, don't cache element coords across screens. - Popover / menu items only appear after the parent button is tapped. SwiftUI
Menu { … }andUIMenuchildren are NOT in the flat tree until the popover is presented. After tapping the parent (+button, dropdown chevron, etc.), re-runidb ui describe-allto capture the menu's children before tapping into them. - Status-bar override for clean screenshots:
xcrun simctl status_bar booted override --time "9:41"andxcrun simctl status_bar booted clearto reset. - Don't tap on a screen the user is mid-interaction with unless you've confirmed it's safe — they may lose form state. Snapshot first, ask second on anything destructive.
- Cross-process SwiftData writes are unsafe while the app holds the SQLite connection — readonly queries only.
- Tab-bar buttons aren't always exposed in
describe-all. SwiftUI bottomTabViewitems often render as a parentGroup { AXLabel: "Tab Bar" }with no per-tab children in the flat tree. Usedescribe-pointat the tab's expected coordinates instead — it returns theAXRadioButtonwithsubrole: AXTabButtonand the right label. Then tap by that frame. head -c Ntruncates JSON output mid-document —idb ui describe-allproduces a single JSON array; piping throughheadfor inspection chops it and breaksjson.loads. Save the full output to a file first (idb ui describe-all > /tmp/tree.json), then parse from the file.- Env vars don't propagate into
python3 -c '...'the way they do intobash. Use a heredoc form instead:Or pass values viapython3 << 'PY' import os label = os.environ.get('LABEL', 'default') PY
--envflags / command args. TheLABEL=... python3 -c "..."form looks right but Python sees an empty environ when invoked through some shells. statusis a readonly variable in zsh. When writing polling loops that read a status code into a shell variable, name it anything else (stat,current,value). The harness's bash exec uses zsh-compatible semantics for some built-ins.- For long-running polls, use
run_in_background: trueon the Bash tool, OR wrap in aMonitor-friendlyuntilloop. Chainingsleep 30 && cat ...calls is blocked by the harness — it's enforcing the "don't sleep-loop, use background tasks" rule. - App launch can succeed at the OSLog level but the simulator display can still show home screen if the app was already running in the background, the Simulator window is not focused, or the post-launch foregrounding race didn't fire. If
xcrun simctl launchreturns a PID but screenshot shows home, runlauncha second time — the second call typically brings it to front. xcrun simctl spawn booted log stream --predicate ...does NOT capture Rusttracing::*output unless the app explicitly wires tracing into OSLog. If your app usestracing-subscriber::fmt()(the default) the logs go to stderr inside the app process, not to OSLog, so the log stream won't see them. To see Rust traces, the app needs atracing-oslog(or equivalent) layer. Without one, you can still observe Rust-side state via SwiftData and behavior via screenshots.
- Mock the network / chain state. For that you need testnet faucets, regtest, or fixture-based tests at the Rust layer.
- Simulate IS-lock / chain-lock signatures. Those come from the masternode network. To test those code paths deterministically you need test fixtures injected at the Rust persister layer.
- Cross-process writes to SwiftData while the app is running.
User reported: identity slot #10 stuck on "Waiting for InstantSendLock…" forever. End-to-end workflow used:
- Screenshot → confirmed UI shows "Broadcast", proofBytes "—".
- SwiftData query → got the full outpoint
780ea9931eae9d4e6a0df2c0c2721c11bd645fc453fb2907b4a4894893a257d0:0(the UI truncates to780ea99…257d0:0, useless for chain lookups). - WebFetch insight.testnet with the full txid → block 1475917, 67 confirmations,
txlock: true→ diagnostic table row 1: SPV catch-up gap. idb ui describe-all→ foundBackButtonat frame{{16, 62}, {44, 44}}and the slot-row by its full-txidAXLabel.idb ui tap 38 84→ navigated back to the list, screenshot revealed all 9 asset locks: only slot #10 was stuck on Broadcast; slots #2-#9 are all InstantSendLocked. Confirms the bug is specific to outpoints that were already confirmed before this app session was first launched.- Label-find-then-tap with substring
"780ea9931"→ restored user's screen to slot #10 detail.
Total time: ~5 minutes of automated control + verification. No coordinate guessing, no screenshot squinting, ground truth from SwiftData + chain.
- Wrap
tap_labelin a checked-in script at.claude/skills/simulator-control/scripts/tap-label.py. - Add
wait-for-label "<label>" --timeout 30that pollsdescribe-alluntil the label appears (or disappears) — useful for SPV-delivered status transitions during UAT. - Macro
run-uat-scenario <name>driving the full iter 5 UAT matrix once seed-data fixtures are in place.