Skip to content

fix: tapOn '<text>' fails for React Native Pressable with accessibilityLabel on iOS (#1409)#3165

Open
qwertey6 wants to merge 5 commits intomobile-dev-inc:mainfrom
ReverentPeer:pr/5-ios-accessibility-text-fallback
Open

fix: tapOn '<text>' fails for React Native Pressable with accessibilityLabel on iOS (#1409)#3165
qwertey6 wants to merge 5 commits intomobile-dev-inc:mainfrom
ReverentPeer:pr/5-ios-accessibility-text-fallback

Conversation

@qwertey6
Copy link
Copy Markdown

Proposed changes

tapOn: "<text>" fails to find React Native buttons that have accessibilityLabel set on iOS — even though users clearly see the text on screen. This has been broken for 2.5 years (#1409, opened in 2023) and affects every React Native app that uses the recommended accessibility pattern.

Impact

Root cause

When a React Native Pressable has accessibilityLabel set, iOS collapses child Text elements into the parent's accessibility label. The element's title and value are empty — only label is populated.

Maestro's iOS hierarchy mapping (IOSDriver.kt:197) populates the text attribute from title or value:

attributes["text"] = element.title?.ifEmpty { element.value } ?: ""

So for these Pressables, text is always "". The user types tapOn: "Continue with Google" and Maestro can't find the element.

Reproduction

<Pressable
  accessibilityLabel="Continue with Google"
  accessibilityRole="button"
  onPress={handler}
>
  <Ionicons name="logo-google" />
  <Text>Continue with Google</Text>
</Pressable>
- tapOn: "Continue with Google"  # FAILS — element not found

The hierarchy dump from maestro hierarchy shows:

{
  "accessibilityText": "Continue with Google",
  "title": "",
  "value": "",
  "text": "",
  "bounds": "[24,582][378,630]",
  "enabled": "true"
}

The element exists, the bounds are correct, the user can see it — but text is empty so the most natural selector fails.

Why the existing workarounds aren't acceptable

  1. tapOn { label: "Continue with Google" }: Users shouldn't need to know the difference between text: and label:. They see text on screen, they expect tapOn: "that text" to work.
  2. tapOn: ".*Continue with Google.*" (regex): Finds the element via the existing Filters.textMatches accessibilityText fallback, but Maestro then taps the bounds-center of whatever element matches — which can be a parent View instead of the Pressable. This is the root of tapOn on Tab Button Succeeds but Does Not Trigger Navigation (Works in Studio, Fails in CLI) #2448's "tap succeeds but onPress doesn't fire."
  3. Removing accessibilityLabel: Bad workaround. The label exists for screen reader users (localization, icon+text combos, etc.).
  4. Coordinate taps: Defeats the entire purpose of using Maestro.

Fix

attributes["text"] = element.title
    ?.takeIf { it.isNotEmpty() }
    ?: element.value
        ?.takeIf { it.isNotEmpty() }
    ?: element.label

Falls back to element.label (the iOS accessibility label) when title and value are both empty. The accessibilityText attribute still uses element.label as its canonical source, so Filters.textMatches's existing fallback through accessibilityText continues to work as before.

This also indirectly fixes the "tap doesn't fire onPress" symptom from #2448: when text is populated on the Pressable itself, normal element ranking picks the deepest text-bearing match (the Pressable) rather than a parent View, and the bounds-center tap lands on the actual touchable area.

Why this hasn't been fixed for 2.5 years

The maintainer noted that "It doesn't look like anyone in the community has been motivated to attempt a fix." A community member identified the exact code location in March 2025 but no PR followed. This change is small (10 lines + comment) and well-isolated.

Testing

  • Builds and unit tests pass
  • The change is purely additive: when title or value is set (e.g., UIButton, text fields), behavior is unchanged. The fallback only triggers when both are empty.
  • Verified against the hierarchy dump from a real React Native app exhibiting this bug

Depends on: #3141#3140#3139#3138 (stacked PR chain for parallel iOS execution and performance)

Issues fixed

Closes #1409
Likely fixes #2448 (same root cause class)

qwertey6 and others added 5 commits April 4, 2026 15:37
iOS simulators share the host's localhost, causing port collisions when
multiple Maestro processes target different sims simultaneously. Session
tracking was per-platform, so two processes on different devices would
interfere with each other's sessions.

Changes:
- Per-device session tracking: SessionStore keys are now
  "{platform}_{deviceId}_{sessionId}" instead of "{platform}_{sessionId}"
- Add --driver-host-port CLI flag for explicit XCTest server port
- Auto-select available ports with isPortAvailable() check
- Refactor SessionStore from singleton to injectable class (DI)
- Add shouldCloseSession(platform, deviceId) for per-device shutdown
  instead of global activeSessions().isEmpty()
- Add cross-process file locking to KeyValueStore (~/.maestro/sessions)
- Append PID to debug log directory to prevent parallel race
- Enable useJUnitPlatform() in maestro-cli (was missing)
- Add SessionStoreTest with 8 tests covering isolation and lifecycle

Verified: 3 iOS simulators + Android emulator running simultaneously,
all passing. Both --driver-host-port (explicit) and auto-port-selection
work correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Default --reinstall-driver to false: reuse a healthy running driver
  instead of killing and reinstalling on every run (~40s saved on iOS)
- XCTestDriverClient checks isChannelAlive() before reinstalling —
  if the user explicitly passes --reinstall-driver, honor it
- Cache extracted iOS build products per-device in
  ~/.maestro/build-products/<deviceId>/ with SHA-256 hash validation:
  skips extraction when source matches cache, re-extracts on upgrade
- Reduce XCTest status check HTTP read timeout from 100s to 3s
- Remove Thread.sleep(1000) heartbeat delay hack (no longer needed
  with per-device session tracking)

Single device: ~52s → ~10-12s. Three devices parallel: ~54s → ~18s.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- iOS XCTest runner: add isVersionMatch() to XCTestInstaller interface.
  LocalXCTestInstaller compares SHA-256 hash of build products against
  a .running-hash marker written at startup. restartXCTestRunner now
  checks both isChannelAlive() AND isVersionMatch() — stale runners
  from a previous Maestro version are replaced automatically.

- Android driver: add isDriverVersionCurrent() that hashes the bundled
  maestro-app.apk and maestro-server.apk, compares against stored hash
  in ~/.maestro/android-driver-hash. On mismatch, APKs are reinstalled
  even when reinstallDriver=false.

- App binary cache (clearAppState): getCachedAppBinary now compares
  Info.plist of cached vs installed app. Stale cache from app updates
  is detected and refreshed before reinstall. Per-device cache dirs
  (~/.maestro/app-cache/<deviceId>/) prevent parallel races.

- Add XCTestDriverClientTest (4 tests) and LocalSimulatorUtilsTest
  (3 tests) covering version mismatch, reuse, and cache behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On iOS, waitForAppToSettle has two tiers: a server-side screenshot
hash check (Tier 1, hardcoded 3000ms) and a client-side hierarchy
comparison fallback (Tier 2). The per-command waitToSettleTimeoutMs
config only controlled Tier 2, so even waitToSettleTimeoutMs: 100
would still burn up to 3 seconds in Tier 1.

Fix: use waitToSettleTimeoutMs as the total settle budget. Tier 1
runs with this timeout, and any remaining time goes to Tier 2:

  - swipe with waitToSettleTimeoutMs: 500 → capped at 500ms total
  - default (no config) → unchanged 3000ms behavior

Wikipedia e2e flow with tuned timeouts: 25s vs 53s baseline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On iOS, when a React Native Pressable has accessibilityLabel set, the
child Text content is collapsed into the parent's accessibility label.
The element's title and value are empty, so Maestro's `text` attribute
was always empty for these elements — making `tapOn: "<text>"` fail to
find buttons that are clearly visible to users.

The element IS reachable via `tapOn { label: ... }` or by regex against
accessibilityText, but those are unintuitive workarounds. Users see text
on screen and expect `tapOn: "that text"` to work — that's the entire
point of the selector.

Fix: in mapViewHierarchy, fall back to element.label (the iOS
accessibility label) when both title and value are empty. accessibilityText
still uses element.label as its canonical source, so the existing
Filters.textMatches accessibilityText fallback continues to work.

This also indirectly fixes the "tap doesn't fire onPress" symptom: when
matching by accessibilityText regex, Maestro might select a parent View
wrapping the Pressable, leading to coordinate taps in the wrong area.
With text populated on the Pressable itself, normal element ranking
picks the correct deepest match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant