Repository navigation
iOS Simulator: Maestro engine and agent-device is/get/alert disagree depending on the bound snapshot backend (Simulator AX vs XCTest) #2638
Description
Activity
Triage
Reproduced and root-caused one class here; the out-of-process classes still need evidence.
Root cause (fixed in #2643). The interactive fold suppresses an
Otherwrapper that carries only
an identifier, delegating that identifier to the wrapper's content. A React NativetestIDwrapper
has no content in the accessibility tree — the text lands beside it, not inside it, on both producers
— so suppression left the identifier with no representative at all, and Maestro reads its visible set
from that presentation whileis/getresolve from the capture. What made it backend-dependent is
that the suppression gate readshittable === true: the Apple runner answers hittability for every
node (trueon screen,falseoff screen), and the Simulator AX bridge answers it for none. Same
screen, same installed app, the two raw trees are node-for-node identical (165 nodes) and differ only
by the presence ofhittable.Suppression now needs the node's own declared
hittable: false. After that change,snapshot -iover
both producers publishes the same 18 identifier hooks, and the Maestro flow passes where it previously
failed withMaestro visible condition did not match.Still open, and what would unblock it. Classes you list as
PHPickerViewControllerand MapLibre
annotation content are probably not this rule: that content may be owned by another process and
therefore absent from an app-process tree, which no presentation change can recover. To tell those
apart, please attach from one failing screen each, run twice (once bound to Simulator AX, once served
by XCTest):agent-device snapshot --raw --json agent-device snapshot -i --jsonAlert.alertwas re-checked against the same simulator today and resolves under Simulator AX, so if
that class still fails for you the captures above will say why. The intermittentformSheetfields
and the non-interactive leafTextin aListHeaderComponentmay or may not be the fixed rule — the
same captures settle it.- addedneeds-infoWaiting on reporter or external inputWaiting on reporter or external input
on Sep 15, 2026 - added a commit that references this issue
on Sep 16, 2026 Mechanism, confirmed in code (not just symptom)
The
is/get/alert-vs-Maestro split is not a visibility-predicate disagreement.isAssertionVisible(packages/selectors/src/internal/predicates.ts:120) andisMaestroNodeVisible(packages/maestro/src/internal/snapshot-policy.ts:8) are byte-identical, including the sharedisUsefulVisibilityAnchor. The split is purely which node set reaches the matcher:- Maestro re-applies the interactive compaction to the snapshot it receives and resolves selectors against that compacted tree —
daemon-runtime-port-observation.ts:107-113callsbuildIosInteractiveSnapshotPresentation(snapshot.nodes)and matches the result. is/get/clickresolve from the regular presentation (selector-capture-policy.ts:interactiveOnly: false), which never runs the semantic compaction fold (engine.ts:121gatesbuildIosInteractiveSnapshotPresentationoninteractiveOnly).
So every interactive-fold rule that drops a node is a rule that can make Maestro fail to resolve a selector
is/getstill resolve from the same capture.Verified by experiment: reverting only
collectIosStructuralIdentifierSuppression(#2643's rule) flipsdaemon-runtime-port-identifier-hook.test.tsfromok:truetook:false, even though that test feeds the hook nodes straight to Maestro and never opens a simulator. The fold is Maestro's sole gate.Per class
- Childless
testIDwrapper (list-headerText,members.count) — root-caused and closed in fix(ios): keep an identifier hook the fold cannot prove inert #2643. Itshittable:falserequirement removed the Simulator-AX-vs-XCTest split, because the leak only appeared on the producer that reports no hittability. This is the exact cross-backend dependency the report describes. Alert.alert— re-checked in fix(ios): keep an identifier hook the fold cannot prove inert #2643 triage; resolves under Simulator AX.- PHPicker / MapLibre annotation content — out-of-process. That content is owned by another process and is absent from an app-process tree, so no presentation change recovers it. The only non-presentation remedy is registering the host in
IOS_SYSTEM_SURFACE_HOSTS(packages/contracts/src/ios-system-surface.ts), which the snapshot route already consumes to fall back to XCTest (snapshot-route.tssystemSurfacePresent). That registry is deliberately closed and requires live evidence per host. Still need the twosnapshot --raw --json/snapshot -i --jsoncaptures (once per backend) from one failing screen to add these correctly.
The remaining fold leak (real, reachable, and NOT yet fixed) — decision needed
#2643 hardened only the childless branch of
collectIosStructuralIdentifierSuppression(noise-structural.ts). Its delegation branch still suppresses an identifier-carryingOtherwrapper whenever it has any content, on the assumption that "the identifier goes on living in whatever the wrapper stood for" (ADR 0004). That assumption is unverified: the kept content is chosen by tree position, and ADR 0004's "moves an identifier" is only satisfied if the representative actually carries the identifier.Reachable shape — an
Otherwrapper with an identifier and non-identifier-bearing content, which is exactly theView testID=...-with-Text-children pattern already modelled bynoise.test.ts'smakeStructuralTree(wrapperidentifier: 'wrapper-N', childrenStaticTextwith only labels):- Runner, on screen: wrapper is
hittable:true, skipped atnoise-structural.ts:14→ kept →id=resolves. - Simulator AX: no
hittablereported, so line 14 does not skip it → suppressed, delegating to content that never carried the identifier →id=un-made → Maestro fails whileis visible 'id=...'passes.
That is the same backend-dependent split as the childless class, one branch over. It is currently encoded as intended by two tests (
noise.test.ts: "suppresses structural identifier wrappers, keeping their content published" and "delegates an identifier wrapper to its content even without a hittability verdict"), which assertsuppressedincludes the identifier-bearing wrapper whose content does not carry that identifier.Closing it is a design call, not a bug sweep, and it cuts against those two deliberate tests, so it needs a maintainer decision and device verification rather than a guard:
- (A) Delegation may suppress only when the kept content actually carries the identifier, else keep the wrapper; or
- (B) literal ADR-0004: transfer the identifier onto the representative when the wrapper is suppressed (keeps the tree compact and the id addressable); or
- (C) resolve it as part of iOS snapshot backend convergence and fast Simulator observation #2188/Selector queries have no fallback producer on the iOS Simulator, and any fallback needs producer equivalence #2274's single-representation convergence, which is where the "Maestro re-folds,
is/getdon't" split — the actual reason these two views can diverge at all — goes away.
Leaning (C) for the split itself and (B) if a narrower slice is wanted first, since (A) only trades a lost identifier for an extra published node and neither (A) nor (B) removes Maestro's dependence on the presentation layer.
- Maestro re-applies the interactive compaction to the snapshot it receives and resolves selectors against that compacted tree —
Closing as a symptom tracker, on the model this issue was filed under ("filed as a concrete symptom for the convergence work in #2188 / #2274").
- The reported symptom (Maestro failing where
is/getpass, gated on the bound backend) for the RNtestIDcase was root-caused and fixed in fix(ios): keep an identifier hook the fold cannot prove inert #2643.Alert.alertre-checks clean under Simulator AX. - The remaining classes live on in the durable trackers, not here:
- Out-of-process surfaces (PHPicker, MapLibre annotation content) need host registration in
IOS_SYSTEM_SURFACE_HOSTSbehind live per-host evidence — the mechanism and gating are Selector queries have no fallback producer on the iOS Simulator, and any fallback needs producer equivalence #2274's producer-equivalence question; the twosnapshot --raw --json/-i --jsoncaptures (one per backend) from a failing screen are what unblock it. - The "Maestro re-folds the interactive presentation,
is/getresolve from the regular capture" split — the reason the two views can diverge at all — is iOS snapshot backend convergence and fast Simulator observation #2188's single-representation convergence. - The delegation-branch identifier leak and the design choice it implies (keep / transfer / converge) are written up in the preceding comment for whoever picks up the fold slice.
- Out-of-process surfaces (PHPicker, MapLibre annotation content) need host registration in
Nothing here is dropped; it's just tracked where the durable outcome lives. If a failing screen reproduces that is NOT out-of-process and NOT the delegation branch, reopen with the captures above.
- The reported symptom (Maestro failing where
Summary
On the iOS Simulator, agent-device's own
is/get/alertcommands and the bundled Maestro engine (test --maestro) disagree about what is on screen, and which answer Maestro gives depends on which snapshot backend the session happens to bind. When the banner readsSimulator AX snapshot inapplicable (system-surface-host-lingering); used XCTest, which read app content, every flow passes. On a freshly booted simulator (Simulator AX bound) the same flows, same commit, fail on the same five kinds of element every run. The app is not involved:agent-device is visible 'id="members.count"'passes andagent-device alertprintsSign out?at the same second Maestro'sassertVisible/tapOnagainst the same surfaces find nothing.Filed as a concrete symptom for the convergence work in #2188 / #2274 (the "no fallback producer" note there matches what we see).
Environment
npx agent-device@latest), headless, no OAuth@expo/ui(SwiftUI) lists and sheets,expo-routeragent-device test --maestro <dir> --platform ios; assertions/taps bytestIDonlyWhat vanishes from the Simulator-AX tree (and not from XCTest)
Alert.alert's buttons.agent-device alertprints the alert (Sign out?), Maestro- tapOn: "Sign Out"matches nothing. Under XCTest the same step passes.PHPickerViewController): the picker is plainly on screen ("Collections" and all),extendedWaitUntil visible: "Collections"times out.presentation: "formSheet"route (expo-router native stack) — intermittently.Textinside aFlatListListHeaderComponent:agent-device get text 'id="members.count"'returns4 members, MaestroassertVisible: id: "members.count"fails.A SwiftUI
BottomSheet's contents are not affected — they resolve under both backends — so this is about surfaces presented outside the app's own host, not sheets as such.Repro (minimal)
Any RN/Expo app with a screen that has a
TextwithtestID="members.count"in a list header and a row that callsAlert.alert("Sign out?", ..., [{text: "Sign Out"}]).xcrun simctl shutdown <udid>; xcrun simctl boot <udid>(a fresh boot binds Simulator AX).agent-device open <bundle> --platform ios --foreground, navigate to the screen.Simulator AX snapshot inapplicable (system-surface-host-lingering); used XCTest— the same flow now passes.agent-device prepare ios-runnerdoes not change which backend is preferred.Observed over 9 consecutive runs at 6–8/9 flows passing under XCTest, then 5 consecutive runs at 5–6/9 under Simulator AX, same commit, the same three flows failing each time.
Expected
Maestro's visibility predicate and
agent-device is/get/alertshould agree, ortest --maestroshould fall back to the XCTest producer for a selector the Simulator-AX tree cannot resolve (the same wayis/getevidently do). Failing that, a way to pin the backend for a run.Why it matters
Adapting flows means deleting exactly the assertions that matter most (the sign-out confirmation, the picker, the member count), and rebooting the simulator to "clean up" before a gate run makes the gate fail. Teams end up documenting "do not reboot the simulator", which is not a real fix.