fix: add iOS private AX snapshot fallback#758
Conversation
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
f854218 to
2558791
Compare
2558791 to
17345dd
Compare
Code reviewVerdict: minor issues — the design is sound and defensively coded, but there are several robustness gaps in the dynamic private-API plumbing and one cross-layer inconsistency. Findings
Verified cleanSimulator-only gating is genuinely compile-time enforced ( OverallCareful, well-layered (runner private fallback + daemon retry as the real-device net) and safe to land for simulators, but it carries real maintenance weight: ~450 lines of dynamic ObjC against undocumented XCTest internals whose selectors, return shapes, and ABI details can shift with any Xcode release. Draft #761 solves the same sparse-tree symptom with public Generated by Claude Code |
… trees Four fixes that turn the #758 private AX fallback from works-on-one-tree-shape into reliable on Bluesky Home: - Depth ladder: the AX server rejects bulk snapshot requests outright (kAXErrorIllegalArgument) once requested depth crosses a tree-size-dependent limit that moves with live content. Retry at 56/40/24/12 instead of giving up after one attempt at 64. - Real attribute identifiers: the server silently ignored the raw keypath strings the bridge passed, so every node came back with a zero frame (breaking ref taps and the interactive/compact filters, which is why 'snapshot -i -c' stayed sparse). Map keypaths through XCElementSnapshot.axAttributesForElementSnapshotKeyPaths (it returns an NSSet) and drop the mapper's expensive extras (automation type, window display id, base type) that pushed deep requests past the 30s main-thread watchdog. - Viewport from the private root frame when the public windows query degrades to an infinite viewport, so off-screen drawer content stops passing the visibility filter. - Runner source fingerprint now includes .m/.h, so bridge edits stop reusing stale cached runner builds. Also hardens the bridge per review: UInt(exactly:) for untrusted element types, pid_t-sized objc_msgSend for process id matching, and objCType-checked NSValue frame decoding.
On-device validation on Bluesky Home — the fallback does not fire as shipped, fixed in a follow-up branchValidated this PR on a live Bluesky Home feed (iPhone 17 Pro simulator, iOS 27, Xcode 26.2, dev-client build from Root causes (all verified empirically with an instrumented bridge)
Results with the fixes (branch
|
| command | this PR (today, Bluesky Home) | with fixes |
|---|---|---|
snapshot |
IOS_AX_SNAPSHOT_FAILED |
184 nodes, real frames, ~16s (ladder 64→56) |
snapshot -i -c |
1 node | 43 interactive nodes with precise rects (feed tabs at x=6/141/283, drawer excluded) |
ref tap from -i -c |
n/a | lands on element centers, navigation verified |
| Settings (healthy app) | unchanged | unchanged — detector sees content, private path never fires |
Also applied the review's bridge-hardening items (UInt(exactly:), pid_t-sized msgSend, objCType-checked NSValue decode).
Cross-check vs #761
#761's recovery primitive (public typed-query sweep) is demonstrably dead on this screen: the -i -c path is that sweep, and it deadlines with zero elements while coordinate taps work. Public query recovery may still help the milder failure class from #761's repro app (sparse snapshot() but working queries), and this PR already includes a public tier for the regular path — but for Bluesky-class trees the private bridge is the only thing that produces nodes, and with the fixes above it does so reliably. Suggest merging the follow-up branch into this PR before landing.
The all-structural sparse detector misses the common large-RN-tree case where the typed-query sweep resolves one or two stray controls before its 1s deadline: the payload has 'content', so recovery never fires, yet 2 nodes is useless in practice. Treat deadline-truncated payloads with <= 8 nodes as needing recovery, and only replace the original payload when the recovered tree actually carries more nodes. Completed sweeps on legitimately minimal screens stay untouched (not truncated).
Update: raw
|
- Sync the setup metadata script's fingerprint extension list with the runtime (.m/.h were added for the ObjC bridge), fixing the cache metadata parity test. - Reduce find.ts complexity flagged by fallow: hoist the node fetcher into createFindNodeFetcher with a recoverSparseInteractiveSnapshot helper, split match disambiguation and resolution scoring into narrowMultipleMatches/resolvedTouchScore, extract rectsMatch.
…ble in snapshot output Two transparency gaps from #701's 'no silent fallback' requirement: - Runner-attached snapshot messages now surface as snapshot warnings (readAppleSnapshotResult previously dropped them), so every recovery through the fallback accessibility backend or query tier is announced, states what it usually means (the app publishes an unhealthy accessibility tree - fixing the app is the real cure), and points to screenshot as visual truth. - A leaf whose label merges many comma-joined segments is flagged as a collapsed accessible container: the app marks a container accessible, hiding every descendant from assistive tech and automation alike. Nothing can be recovered below it (VoiceOver sees the same merged element), so the warning names the node, estimates the merged label count, and gives the app-side fix plus the screenshot/coordinate-tap workaround. Validated live on the lab stress fixture (adlab://stress?accessible=1): the 6-node tree now carries '@E5 [Other] merges ~126 labels...'.
Real-world validation: production RN app login (reporter-provided build) — recovered after two more detector/plumbing fixesTested a reporter-provided simulator build of a production React Native app (full-screen This branch initially did not recover it, for two reasons now fixed (pushed as
Result on that login screen:
Notably this shape recovered through the public query tier (XCUIElementQuery sees through the modal overlay; no private API involved) — evidence the three-tier design is right: queries first, private AX only for Bluesky-class trees where queries also fail. Healthy apps and Bluesky behavior unchanged; Swift detector tests extended with the labeled-root case; all gates green (750 tests, fallow, lint). |
… through the daemon Validated against a real-world repro (a production React Native app's login screen, simulator build provided privately by the reporter): a full-screen accessibilityViewIsModal overlay leaves the public snapshot with just Application+Window. Two gaps kept recovery off: - The sparse detector counted the Application label (the app's display name) as content and the full-screen root as hittable, so the app name alone defeated recovery. Application/Window labels and root hittability say nothing about tree health and no longer count. - Interactor-level snapshot warnings were dropped by the daemon capture chain (only the runtime/commands layer kept them); they now thread through CaptureSnapshotResult into BackendSnapshotResult. With both fixes that login screen recovers through the public query tier: 16 nodes with every control addressable (fill @ref + read-back verified), and the output carries the recovery warning. Bluesky-class trees still ladder into the private fallback unchanged.
6ddfce4 to
476979b
Compare
Summary
Adds a simulator-only private AX snapshot fallback for iOS when XCTest returns a sparse application/window tree or fails while serializing AX snapshots.
The fallback uses XCTest private accessibility interfaces dynamically, maps the recovered tree back into existing SnapshotNode output, and keeps normal XCTest snapshots authoritative whenever they return real content. It also adds a conservative public XCTest-query recovery tier for regular sparse snapshots before falling back to private AX; compact interactive snapshots still use the private-AX/find recovery path because Bluesky showed public queries collapse there too.
Also fixes SpringBoard permission alerts in compact interactive snapshots: modal detection now runs before the compact app-tree shortcut, and permission sheets return alert text plus actionable button refs without broad SpringBoard subtree walks.
Hardens mutating find actions on iOS: when compact interactive snapshots collapse to the application root, find retries with a full snapshot, then with a query-scoped full snapshot if unscoped AX serialization fails on unrelated feed content.
Closes #701
Closes #761
Validation
snapshot -i -c, did not expose the permission alert, andfind Search clickfailed there.Touched files: 8. Scope covers the iOS XCTest runner snapshot path plus daemon find fallback handling for sparse compact iOS snapshots.