Skip to content

fix(tap): Support switch toggle activation#46

Merged
cameroncooke merged 5 commits into
mainfrom
cameroncooke/fix/switch-toggle-tap-style
May 10, 2026
Merged

fix(tap): Support switch toggle activation#46
cameroncooke merged 5 commits into
mainfrom
cameroncooke/fix/switch-toggle-tap-style

Conversation

@cameroncooke
Copy link
Copy Markdown
Owner

@cameroncooke cameroncooke commented May 9, 2026

Fix selector-based switch and toggle activation in AXe.

Weather-style switch controls can appear as wide accessibility rows or as CheckBox elements with switch role metadata, so resolving to the geometric center can miss the actual activation target. This updates tap resolution to recognize richer switch metadata, choose the switch activation point, and handle both descendant and sibling switch layouts.

Normal taps keep the existing simulator tapAt path by default. The new --tap-style automatic behavior only switches to physical touch down/up for matched switch/toggle activations, with --tap-style simulator and --tap-style physical available for explicit control. Batch tap steps mirror direct tap semantics and can be configured at either the batch or step level.

Adds AxePlayground coverage for SwiftUI Toggle and UIKit UISwitch, plus resolver and E2E regression tests. The docs, changelog, and bundled AXe skill references are updated for the new behavior.


Note

Medium Risk
Changes core tap dispatch and accessibility resolution logic (including new physical touch path and new resolution heuristics), which can affect interaction correctness across many UI elements and batch flows.

Overview
Selector-based tap now resolves an activation point (not just element center) and reliably redirects row/label matches to a single contained or sibling switch/toggle when present. Accessibility decoding/normalization is expanded (e.g., Toggle, switch role metadata, AXIdentifier fallback) and element comparison is tightened to avoid misidentifying distinct ancestors.

Adds --tap-style (automatic|simulator|physical) to tap and batch so switch/toggle activations default to physical touch down/up while normal taps continue using simulator tapAt; batch plans gain a physicalTap primitive and runner support.

Adds a new Playground SwitchTestView plus unit + E2E tests covering switch/toggle activation and tap-style overrides, and updates docs/skill references/README/changelog to reflect the new behavior.

Reviewed by Cursor Bugbot for commit e373973. Bugbot is set up for automated code reviews on this repo. Configure here.

@cameroncooke cameroncooke marked this pull request as ready for review May 9, 2026 18:22
Comment thread Sources/AXe/Utilities/AccessibilityTargetResolver.swift
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 9, 2026

Review Change Stack

Walkthrough

This pull request adds comprehensive support for reliably tapping UISwitch and SwiftUI Toggle controls in the AXe iOS simulator CLI. The changes introduce a new --tap-style option (automatic, simulator, physical) at both command and batch levels. The core implementation detects switch-like controls during accessibility element resolution, computes activation-point tap coordinates instead of geometric centres, and dispatches to either physical HID touch sequences or simulator tap events. A new demo view shows both control types, and extensive tests validate the resolution logic and command execution paths. Documentation and examples explain the new selector-tap behaviour and configuration options.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.92% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'fix(tap): Support switch toggle activation' directly and concisely describes the main change: adding support for properly activating switch/toggle controls via tap resolution.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The description clearly explains the fix for selector-based switch and toggle activation, detailing the new tap-style functionality and supporting changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/AXe/Commands/Tap.swift (1)

8-24: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update help text now that taps don't always go to the centre.

The abstract on Line 8 ("tap its center") and the --id/--label/--value help strings on Lines 17, 20, 23 still describe the old behaviour. With the new resolver, switch-like targets are dispatched at the activation point rather than the geometric centre, so the documented behaviour and actual behaviour diverge for the most interesting case introduced by this PR.

📝 Suggested wording
-        abstract: "Tap on a specific point on the screen, or locate an element by accessibility and tap its center."
+        abstract: "Tap on a specific point on the screen, or locate an element by accessibility and tap its activation point (centre for most controls; trailing edge for switches/toggles)."
@@
-    `@Option`(name: [.customLong("id")], help: "Tap the center of the element matching AXUniqueId (accessibilityIdentifier). Ignored if -x and -y are provided.")
+    `@Option`(name: [.customLong("id")], help: "Tap the element matching AXUniqueId (accessibilityIdentifier) at its activation point. Ignored if -x and -y are provided.")
@@
-    `@Option`(name: [.customLong("label")], help: "Tap the center of the element matching AXLabel (accessibilityLabel). Ignored if -x and -y are provided.")
+    `@Option`(name: [.customLong("label")], help: "Tap the element matching AXLabel (accessibilityLabel) at its activation point. Ignored if -x and -y are provided.")
@@
-    `@Option`(name: [.customLong("value")], help: "Tap the center of the element matching AXValue (the current value of a control). Ignored if -x and -y are provided.")
+    `@Option`(name: [.customLong("value")], help: "Tap the element matching AXValue (the current value of a control) at its activation point. Ignored if -x and -y are provided.")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AXe/Commands/Tap.swift` around lines 8 - 24, Update the help text in
Tap.swift to reflect that element-targeted taps may go to the element's
activation point (not always the geometric center): change the command abstract
and the Option help strings for pointX/pointY (if needed) and for elementID,
elementLabel, and elementValue to remove "center" and instead say "tap the
element (at its activation point)" or similar; ensure the -x/-y help still
documents coordinate-based tapping while the --id/--label/--value helps explain
element-based taps use the element's activation point (or center when
appropriate).
🧹 Nitpick comments (6)
Sources/AXe/Commands/Tap.swift (1)

140-163: 💤 Low value

Unreachable .automatic branch — consider an exhaustiveness-friendly shape.

resolvedTapStyle(for:) is documented and implemented to return only .simulator or .physical, so the .automatic arm here is dead code that throws a runtime CLIError. If TapStyle ever grows another case, the compiler would still be happy here, masking the regression.

A small refactor that has the type system enforce the resolution would make this safer — e.g. let resolvedTapStyle return a private enum ResolvedTapStyle { case simulator; case physical }, or use precondition(false, ...) / fatalError to make the unreachable nature obvious.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AXe/Commands/Tap.swift` around lines 140 - 163, The switch on
resolvedTapStyle(for:) has a dead .automatic case; change resolvedTapStyle to
return a private enum (e.g. private enum ResolvedTapStyle { case simulator,
physical }) so the compiler enforces only those two possibilities, update
resolvedTapStyle signature and its implementation to return ResolvedTapStyle,
update callers (including this switch in Tap.swift) to switch over
ResolvedTapStyle and remove the .automatic arm, and ensure
HIDInteractor.performPhysicalTap and HIDInteractor.performHIDEvent call sites
remain unchanged aside from using the new enum type.
Sources/AXe/Utilities/HIDInteractor.swift (1)

77-122: 💤 Low value

LGTM with a minor note on the recovery flag.

The pre-delay → touch-down → hold → touch-up → post-delay sequence is correct and uses the shared TapTiming.defaultHoldDuration, and the best-effort touch-up on failure prevents leaving the simulator in a stuck-down state.

One small observation: didTouchDown is only reset to false after touchUpEvent succeeds (Line 109), so if the touch-up itself throws, the catch will retry the same touch-up via try?. It's harmless because of the try?, but you could move the reset before the touch-up call (or use a defer-based guard) to make the intent — "only recover if we actually have an unbalanced down" — clearer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AXe/Utilities/HIDInteractor.swift` around lines 77 - 122, The catch
concern is that didTouchDown is only set to false after the touch-up succeeds
inside performPhysicalTap(in:session:logger:), making intent unclear if touchUp
throws; update performPhysicalTap to reset didTouchDown before attempting the
touchUp (or use a defer immediately after setting didTouchDown = true to clear
it) so the recovery branch still only runs when a down actually occurred but the
flag is not left set when touchUp itself errors; reference
performPhysicalTap(at:preDelay:postDelay:in:session:logger:), the didTouchDown
variable, and the touchUpEvent/performHIDEvent call to locate the change.
Sources/AXe/Utilities/AccessibilityTargetResolver.swift (1)

100-112: 💤 Low value

Consider naming the activation-point heuristic constants.

The literals 100 (wide-frame threshold) and 31.0 (trailing inset) encode iOS UISwitch/SwiftUI Toggle geometry but read as opaque magic numbers. Hoisting them to file/type-scope private static let constants with a one-line comment about the iOS switch widget geometry would make the heuristic easier to tune and document, and would let the tests reference the same constants if desired.

♻️ Suggested extraction
 struct AccessibilityTargetResolver {
     static let describeUITip = "Make sure the app is on the expected screen, then run `axe describe-ui --udid <SIMULATOR_UDID>` and prefer --id when available."
+
+    /// Frames wider than this are treated as a row containing a trailing-edge
+    /// switch widget rather than a bare switch widget.
+    private static let wideSwitchFrameThreshold: Double = 100
+    /// Approximate distance from the trailing edge of a wide switch row to the
+    /// centre of the iOS switch thumb/track.
+    private static let switchTrailingActivationInset: Double = 31.0
@@
-        if element.isSwitchLikeControl, frame.width > 100 {
-            let switchTrailingActivationInset = 31.0
-            return (x: frame.x + frame.width - switchTrailingActivationInset, y: centerY)
-        }
+        if element.isSwitchLikeControl, frame.width > wideSwitchFrameThreshold {
+            return (x: frame.x + frame.width - switchTrailingActivationInset, y: centerY)
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AXe/Utilities/AccessibilityTargetResolver.swift` around lines 100 -
112, The activationPoint(for:frame:) method uses opaque magic numbers 100 and
31.0; extract them into named file/type-scoped private static let constants
(e.g., wideFrameThreshold and switchTrailingActivationInset) with a one-line
comment describing that they reflect iOS UISwitch/SwiftUI Toggle geometry, then
replace the literals in activationPoint and any tests to reference those
constants; keep the existing behavior but improve readability and allow future
tuning.
Sources/AXe/Utilities/Batch/Command+BatchConvertible.swift (2)

26-55: 💤 Low value

Retry loop logging timing is slightly misleading.

On line 41 the log reads "Element not found, retrying in \(context.pollInterval)s…" and is emitted before the sleep on every iteration, including iterations where the previous retry failed. Consider phrasing as "Element not found, waiting \(pollInterval)s before retry…" or moving the log to indicate a retry attempt count, so the message is unambiguous when investigating timing in verbose logs.

Otherwise the retry/deadline logic looks correct: only ElementResolutionError.isNotFound triggers retries, all other errors propagate immediately, and Batch.validate() already enforces pollInterval > 0 when waitTimeout > 0, so this won't hot-loop.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AXe/Utilities/Batch/Command`+BatchConvertible.swift around lines 26 -
55, The retry log message in resolveBatchTapPoint is misleading because
logger.info().log("Element not found, retrying in \(context.pollInterval)s…") is
emitted before sleeping on every loop; change the logging to either announce
waiting (e.g., "Element not found, waiting \(context.pollInterval)s before
retry…") or move the log to just after the sleep to indicate an actual retry
attempt; update the logger invocation (logger.info().log) and keep use of
context.pollInterval and the existing loop/deadline handling unchanged so only
the phrasing/location of the log is modified.

110-119: 💤 Low value

Defensive .automatic throw is unreachable; consider tightening the type.

resolvedTapStyle(for:context:) is guaranteed to return only .physical or .simulator. The case .automatic arm on line 117-119 exists only to satisfy exhaustiveness and throws a runtime CLIError that can never fire. Two cleaner options:

  • Have resolvedTapStyle return a dedicated nested enum ResolvedTapStyle { case physical, simulator }, eliminating the dead branch entirely.
  • Or, keep the current shape but downgrade the .automatic arm to assertionFailure + a sensible fallback (e.g. .simulator), so a future reviewer isn't misled into thinking this is reachable.

Not blocking — the current code is correct — just a small clarity improvement.

♻️ Suggested refactor using a narrower return type
+    private enum ResolvedTapStyle {
+        case physical
+        case simulator
+    }
+
-    private func resolvedTapStyle(for resolution: TapResolution, context: BatchContext) -> TapStyle {
+    private func resolvedTapStyle(for resolution: TapResolution, context: BatchContext) -> ResolvedTapStyle {
         let requestedStyle = tapStyle ?? context.tapStyle
         switch requestedStyle {
         case .automatic:
             return resolution.isSwitchLikeControl ? .physical : .simulator
         case .simulator:
             return .simulator
         case .physical:
             return .physical
         }
     }

Then the switch style at the call site only needs .physical and .simulator arms.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AXe/Utilities/Batch/Command`+BatchConvertible.swift around lines 110
- 119, The switch handling the result of resolvedTapStyle(for:context:) includes
an unreachable .automatic arm; tighten the return type or make the branch a
debug-only fallback. Option A: change resolvedTapStyle(for:context:) to return a
narrower enum (e.g., ResolvedTapStyle { case physical, simulator }) so the
caller only switches on .physical and .simulator and remove the .automatic case.
Option B: keep the current signature but replace the .automatic throw with an
assertionFailure and a sensible fallback (e.g., treat as .simulator) so the code
still returns the HID/physical tap via .physicalTap(...) or
.hidMergeable(buildDelayedEvent(preDelay:mainEvent:postDelay:)) while surfacing
a debug-time failure via assertionFailure. Ensure callers using
FBSimulatorHIDEvent.tapAt(...) and buildDelayedEvent(...) continue to receive
correct events.
Tests/BatchTests.swift (1)

159-164: 💤 Low value

Use the local waitForLabel helper for consistency.

These new tests call TestHelpers.waitForLabel, but all other tests in this file use the private waitForLabel helper defined at line 218. For consistency with the existing test suite, prefer the local helper here (or migrate the entire file to use TestHelpers.waitForLabel and remove the local helper).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Tests/BatchTests.swift` around lines 159 - 164, Replace the two calls to
TestHelpers.waitForLabel with the file-local private helper waitForLabel to keep
consistency with other tests: change usages of TestHelpers.waitForLabel(...) to
waitForLabel(...) for the swiftUIState and uiKitState assignments (or
alternatively migrate all other calls to TestHelpers.waitForLabel and remove the
private waitForLabel helper), ensuring the same arguments and timeout behavior
are preserved when updating the references.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CHANGELOG.md`:
- Line 12: The changelog bullet about selector-based `tap` and batch tap steps
(mentioning `UISwitch`, SwiftUI `Toggle`, and `--tap-style`) must be rewritten
to follow the repo contribution format: if this is an internal change reference
it as "Fixed selector-based tap and batch tap steps so UIKit UISwitch and
SwiftUI Toggle controls can be activated reliably (#[ISSUE])" with the issue
link; if it’s an external PR reference it as "Added --tap-style so switch/toggle
taps can use physical touch automatically (#[PR] by [`@username`])" with the PR
link and author—update the line to include the correct bracketed tracking number
and attribution per the guidelines.

In `@Sources/AXe/Utilities/AccessibilityTargetResolver.swift`:
- Around line 27-28: The message returned from the enum case
multipleSwitchDescendants in AccessibilityTargetResolver.swift suggests using
`--element-type Switch`, but `isSwitchLikeControl` accepts more than `type ==
"Switch"` (e.g., CheckBox with `AXSwitch` subrole), so change the wording to
prioritize `--id` and coordinates and make `--element-type` an optional
suggestion only when applicable; update the string returned for case
.multipleSwitchDescendants(let count, let selectorDescription) to mention
targeting by `--id` or coordinates first, and either remove or qualify the
`--element-type Switch` suggestion (or add a clause that `--element-type` only
helps when the element’s type is actually Switch) so the tip aligns with the
behavior of `isSwitchLikeControl`.

---

Outside diff comments:
In `@Sources/AXe/Commands/Tap.swift`:
- Around line 8-24: Update the help text in Tap.swift to reflect that
element-targeted taps may go to the element's activation point (not always the
geometric center): change the command abstract and the Option help strings for
pointX/pointY (if needed) and for elementID, elementLabel, and elementValue to
remove "center" and instead say "tap the element (at its activation point)" or
similar; ensure the -x/-y help still documents coordinate-based tapping while
the --id/--label/--value helps explain element-based taps use the element's
activation point (or center when appropriate).

---

Nitpick comments:
In `@Sources/AXe/Commands/Tap.swift`:
- Around line 140-163: The switch on resolvedTapStyle(for:) has a dead
.automatic case; change resolvedTapStyle to return a private enum (e.g. private
enum ResolvedTapStyle { case simulator, physical }) so the compiler enforces
only those two possibilities, update resolvedTapStyle signature and its
implementation to return ResolvedTapStyle, update callers (including this switch
in Tap.swift) to switch over ResolvedTapStyle and remove the .automatic arm, and
ensure HIDInteractor.performPhysicalTap and HIDInteractor.performHIDEvent call
sites remain unchanged aside from using the new enum type.

In `@Sources/AXe/Utilities/AccessibilityTargetResolver.swift`:
- Around line 100-112: The activationPoint(for:frame:) method uses opaque magic
numbers 100 and 31.0; extract them into named file/type-scoped private static
let constants (e.g., wideFrameThreshold and switchTrailingActivationInset) with
a one-line comment describing that they reflect iOS UISwitch/SwiftUI Toggle
geometry, then replace the literals in activationPoint and any tests to
reference those constants; keep the existing behavior but improve readability
and allow future tuning.

In `@Sources/AXe/Utilities/Batch/Command`+BatchConvertible.swift:
- Around line 26-55: The retry log message in resolveBatchTapPoint is misleading
because logger.info().log("Element not found, retrying in
\(context.pollInterval)s…") is emitted before sleeping on every loop; change the
logging to either announce waiting (e.g., "Element not found, waiting
\(context.pollInterval)s before retry…") or move the log to just after the sleep
to indicate an actual retry attempt; update the logger invocation
(logger.info().log) and keep use of context.pollInterval and the existing
loop/deadline handling unchanged so only the phrasing/location of the log is
modified.
- Around line 110-119: The switch handling the result of
resolvedTapStyle(for:context:) includes an unreachable .automatic arm; tighten
the return type or make the branch a debug-only fallback. Option A: change
resolvedTapStyle(for:context:) to return a narrower enum (e.g., ResolvedTapStyle
{ case physical, simulator }) so the caller only switches on .physical and
.simulator and remove the .automatic case. Option B: keep the current signature
but replace the .automatic throw with an assertionFailure and a sensible
fallback (e.g., treat as .simulator) so the code still returns the HID/physical
tap via .physicalTap(...) or
.hidMergeable(buildDelayedEvent(preDelay:mainEvent:postDelay:)) while surfacing
a debug-time failure via assertionFailure. Ensure callers using
FBSimulatorHIDEvent.tapAt(...) and buildDelayedEvent(...) continue to receive
correct events.

In `@Sources/AXe/Utilities/HIDInteractor.swift`:
- Around line 77-122: The catch concern is that didTouchDown is only set to
false after the touch-up succeeds inside performPhysicalTap(in:session:logger:),
making intent unclear if touchUp throws; update performPhysicalTap to reset
didTouchDown before attempting the touchUp (or use a defer immediately after
setting didTouchDown = true to clear it) so the recovery branch still only runs
when a down actually occurred but the flag is not left set when touchUp itself
errors; reference performPhysicalTap(at:preDelay:postDelay:in:session:logger:),
the didTouchDown variable, and the touchUpEvent/performHIDEvent call to locate
the change.

In `@Tests/BatchTests.swift`:
- Around line 159-164: Replace the two calls to TestHelpers.waitForLabel with
the file-local private helper waitForLabel to keep consistency with other tests:
change usages of TestHelpers.waitForLabel(...) to waitForLabel(...) for the
swiftUIState and uiKitState assignments (or alternatively migrate all other
calls to TestHelpers.waitForLabel and remove the private waitForLabel helper),
ensuring the same arguments and timeout behavior are preserved when updating the
references.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 722af337-94b1-4fd5-8c5f-3f1b594e6909

📥 Commits

Reviewing files that changed from the base of the PR and between 550b3ab and c8a653b.

📒 Files selected for processing (26)
  • AxePlaygroundApp/AxePlayground/ContentView.swift
  • AxePlaygroundApp/AxePlayground/Views/SwitchTestView.swift
  • BATCHING.md
  • CHANGELOG.md
  • README.md
  • Skills/CLI/axe/SKILL.md
  • Skills/CLI/axe/references/batch-reference.md
  • Skills/CLI/axe/references/cli-quick-reference.md
  • Sources/AXe/Commands/Batch.swift
  • Sources/AXe/Commands/Tap.swift
  • Sources/AXe/Commands/Touch.swift
  • Sources/AXe/Resources/skills/axe/SKILL.md
  • Sources/AXe/Types/TapStyle.swift
  • Sources/AXe/Utilities/AccessibilityElement.swift
  • Sources/AXe/Utilities/AccessibilityPoller.swift
  • Sources/AXe/Utilities/AccessibilityTargetResolver.swift
  • Sources/AXe/Utilities/Batch/BatchContext.swift
  • Sources/AXe/Utilities/Batch/BatchPlan.swift
  • Sources/AXe/Utilities/Batch/BatchPlanRunner.swift
  • Sources/AXe/Utilities/Batch/Command+BatchConvertible.swift
  • Sources/AXe/Utilities/HIDInteractor.swift
  • Tests/AccessibilityTargetResolverTests.swift
  • Tests/BatchTests.swift
  • Tests/TapTests.swift
  • Tests/TestUtilities.swift
  • USAGE_EXAMPLES.md

Comment thread CHANGELOG.md Outdated
Comment thread Sources/AXe/Utilities/AccessibilityTargetResolver.swift Outdated
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Sibling switch logic redirects taps from actionable elements
    • Added guard to check if matched element is actionable before attempting sibling switch lookup, preventing incorrect redirection while preserving intended behavior for non-actionable labels.

Create PR

Or push these changes by commenting:

@cursor push 76dfef03bf
Preview (76dfef03bf)
diff --git a/Sources/AXe/Utilities/AccessibilityTargetResolver.swift b/Sources/AXe/Utilities/AccessibilityTargetResolver.swift
--- a/Sources/AXe/Utilities/AccessibilityTargetResolver.swift
+++ b/Sources/AXe/Utilities/AccessibilityTargetResolver.swift
@@ -174,6 +174,10 @@
             return switchDescendants[0]
         }
 
+        if matchedElement.isActionable {
+            return matchedElement
+        }
+
         if let ancestor = nearestAncestor(of: matchedElement, in: roots) {
             let siblingSwitches = ancestor.switchLikeDescendantsIncludingSelf()
             if siblingSwitches.count == 1 {

You can send follow-ups to the cloud agent here.

Comment thread Sources/AXe/Utilities/AccessibilityTargetResolver.swift
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Sibling switch redirection causes test assertion failure
    • Added query type awareness to selectActivationElement so sibling switch redirection only applies to label queries, not value or id queries.
  • ✅ Fixed: Duplicated polling logic between batch and direct tap
    • Extracted polling retry logic into shared AccessibilityPoller.pollForResolution function with root-fetching closure to eliminate duplication.

Create PR

Or push these changes by commenting:

@cursor push 76246a431e
Preview (76246a431e)
diff --git a/Sources/AXe/Utilities/AccessibilityPoller.swift b/Sources/AXe/Utilities/AccessibilityPoller.swift
--- a/Sources/AXe/Utilities/AccessibilityPoller.swift
+++ b/Sources/AXe/Utilities/AccessibilityPoller.swift
@@ -10,7 +10,26 @@
         elementType: String? = nil,
         logger: AxeLogger
     ) async throws -> TapResolution {
-        let roots = try await AccessibilityFetcher.fetchAccessibilityElements(for: simulatorUDID, logger: logger)
+        try await pollForResolution(
+            query: query,
+            waitTimeout: waitTimeout,
+            pollInterval: pollInterval,
+            elementType: elementType,
+            logger: logger
+        ) {
+            try await AccessibilityFetcher.fetchAccessibilityElements(for: simulatorUDID, logger: logger)
+        }
+    }
+
+    static func pollForResolution(
+        query: AccessibilityQuery,
+        waitTimeout: TimeInterval,
+        pollInterval: TimeInterval,
+        elementType: String?,
+        logger: AxeLogger,
+        rootsFetcher: () async throws -> [AccessibilityElement]
+    ) async throws -> TapResolution {
+        let roots = try await rootsFetcher()
         do {
             return try AccessibilityTargetResolver.resolveTap(roots: roots, query: query, elementType: elementType)
         } catch let error as ElementResolutionError where error.isNotFound && waitTimeout > 0 {
@@ -22,7 +41,7 @@
                 logger.info().log("Element not found, retrying in \(pollInterval)s…")
                 try await Task.sleep(for: .seconds(pollInterval))
 
-                let freshRoots = try await AccessibilityFetcher.fetchAccessibilityElements(for: simulatorUDID, logger: logger)
+                let freshRoots = try await rootsFetcher()
                 do {
                     return try AccessibilityTargetResolver.resolveTap(roots: freshRoots, query: query, elementType: elementType)
                 } catch let retryError as ElementResolutionError where retryError.isNotFound {

diff --git a/Sources/AXe/Utilities/AccessibilityTargetResolver.swift b/Sources/AXe/Utilities/AccessibilityTargetResolver.swift
--- a/Sources/AXe/Utilities/AccessibilityTargetResolver.swift
+++ b/Sources/AXe/Utilities/AccessibilityTargetResolver.swift
@@ -4,6 +4,15 @@
     case id(String)
     case label(String)
     case value(String)
+
+    var allowsSiblingRedirection: Bool {
+        switch self {
+        case .label:
+            return true
+        case .id, .value:
+            return false
+        }
+    }
 }
 
 enum ElementResolutionError: LocalizedError {
@@ -81,7 +90,8 @@
         let activationElement = try selectActivationElement(
             from: matchedElement,
             roots: roots,
-            selectorDescription: selectorDescription
+            selectorDescription: selectorDescription,
+            allowSiblingRedirection: query.allowsSiblingRedirection
         )
 
         guard let frame = activationElement.frame else {
@@ -157,7 +167,8 @@
     private static func selectActivationElement(
         from matchedElement: AccessibilityElement,
         roots: [AccessibilityElement],
-        selectorDescription: String
+        selectorDescription: String,
+        allowSiblingRedirection: Bool
     ) throws -> AccessibilityElement {
         if matchedElement.isSwitchLikeControl {
             return matchedElement
@@ -178,7 +189,7 @@
             return matchedElement
         }
 
-        if let ancestor = nearestAncestor(of: matchedElement, in: roots) {
+        if allowSiblingRedirection, let ancestor = nearestAncestor(of: matchedElement, in: roots) {
             let siblingSwitches = ancestor.switchLikeDescendantsIncludingSelf()
             if siblingSwitches.count == 1 {
                 return siblingSwitches[0]

diff --git a/Sources/AXe/Utilities/Batch/Command+BatchConvertible.swift b/Sources/AXe/Utilities/Batch/Command+BatchConvertible.swift
--- a/Sources/AXe/Utilities/Batch/Command+BatchConvertible.swift
+++ b/Sources/AXe/Utilities/Batch/Command+BatchConvertible.swift
@@ -29,28 +29,17 @@
     elementType: String?,
     logger: AxeLogger
 ) async throws -> TapResolution {
-    let roots = try await context.accessibilityRoots(logger: logger)
-    do {
-        return try AccessibilityTargetResolver.resolveTap(roots: roots, query: query, elementType: elementType)
-    } catch let error as ElementResolutionError where error.isNotFound && context.waitTimeout > 0 {
-        let clock = ContinuousClock()
-        let deadline = clock.now + .seconds(context.waitTimeout)
-
-        var lastError = error
-        while clock.now < deadline {
-            logger.info().log("Element not found, retrying in \(context.pollInterval)s…")
-            try await Task.sleep(for: .seconds(context.pollInterval))
-
-            let freshRoots = try await context.accessibilityRoots(logger: logger, forceRefresh: true)
-            do {
-                return try AccessibilityTargetResolver.resolveTap(roots: freshRoots, query: query, elementType: elementType)
-            } catch let retryError as ElementResolutionError where retryError.isNotFound {
-                lastError = retryError
-                continue
-            }
-        }
-
-        throw lastError
+    var isFirstFetch = true
+    return try await AccessibilityPoller.pollForResolution(
+        query: query,
+        waitTimeout: context.waitTimeout,
+        pollInterval: context.pollInterval,
+        elementType: elementType,
+        logger: logger
+    ) {
+        let forceRefresh = !isFirstFetch
+        isFirstFetch = false
+        return try await context.accessibilityRoots(logger: logger, forceRefresh: forceRefresh)
     }
 }

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 681ac45. Configure here.

Comment thread Sources/AXe/Utilities/AccessibilityTargetResolver.swift
Comment thread Sources/AXe/Utilities/Batch/Command+BatchConvertible.swift
Comment thread Sources/AXe/Utilities/AccessibilityElement.swift Outdated
Comment thread Sources/AXe/Utilities/AccessibilityTargetResolver.swift
cameroncooke and others added 4 commits May 10, 2026 19:50
Recognize UIKit and SwiftUI switch-like controls from richer accessibility
metadata and resolve selector taps to the switch activation point. Add an
automatic tap style that keeps normal taps on simulator tapAt while using
physical touch down/up for switch and toggle activations.

Mirror the behavior in batch taps and add playground coverage for SwiftUI
Toggle and UIKit UISwitch controls.

Co-Authored-By: Codex <noreply@openai.com>
Add guard to prevent sibling switch lookup when matched element is already actionable (Button, Link, etc). This ensures explicit --id or --label targeting of actionable elements is respected, while preserving sibling switch behavior for non-actionable labels.

Applied via @cursor push command
Elements with same type/frame but nil labels/values are now distinguished
by role, roleDescription, and subrole properties to prevent incorrect
identity matches in nearestAncestor tree traversal.

Co-authored-by: Cameron Cooke <web@cameroncooke.com>
…ed polling logic

- Only apply sibling switch redirection for label-based queries, not value or id queries
- Extract polling retry logic into shared AccessibilityPoller.pollForResolution function
- Fixes test assertion failure in elementsWithSameTypeAndFrameButDifferentRolesAreDistinct
- Eliminates code duplication between batch and direct tap polling paths

Applied via @cursor push command
@cameroncooke cameroncooke force-pushed the cameroncooke/fix/switch-toggle-tap-style branch from 11d6588 to bb4b6f9 Compare May 10, 2026 19:04
Tighten switch activation resolution by using stable AXUniqueId identity,
limiting sibling switch detection to direct children, and clarifying
ambiguous selector guidance.

Update tap help text, changelog attribution, and regression coverage for
identifier collisions and nested sibling switch lookup.

Refs GH-46
Co-Authored-By: Codex <noreply@openai.com>
@cameroncooke
Copy link
Copy Markdown
Owner Author

Follow-up from review comments, fixed in e373973:

  • Updated tap help text to describe element activation points rather than always saying center.
  • Named the switch activation geometry constants.
  • Updated new BatchTests calls to use the file-local waitForLabel helper.
  • Tightened sibling switch lookup to direct children and added regression coverage.

I left the lower-value type-shape/logging/refactor suggestions out of this PR because they are not required for the switch activation fix.

@cameroncooke cameroncooke merged commit 510d4df into main May 10, 2026
5 checks passed
@cameroncooke cameroncooke deleted the cameroncooke/fix/switch-toggle-tap-style branch May 10, 2026 19:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants