From a1d7213f11171bf514da75fe17455fadccc1a989 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 11 May 2026 11:17:33 +0100 Subject: [PATCH 1/8] feat(slider): Add deterministic slider command Add a selector-based slider command that sets controls to a requested 0-100 percentage and verifies the settled accessibility value before reporting success. Cover the behavior with playground-backed e2e tests that assert the visible slider position after the command runs, and document the new command in the CLI docs and skill references. Co-Authored-By: Codex --- CHANGELOG.md | 4 + README.md | 18 + Skills/CLI/axe/SKILL.md | 18 +- .../CLI/axe/references/cli-quick-reference.md | 13 +- Sources/AXe/Commands/Slider.swift | 379 ++++++++++++++++++ Sources/AXe/Resources/skills/axe/SKILL.md | 18 +- .../AXe/Utilities/AccessibilityElement.swift | 17 +- .../AXe/Utilities/AccessibilityPoller.swift | 47 ++- .../AccessibilityTargetResolver.swift | 23 +- Sources/AXe/main.swift | 1 + Tests/AccessibilityTargetResolverTests.swift | 25 ++ Tests/README.md | 3 + Tests/SliderTests.swift | 107 +++++ USAGE_EXAMPLES.md | 26 +- test-runner.sh | 4 +- 15 files changed, 674 insertions(+), 29 deletions(-) create mode 100644 Sources/AXe/Commands/Slider.swift create mode 100644 Tests/SliderTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index ba8efe6..fefd645 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added `axe slider --id/--label --value 0...100` for deterministic selector-based slider setting with orientation-aware HID dragging and AXValue verification. + ### Fixed - Fixed `describe-ui` and selector-based `tap --label` exposing and activating real SwiftUI `TabView` tab items, navigation search fields, toolbar segmented picker items, and generated navigation back buttons from the CoreSimulator accessibility bridge. Also fixed selector decoding when the accessibility tree contains numeric `AXValue` fields such as sliders. diff --git a/README.md b/README.md index d026c13..9ac29b1 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ AXe is a comprehensive CLI tool for interacting with iOS Simulators using Apple' - [Install AXe Skill](#install-axe-skill) - [Commands Overview](#commands-overview) - [**Touch \& Gestures**](#touch--gestures-1) + - [**Sliders**](#sliders) - [**Gesture Presets**](#gesture-presets) - [**Text Input**](#text-input) - [**Hardware Buttons**](#hardware-buttons-1) @@ -46,6 +47,7 @@ AXe provides complete iOS Simulator automation capabilities: - **Tap**: Precise touch events at specific coordinates with timing controls - **Swipe**: Multi-touch gestures with configurable duration and delta - **Touch Control**: Low-level touch down/up events for advanced gesture control +- **Sliders**: Set slider controls to an exact 0-100 percentage by accessibility identifier or label - **Gesture Presets**: Common gesture patterns (scroll-up, scroll-down, scroll-left, scroll-right, edge swipes) - **Batch Chaining**: Execute ordered multi-step interaction workflows in one invocation @@ -144,6 +146,7 @@ axe tap --id "Safari" --udid $UDID axe tap --label "Safari" --udid $UDID axe tap --label "Weather Alerts" --udid $UDID # Auto-uses physical touch for matched switches/toggles axe tap -x 320 -y 780 --tap-style physical --udid $UDID # Force physical touch for coordinate taps +axe slider --id "volume-slider" --value 75 --udid $UDID axe type 'Hello World!' --udid $UDID axe swipe --start-x 100 --start-y 300 --end-x 300 --end-y 100 --udid $UDID axe button home --udid $UDID @@ -213,6 +216,18 @@ axe touch -x 150 -y 250 --down --up --udid SIMULATOR_UDID axe touch -x 150 -y 250 --down --up --delay 1.0 --udid SIMULATOR_UDID ``` +### **Sliders** + +```bash +# Set a slider by accessibility identifier to a 0-100 percentage +axe slider --id slider-value-slider --value 75 --udid SIMULATOR_UDID + +# Set a slider by label and narrow matching to slider elements +axe slider --label "Volume" --value 40 --element-type Slider --udid SIMULATOR_UDID +``` + +`slider` resolves the real accessibility slider element, drags from its current AXValue-derived position to the requested percentage, then re-reads AXValue to verify the result. + ### **Gesture Presets** ```bash @@ -367,6 +382,9 @@ axe screenshot --output ~/Desktop/ --udid SIMULATOR_UDID axe describe-ui --udid SIMULATOR_UDID # Full screen axe describe-ui --point 100,200 --udid SIMULATOR_UDID # Specific point +# Set slider controls discovered via describe-ui +axe slider --id slider-value-slider --value 75 --udid SIMULATOR_UDID + # List simulators axe list-simulators ``` diff --git a/Skills/CLI/axe/SKILL.md b/Skills/CLI/axe/SKILL.md index 3cba505..e033d00 100644 --- a/Skills/CLI/axe/SKILL.md +++ b/Skills/CLI/axe/SKILL.md @@ -1,23 +1,25 @@ --- name: axe -description: Provides agent-ready AXe CLI usage guidance for iOS Simulator automation. Use when asked to "use AXe", "automate a simulator", "tap/swipe/type on simulator", "describe UI", "take a screenshot", "record video", "batch steps", or "interact with an iOS app". Covers all commands including touch, gestures, text input, keyboard, buttons, accessibility, screenshots, video, and batch workflows. +description: Provides agent-ready AXe CLI usage guidance for iOS Simulator automation. Use when asked to "use AXe", "automate a simulator", "tap/swipe/type on simulator", "set a slider", "describe UI", "take a screenshot", "record video", "batch steps", or "interact with an iOS app". Covers all commands including touch, gestures, sliders, text input, keyboard, buttons, accessibility, screenshots, video, and batch workflows. --- ## Step 1: Confirm runtime context 1. Identify simulator UDID target first (`axe list-simulators`). 2. Simulator-interaction AXe commands require `--udid `. Commands like `list-simulators` and `init` do not. -3. Run `axe describe-ui --udid ` to inspect the full current screen. Use `axe describe-ui --point --udid ` to inspect the element at a specific coordinate. Use the output to discover available `--id` and `--label` values for selector taps, and to confirm coordinates for coordinate-based taps. -4. Prefer selector taps (`tap --id` / `tap --label`) over raw coordinates. Selectors are resilient to layout changes, work across device sizes, and support element waiting (`--wait-timeout`) in batch flows. For UIKit `UISwitch` and SwiftUI `Toggle` rows, selector taps activate the contained switch/toggle when the match contains exactly one such control. Default tap style is `automatic`: switches/toggles use physical touch down/up, while normal taps use simulator `tapAt`. +3. Run `axe describe-ui --udid ` to inspect the full current screen. Use `axe describe-ui --point --udid ` to inspect the element at a specific coordinate. Use the output to discover available `--id` and `--label` values for selector taps and slider setting, and to confirm coordinates for coordinate-based taps. +4. Prefer selectors (`tap --id` / `tap --label`, `slider --id` / `slider --label`) over raw coordinates. Selectors are resilient to layout changes, work across device sizes, and support element waiting where documented. For UIKit `UISwitch` and SwiftUI `Toggle` rows, selector taps activate the contained switch/toggle when the match contains exactly one such control. Default tap style is `automatic`: switches/toggles use physical touch down/up, while normal taps use simulator `tapAt`. ## Step 2: Choose the right command -Available commands: `init`, `tap`, `swipe`, `gesture`, `touch`, `type`, `button`, `key`, `key-sequence`, `key-combo`, `batch`, `describe-ui`, `screenshot`, `record-video`, `stream-video`, `list-simulators`. Run `axe --help` or `axe --help` for full options. +Available commands: `init`, `tap`, `slider`, `swipe`, `gesture`, `touch`, `type`, `button`, `key`, `key-sequence`, `key-combo`, `batch`, `describe-ui`, `screenshot`, `record-video`, `stream-video`, `list-simulators`. Run `axe --help` or `axe --help` for full options. Common examples: ```bash axe tap --id --udid axe tap --label --udid axe tap --label 'Weather Alerts' --udid +axe slider --id --value 75 --udid +axe slider --label --value 40 --element-type Slider --udid axe tap -x -y --tap-style physical --udid axe tap -x -y --udid axe type 'text' --udid @@ -28,14 +30,15 @@ axe screenshot --udid --output screenshot.png ## Step 3: Understand the execution model -HID commands (`tap`, `swipe`, `type`, `key`, etc.) are fire-and-forget — AXe confirms the event was dispatched to the simulator but cannot verify the app actually processed it. A tap may land before a view is interactive, or during a transition. This means: -- Always verify outcomes separately with `describe-ui` or `screenshot`. -- Use `--wait-timeout` in batch to wait for elements to appear, and `sleep` steps or `--pre-delay` / `--post-delay` to allow animations to settle. +Most HID commands (`tap`, `swipe`, `type`, `key`, etc.) are fire-and-forget — AXe confirms the event was dispatched to the simulator but cannot verify the app actually processed it. A tap may land before a view is interactive, or during a transition. `slider` is the exception: it re-reads the matched slider AXValue and fails if the requested 0-100 value was not reached. This means: +- Always verify outcomes separately with `describe-ui` or `screenshot` when app behavior matters beyond the direct command result. +- Use `--wait-timeout` in batch to wait for tap elements to appear, and `sleep` steps or `--pre-delay` / `--post-delay` to allow animations to settle. ## Step 4: Apply timing and input best practices - Use `--pre-delay` / `--post-delay` on tap, swipe, and gesture commands for fixed delays around actions. - Use `--duration` to control how long a swipe, gesture, button press, or key press lasts. - Coordinate-based `tap`, `swipe`, and `touch` accept coordinates from `describe-ui` directly; AXe detects rotated landscape simulator orientation and letterboxed landscape-only app layouts automatically. +- Use `axe slider --id --value <0-100>` for sliders instead of approximating with raw swipe coordinates; it uses the slider frame/current AXValue and verifies the result. - For text with shell-sensitive characters, prefer `--stdin` or `--file` over inline quotes. - Use single quotes for inline text arguments to avoid shell expansion issues. @@ -48,6 +51,7 @@ HID commands (`tap`, `swipe`, `type`, `key`, etc.) are fire-and-forget — AXe c **Fall back to discrete commands** when: - A step's parameters depend on runtime inspection of a previous step's result (e.g. parsing `describe-ui` JSON to choose coordinates dynamically). +- Setting a slider value with `slider`; batch steps do not support slider verification. **Handling animations and transitions in batch:** - Use `--wait-timeout ` so selector taps (`--id` / `--label`) poll the accessibility tree until the element appears or the timeout expires. This is the primary mechanism for multi-screen flows. diff --git a/Skills/CLI/axe/references/cli-quick-reference.md b/Skills/CLI/axe/references/cli-quick-reference.md index eef7737..7b7a95a 100644 --- a/Skills/CLI/axe/references/cli-quick-reference.md +++ b/Skills/CLI/axe/references/cli-quick-reference.md @@ -29,6 +29,16 @@ axe tap -x 320 -y 780 --tap-style physical --udid axe tap -x 100 -y 200 --pre-delay 1.0 --post-delay 0.5 --udid ``` +## Slider + +```bash +# Value is a percentage from 0 to 100 +axe slider --id "volume-slider" --value 75 --udid +axe slider --label "Volume" --value 40 --element-type Slider --udid +``` + +`slider` resolves the matched accessibility slider, uses its frame/current AXValue for the drag start and end points, and re-reads AXValue to verify the requested value was reached. + ## Swipe ```bash @@ -218,10 +228,11 @@ axe stream-video --udid --fps 30 --format ffmpeg | \ | `--pre-delay` | 0–10s | Delay before action | tap, swipe, gesture | | `--post-delay` | 0–10s | Delay after action | tap, swipe, gesture | | `--duration` | 0–10s | Action duration | swipe, gesture, button, key | +| `--value` | 0–100 | Target slider percentage | slider | | `--delay` | 0–5s | Between-item delay | key-sequence, touch | ## Best practices -- Prefer `--id` / `--label` taps over coordinates for resilience. +- Prefer `--id` / `--label` selectors over coordinates for resilience; use `slider` for slider values instead of raw swipe coordinates. - Selector taps activate a contained UIKit `UISwitch` or SwiftUI `Toggle` when the matched row or label contains exactly one switch/toggle. - Default `--tap-style automatic` uses physical touch for matched switches/toggles and simulator `tapAt` for normal taps; use `--tap-style physical|simulator` to override. - Use single quotes for inline text to avoid shell expansion. diff --git a/Sources/AXe/Commands/Slider.swift b/Sources/AXe/Commands/Slider.swift new file mode 100644 index 0000000..485c39a --- /dev/null +++ b/Sources/AXe/Commands/Slider.swift @@ -0,0 +1,379 @@ +import ArgumentParser +import Foundation +import FBControlCore +import FBSimulatorControl + +struct Slider: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Set a slider to a deterministic value from 0 to 100 using accessibility selector targeting." + ) + + private static let dragDuration = 0.6 + private static let dragStepDelta = 2.0 + private static let dragInitialHold: TimeInterval = 0.05 + private static let dragFinalHold: TimeInterval = 0.2 + private static let verificationTimeout: TimeInterval = 1.5 + private static let verificationPollInterval: TimeInterval = 0.1 + private static let verificationStabilityDelay: TimeInterval = 0.3 + private static let maxAdjustmentAttempts = 8 + private static let valueTolerance = 0.004 + private static let minimumCorrectionStep = 0.005 + + @Option(name: [.customLong("id")], help: "Set the slider matching AXUniqueId (accessibilityIdentifier).") + var elementID: String? + + @Option(name: [.customLong("label")], help: "Set the slider matching AXLabel (accessibilityLabel).") + var elementLabel: String? + + @Option(name: [.customLong("element-type")], help: "Filter matches to this accessibility type, usually Slider.") + var elementType: String? + + @Option(name: [.customLong("value")], help: "Target slider value as a percentage from 0 to 100.") + var value: Double + + @Option(name: .customLong("wait-timeout"), help: "Maximum seconds to poll for the slider before failing (0 = no waiting, default).") + var waitTimeout: Double = 0 + + @Option(name: .customLong("poll-interval"), help: "Seconds between accessibility tree polls when --wait-timeout is active (default: 0.25).") + var pollInterval: Double = 0.25 + + @Option(name: .customLong("udid"), help: "The UDID of the simulator.") + var simulatorUDID: String + + func validate() throws { + let selectorCount = [elementID != nil, elementLabel != nil].filter { $0 }.count + guard selectorCount == 1 else { + throw ValidationError("Use exactly one of --id or --label to target a slider.") + } + + if let elementID, elementID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw ValidationError("--id must not be empty.") + } + if let elementLabel, elementLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw ValidationError("--label must not be empty.") + } + + guard value.isFinite, (0...100).contains(value) else { + throw ValidationError("--value must be a finite number between 0 and 100.") + } + guard waitTimeout >= 0 else { + throw ValidationError("--wait-timeout must be non-negative.") + } + if waitTimeout > 0 { + guard pollInterval > 0 else { + throw ValidationError("--poll-interval must be greater than 0 when --wait-timeout is active.") + } + } + } + + func run() async throws { + let logger = AxeLogger() + try await setup(logger: logger) + try await performGlobalSetup(logger: logger) + + let query = try accessibilityQuery() + let targetNormalized = value / 100.0 + + let match = try await AccessibilityPoller.resolveElementWithPolling( + query: query, + simulatorUDID: simulatorUDID, + waitTimeout: waitTimeout, + pollInterval: pollInterval, + elementType: elementType, + logger: logger + ) + + let observedValue = try await setAndVerifySliderValue( + initialMatch: match, + query: query, + targetNormalized: targetNormalized, + logger: logger + ) + + logger.info().log("Slider set completed successfully") + print("✓ Slider set to \(formatPercent(value)) successfully (AXValue: \(observedValue))") + } + + private func accessibilityQuery() throws -> AccessibilityQuery { + if let elementID { + return .id(elementID) + } + if let elementLabel { + return .label(elementLabel) + } + throw CLIError(errorDescription: "Unexpected state: no slider selector.") + } + + private func makeAdjustment( + for element: AccessibilityElement, + targetNormalized: Double + ) throws -> SliderAdjustment { + guard element.isSliderLikeControl else { + let typeDescription = element.type ?? element.role ?? "unknown" + throw CLIError(errorDescription: "Matched element is not a slider (type: \(typeDescription)). Use --element-type Slider or a more specific --id/--label selector.") + } + guard let frame = element.frame else { + throw ElementResolutionError.invalidFrame(reason: "Matched slider has no frame.") + } + guard frame.width > 0, frame.height > 0 else { + throw ElementResolutionError.invalidFrame(reason: "Matched slider has an invalid frame size (\(frame.width)x\(frame.height)).") + } + + let currentNormalized = try parseNormalizedAXValue(element.normalizedValue) + let centerY = frame.y + (frame.height / 2.0) + return SliderAdjustment( + logicalStart: (x: frame.x + (frame.width * currentNormalized), y: centerY), + logicalEnd: (x: frame.x + (frame.width * targetNormalized), y: centerY), + currentNormalized: currentNormalized, + targetNormalized: targetNormalized + ) + } + + private func setAndVerifySliderValue( + initialMatch: AccessibilityMatch, + query: AccessibilityQuery, + targetNormalized: Double, + logger: AxeLogger + ) async throws -> String { + var match = initialMatch + let initialNormalized = try parseNormalizedAXValue(match.element.normalizedValue) + var commandedNormalized = initialCommandedNormalized(currentNormalized: initialNormalized, targetNormalized: targetNormalized) + var lastRawValue = match.element.normalizedValue + var lowerBound = initialNormalized < targetNormalized ? SliderCommandObservation(commanded: initialNormalized, observed: initialNormalized) : nil + var upperBound = initialNormalized > targetNormalized ? SliderCommandObservation(commanded: initialNormalized, observed: initialNormalized) : nil + + for attempt in 1...Self.maxAdjustmentAttempts { + let adjustment = try makeAdjustment(for: match.element, targetNormalized: commandedNormalized) + lastRawValue = match.element.normalizedValue + + logger.info().log( + "Setting slider \(match.selectorDescription) attempt \(attempt) from AXValue \(formatNormalized(adjustment.currentNormalized)) toward \(formatNormalized(targetNormalized))" + ) + + try await performSliderDrag(adjustment, logger: logger) + + let observedValue = try await pollObservedSliderValue( + query: query, + targetNormalized: targetNormalized, + logger: logger + ) + match = observedValue.match + lastRawValue = observedValue.rawValue + + if observedValue.isWithinTolerance { + return lastRawValue ?? formatNormalized(observedValue.normalizedValue) + } + + let observedNormalized = observedValue.normalizedValue + + let observation = SliderCommandObservation(commanded: commandedNormalized, observed: observedNormalized) + if observedNormalized < targetNormalized { + lowerBound = observation + } else { + upperBound = observation + } + commandedNormalized = nextCommandedNormalized( + targetNormalized: targetNormalized, + currentCommandedNormalized: commandedNormalized, + observedNormalized: observedNormalized, + lowerBound: lowerBound, + upperBound: upperBound + ) + } + + throw CLIError( + errorDescription: "Slider value did not reach requested value \(formatPercent(value)). Observed AXValue: \(lastRawValue ?? "none")." + ) + } + + private func performSliderDrag(_ adjustment: SliderAdjustment, logger: AxeLogger) async throws { + let physicalPoints = try await OrientationAwareCoordinates.translateBatch( + points: [adjustment.logicalStart, adjustment.logicalEnd], + for: simulatorUDID, + logger: logger + ) + let physicalStart = physicalPoints[0] + let physicalEnd = physicalPoints[1] + + let distance = hypot(physicalEnd.x - physicalStart.x, physicalEnd.y - physicalStart.y) + let steps = max(1, Int(ceil(distance / Self.dragStepDelta))) + let stepDelay = Self.dragDuration / Double(steps) + + var events: [FBSimulatorHIDEvent] = [ + .touchDownAt(x: physicalStart.x, y: physicalStart.y), + .delay(Self.dragInitialHold) + ] + + for step in 1...steps { + let progress = Double(step) / Double(steps) + let x = physicalStart.x + ((physicalEnd.x - physicalStart.x) * progress) + let y = physicalStart.y + ((physicalEnd.y - physicalStart.y) * progress) + events.append(.touchDownAt(x: x, y: y)) + events.append(.delay(stepDelay)) + } + + events.append(.delay(Self.dragFinalHold)) + events.append(.touchUpAt(x: physicalEnd.x, y: physicalEnd.y)) + + try await HIDInteractor.performHIDEvent(FBSimulatorHIDEvent(events: events), for: simulatorUDID, logger: logger) + } + + private func resolveSliderElement(query: AccessibilityQuery, logger: AxeLogger) async throws -> AccessibilityMatch { + let roots = try await AccessibilityFetcher.fetchAccessibilityElements(for: simulatorUDID, logger: logger) + let match = try AccessibilityTargetResolver.resolveElement( + roots: roots, + query: query, + elementType: elementType + ) + guard match.element.isSliderLikeControl else { + throw CLIError(errorDescription: "Matched element is no longer a slider.") + } + return match + } + + private func pollObservedSliderValue( + query: AccessibilityQuery, + targetNormalized: Double, + logger: AxeLogger + ) async throws -> SliderObservedValue { + let clock = ContinuousClock() + let deadline = clock.now + .seconds(Self.verificationTimeout) + var lastObservedValue: SliderObservedValue? + + repeat { + let match = try await resolveSliderElement(query: query, logger: logger) + let rawValue = match.element.normalizedValue + let normalizedValue = try parseNormalizedAXValue(rawValue) + let observedValue = SliderObservedValue( + match: match, + rawValue: rawValue, + normalizedValue: normalizedValue, + isWithinTolerance: abs(normalizedValue - targetNormalized) <= Self.valueTolerance + ) + if observedValue.isWithinTolerance { + try await Task.sleep(for: .seconds(Self.verificationStabilityDelay)) + let stableMatch = try await resolveSliderElement(query: query, logger: logger) + let stableRawValue = stableMatch.element.normalizedValue + let stableNormalizedValue = try parseNormalizedAXValue(stableRawValue) + let stableObservedValue = SliderObservedValue( + match: stableMatch, + rawValue: stableRawValue, + normalizedValue: stableNormalizedValue, + isWithinTolerance: abs(stableNormalizedValue - targetNormalized) <= Self.valueTolerance + ) + if stableObservedValue.isWithinTolerance { + return stableObservedValue + } + lastObservedValue = stableObservedValue + } else { + lastObservedValue = observedValue + } + + if clock.now < deadline { + try await Task.sleep(for: .seconds(Self.verificationPollInterval)) + } + } while clock.now < deadline + + if let lastObservedValue { + return lastObservedValue + } + throw CLIError(errorDescription: "Slider value could not be verified because AXValue was unavailable after dragging.") + } + + private func initialCommandedNormalized(currentNormalized: Double, targetNormalized: Double) -> Double { + let distance = targetNormalized - currentNormalized + guard abs(distance) > 0.25 else { + return targetNormalized + } + return clampedNormalized(targetNormalized - (distance * 0.1)) + } + + private func nextCommandedNormalized( + targetNormalized: Double, + currentCommandedNormalized: Double, + observedNormalized: Double, + lowerBound: SliderCommandObservation?, + upperBound: SliderCommandObservation? + ) -> Double { + let correction = targetNormalized - observedNormalized + if abs(correction) <= 0.02 { + return targetNormalized + } + + if let lowerBound, + let upperBound, + abs(upperBound.observed - lowerBound.observed) > .ulpOfOne { + let observedRange = upperBound.observed - lowerBound.observed + let commandedRange = upperBound.commanded - lowerBound.commanded + let interpolated = lowerBound.commanded + ((targetNormalized - lowerBound.observed) * commandedRange / observedRange) + if interpolated.isFinite { + return clampedNormalized(interpolated) + } + } + + if observedNormalized < targetNormalized { + let corrected = max(currentCommandedNormalized + correction, currentCommandedNormalized + Self.minimumCorrectionStep) + return clampedNormalized(min(corrected, upperBound?.commanded ?? 1.0)) + } + + let corrected = min(currentCommandedNormalized + correction, currentCommandedNormalized - Self.minimumCorrectionStep) + return clampedNormalized(max(corrected, lowerBound?.commanded ?? 0.0)) + } + + private func clampedNormalized(_ value: Double) -> Double { + min(max(value, 0.0), 1.0) + } + + private func parseNormalizedAXValue(_ rawValue: String?) throws -> Double { + guard let rawValue else { + throw CLIError(errorDescription: "Matched slider does not expose a numeric AXValue, so AXe cannot deterministically set it.") + } + + let trimmedValue = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedValue.isEmpty else { + throw CLIError(errorDescription: "Matched slider does not expose a numeric AXValue, so AXe cannot deterministically set it.") + } + + let isPercent = trimmedValue.hasSuffix("%") + let numericText = trimmedValue.replacingOccurrences(of: "%", with: "") + guard let parsedValue = Double(numericText.trimmingCharacters(in: .whitespacesAndNewlines)), parsedValue.isFinite else { + throw CLIError(errorDescription: "Matched slider does not expose a numeric AXValue, so AXe cannot deterministically set it.") + } + + let normalizedValue = isPercent || parsedValue > 1.0 ? parsedValue / 100.0 : parsedValue + guard (0...1).contains(normalizedValue) else { + throw CLIError(errorDescription: "Matched slider AXValue is outside the supported 0...100 range: \(rawValue).") + } + return normalizedValue + } + + private func formatPercent(_ value: Double) -> String { + if value.rounded() == value { + return String(Int(value)) + } + return String(format: "%.2f", value) + } + + private func formatNormalized(_ value: Double) -> String { + String(format: "%.3f", value) + } +} + +private struct SliderAdjustment { + let logicalStart: (x: Double, y: Double) + let logicalEnd: (x: Double, y: Double) + let currentNormalized: Double + let targetNormalized: Double +} + +private struct SliderCommandObservation { + let commanded: Double + let observed: Double +} + +private struct SliderObservedValue { + let match: AccessibilityMatch + let rawValue: String? + let normalizedValue: Double + let isWithinTolerance: Bool +} diff --git a/Sources/AXe/Resources/skills/axe/SKILL.md b/Sources/AXe/Resources/skills/axe/SKILL.md index 0e690f8..b038a67 100644 --- a/Sources/AXe/Resources/skills/axe/SKILL.md +++ b/Sources/AXe/Resources/skills/axe/SKILL.md @@ -1,23 +1,25 @@ --- name: axe -description: Provides agent-ready AXe CLI usage guidance for iOS Simulator automation. Use when asked to "use AXe", "automate a simulator", "tap/swipe/type on simulator", "describe UI", "take a screenshot", "record video", "batch steps", or "interact with an iOS app". Covers all commands including touch, gestures, text input, keyboard, buttons, accessibility, screenshots, video, and batch workflows. +description: Provides agent-ready AXe CLI usage guidance for iOS Simulator automation. Use when asked to "use AXe", "automate a simulator", "tap/swipe/type on simulator", "set a slider", "describe UI", "take a screenshot", "record video", "batch steps", or "interact with an iOS app". Covers all commands including touch, gestures, sliders, text input, keyboard, buttons, accessibility, screenshots, video, and batch workflows. --- ## Step 1: Confirm runtime context 1. Identify simulator UDID target first (`axe list-simulators`). 2. Simulator-interaction AXe commands require `--udid `. Commands like `list-simulators` and `init` do not. -3. Run `axe describe-ui --udid ` to inspect the full current screen. Use `axe describe-ui --point --udid ` to inspect the element at a specific coordinate. Use the output to discover available `--id` and `--label` values for selector taps, and to confirm coordinates for coordinate-based taps. -4. Prefer selector taps (`tap --id` / `tap --label`) over raw coordinates. Selectors are resilient to layout changes, work across device sizes, and support element waiting (`--wait-timeout`) in batch flows. For UIKit `UISwitch` and SwiftUI `Toggle` rows, selector taps activate the contained switch/toggle when the match contains exactly one such control. Default tap style is `automatic`: switches/toggles use physical touch down/up, while normal taps use simulator `tapAt`. +3. Run `axe describe-ui --udid ` to inspect the full current screen. Use `axe describe-ui --point --udid ` to inspect the element at a specific coordinate. Use the output to discover available `--id` and `--label` values for selector taps and slider setting, and to confirm coordinates for coordinate-based taps. +4. Prefer selectors (`tap --id` / `tap --label`, `slider --id` / `slider --label`) over raw coordinates. Selectors are resilient to layout changes, work across device sizes, and support element waiting where documented. For UIKit `UISwitch` and SwiftUI `Toggle` rows, selector taps activate the contained switch/toggle when the match contains exactly one such control. Default tap style is `automatic`: switches/toggles use physical touch down/up, while normal taps use simulator `tapAt`. ## Step 2: Choose the right command -Available commands: `init`, `tap`, `swipe`, `gesture`, `touch`, `type`, `button`, `key`, `key-sequence`, `key-combo`, `batch`, `describe-ui`, `screenshot`, `record-video`, `stream-video`, `list-simulators`. Run `axe --help` or `axe --help` for full options. +Available commands: `init`, `tap`, `slider`, `swipe`, `gesture`, `touch`, `type`, `button`, `key`, `key-sequence`, `key-combo`, `batch`, `describe-ui`, `screenshot`, `record-video`, `stream-video`, `list-simulators`. Run `axe --help` or `axe --help` for full options. Common examples: ```bash axe tap --id --udid axe tap --label --udid axe tap --label 'Weather Alerts' --udid +axe slider --id --value 75 --udid +axe slider --label --value 40 --element-type Slider --udid axe tap -x -y --tap-style physical --udid axe tap -x -y --udid axe type 'text' --udid @@ -28,14 +30,15 @@ axe screenshot --udid --output screenshot.png ## Step 3: Understand the execution model -HID commands (`tap`, `swipe`, `type`, `key`, etc.) are fire-and-forget — AXe confirms the event was dispatched to the simulator but cannot verify the app actually processed it. A tap may land before a view is interactive, or during a transition. This means: -- Always verify outcomes separately with `describe-ui` or `screenshot`. -- Use `--wait-timeout` in batch to wait for elements to appear, and `sleep` steps or `--pre-delay` / `--post-delay` to allow animations to settle. +Most HID commands (`tap`, `swipe`, `type`, `key`, etc.) are fire-and-forget — AXe confirms the event was dispatched to the simulator but cannot verify the app actually processed it. A tap may land before a view is interactive, or during a transition. `slider` is the exception: it re-reads the matched slider AXValue and fails if the requested 0-100 value was not reached. This means: +- Always verify outcomes separately with `describe-ui` or `screenshot` when app behavior matters beyond the direct command result. +- Use `--wait-timeout` in batch to wait for tap elements to appear, and `sleep` steps or `--pre-delay` / `--post-delay` to allow animations to settle. ## Step 4: Apply timing and input best practices - Use `--pre-delay` / `--post-delay` on tap, swipe, and gesture commands for fixed delays around actions. - Use `--duration` to control how long a swipe, gesture, button press, or key press lasts. - Coordinate-based `tap`, `swipe`, and `touch` accept coordinates from `describe-ui` directly; AXe detects rotated landscape simulator orientation and letterboxed landscape-only app layouts automatically. +- Use `axe slider --id --value <0-100>` for sliders instead of approximating with raw swipe coordinates; it uses the slider frame/current AXValue and verifies the result. - For text with shell-sensitive characters, prefer `--stdin` or `--file` over inline quotes. - Use single quotes for inline text arguments to avoid shell expansion issues. @@ -48,6 +51,7 @@ HID commands (`tap`, `swipe`, `type`, `key`, etc.) are fire-and-forget — AXe c **Fall back to discrete commands** when: - A step's parameters depend on runtime inspection of a previous step's result (e.g. parsing `describe-ui` JSON to choose coordinates dynamically). +- Setting a slider value with `slider`; batch steps do not support slider verification. **Handling animations and transitions in batch:** - Use `--wait-timeout ` so selector taps (`--id` / `--label`) poll the accessibility tree until the element appears or the timeout expires. This is the primary mechanism for multi-screen flows. diff --git a/Sources/AXe/Utilities/AccessibilityElement.swift b/Sources/AXe/Utilities/AccessibilityElement.swift index e54bbf0..775cb41 100644 --- a/Sources/AXe/Utilities/AccessibilityElement.swift +++ b/Sources/AXe/Utilities/AccessibilityElement.swift @@ -11,6 +11,7 @@ struct AccessibilityElement: Decodable { "RadioButton", "SecureTextField", "SegmentedControl", + "Slider", "Switch", "Tab", "TabBarButton", @@ -107,7 +108,21 @@ struct AccessibilityElement: Decodable { } var isActionable: Bool { - isSwitchLikeControl || type.map(Self.actionableTypes.contains) == true + isSwitchLikeControl || isSliderLikeControl || type.map(Self.actionableTypes.contains) == true + } + + var isSliderLikeControl: Bool { + if type == "Slider" { + return true + } + if role == "AXSlider" || subrole == "AXSlider" { + return true + } + if let roleDescription = trimmed(roleDescription)?.lowercased(), + roleDescription.contains("slider") { + return true + } + return false } var isSwitchLikeControl: Bool { diff --git a/Sources/AXe/Utilities/AccessibilityPoller.swift b/Sources/AXe/Utilities/AccessibilityPoller.swift index 90a8ef5..ad7cc79 100644 --- a/Sources/AXe/Utilities/AccessibilityPoller.swift +++ b/Sources/AXe/Utilities/AccessibilityPoller.swift @@ -15,7 +15,28 @@ struct AccessibilityPoller { waitTimeout: waitTimeout, pollInterval: pollInterval, elementType: elementType, - logger: logger + logger: logger, + resolver: AccessibilityTargetResolver.resolveTap + ) { + try await AccessibilityFetcher.fetchAccessibilityElements(for: simulatorUDID, logger: logger) + } + } + + static func resolveElementWithPolling( + query: AccessibilityQuery, + simulatorUDID: String, + waitTimeout: TimeInterval, + pollInterval: TimeInterval, + elementType: String? = nil, + logger: AxeLogger + ) async throws -> AccessibilityMatch { + try await pollForResolution( + query: query, + waitTimeout: waitTimeout, + pollInterval: pollInterval, + elementType: elementType, + logger: logger, + resolver: AccessibilityTargetResolver.resolveElement ) { try await AccessibilityFetcher.fetchAccessibilityElements(for: simulatorUDID, logger: logger) } @@ -29,9 +50,29 @@ struct AccessibilityPoller { logger: AxeLogger, rootsFetcher: () async throws -> [AccessibilityElement] ) async throws -> TapResolution { + try await pollForResolution( + query: query, + waitTimeout: waitTimeout, + pollInterval: pollInterval, + elementType: elementType, + logger: logger, + resolver: AccessibilityTargetResolver.resolveTap, + rootsFetcher: rootsFetcher + ) + } + + private static func pollForResolution( + query: AccessibilityQuery, + waitTimeout: TimeInterval, + pollInterval: TimeInterval, + elementType: String?, + logger: AxeLogger, + resolver: ([AccessibilityElement], AccessibilityQuery, String?) throws -> T, + rootsFetcher: () async throws -> [AccessibilityElement] + ) async throws -> T { let roots = try await rootsFetcher() do { - return try AccessibilityTargetResolver.resolveTap(roots: roots, query: query, elementType: elementType) + return try resolver(roots, query, elementType) } catch let error as ElementResolutionError where error.isNotFound && waitTimeout > 0 { let clock = ContinuousClock() let deadline = clock.now + .seconds(waitTimeout) @@ -43,7 +84,7 @@ struct AccessibilityPoller { let freshRoots = try await rootsFetcher() do { - return try AccessibilityTargetResolver.resolveTap(roots: freshRoots, query: query, elementType: elementType) + return try resolver(freshRoots, query, elementType) } catch let retryError as ElementResolutionError where retryError.isNotFound { lastError = retryError continue diff --git a/Sources/AXe/Utilities/AccessibilityTargetResolver.swift b/Sources/AXe/Utilities/AccessibilityTargetResolver.swift index 3bd4a61..0fd64de 100644 --- a/Sources/AXe/Utilities/AccessibilityTargetResolver.swift +++ b/Sources/AXe/Utilities/AccessibilityTargetResolver.swift @@ -44,6 +44,11 @@ enum ElementResolutionError: LocalizedError { } } +struct AccessibilityMatch { + let element: AccessibilityElement + let selectorDescription: String +} + struct AccessibilityTargetResolver { static let describeUITip = "Make sure the app is on the expected screen, then run `axe describe-ui --udid ` and prefer --id when available." @@ -58,11 +63,11 @@ struct AccessibilityTargetResolver { try resolveTap(roots: roots, query: query, elementType: elementType).point } - static func resolveTap( + static func resolveElement( roots: [AccessibilityElement], query: AccessibilityQuery, elementType: String? = nil - ) throws -> TapResolution { + ) throws -> AccessibilityMatch { var allElements = roots.flatMap { $0.flattened() } if let elementType { @@ -90,10 +95,20 @@ struct AccessibilityTargetResolver { selectorDescription = "--value '\(rawValue)'" } + return AccessibilityMatch(element: matchedElement, selectorDescription: selectorDescription) + } + + static func resolveTap( + roots: [AccessibilityElement], + query: AccessibilityQuery, + elementType: String? = nil + ) throws -> TapResolution { + let match = try resolveElement(roots: roots, query: query, elementType: elementType) + let activationElement = try selectActivationElement( - from: matchedElement, + from: match.element, roots: roots, - selectorDescription: selectorDescription, + selectorDescription: match.selectorDescription, allowSiblingRedirection: query.allowsSiblingRedirection ) diff --git a/Sources/AXe/main.swift b/Sources/AXe/main.swift index d07121a..98e4038 100644 --- a/Sources/AXe/main.swift +++ b/Sources/AXe/main.swift @@ -18,6 +18,7 @@ struct Axe: AsyncParsableCommand { ListSimulators.self, Init.self, Tap.self, + Slider.self, Type.self, Swipe.self, Button.self, diff --git a/Tests/AccessibilityTargetResolverTests.swift b/Tests/AccessibilityTargetResolverTests.swift index c8343e7..e79b98b 100644 --- a/Tests/AccessibilityTargetResolverTests.swift +++ b/Tests/AccessibilityTargetResolverTests.swift @@ -259,6 +259,31 @@ struct AccessibilityTargetResolverTests { #expect(!resolution.isSwitchLikeControl) } + @Test("Resolved element preserves slider frame and AXValue") + func resolvedElementPreservesSliderFrameAndAXValue() throws { + let roots = try decodeElements( + """ + [ + { + "type": "Slider", + "role": "AXSlider", + "frame": { "x": 40, "y": 200, "width": 300, "height": 40 }, + "AXLabel": "Volume", + "AXUniqueId": "volume-slider", + "AXValue": "0.25" + } + ] + """ + ) + + let match = try AccessibilityTargetResolver.resolveElement(roots: roots, query: .label("Volume"), elementType: "Slider") + + #expect(match.selectorDescription == "--label 'Volume'") + #expect(match.element.isSliderLikeControl) + #expect(match.element.frame?.x == 40) + #expect(match.element.normalizedValue == "0.25") + } + private func decodeElements(_ json: String) throws -> [AccessibilityElement] { let data = try #require(json.data(using: .utf8)) return try JSONDecoder().decode([AccessibilityElement].self, from: data) diff --git a/Tests/README.md b/Tests/README.md index 904e348..cc14c47 100644 --- a/Tests/README.md +++ b/Tests/README.md @@ -9,6 +9,7 @@ Each AXe command has its own dedicated test file: - `ListSimulatorsTests.swift` - Tests for `list-simulators` command - `DescribeUITests.swift` - Tests for `describe-ui` command - `TapTests.swift` - Tests for `tap` command +- `SliderTests.swift` - Tests for `slider` command - `SwipeTests.swift` - Tests for `swipe` command - `TypeTests.swift` - Tests for `type` command - `KeyTests.swift` - Tests for `key` and `key-sequence` commands @@ -30,6 +31,7 @@ AXE_E2E=1 SIMULATOR_UDID= swift test # Run specific test files swift test --filter TapTests +swift test --filter SliderTests swift test --filter SwipeTests swift test --filter TypeTests swift test --filter KeyTests @@ -66,6 +68,7 @@ Each test file can be run directly: ```bash swift test --filter TapTests +swift test --filter SliderTests swift test --filter SwipeTests ``` diff --git a/Tests/SliderTests.swift b/Tests/SliderTests.swift new file mode 100644 index 0000000..39ceab3 --- /dev/null +++ b/Tests/SliderTests.swift @@ -0,0 +1,107 @@ +import Testing +import Foundation + +@Suite("Slider Command Surface Tests") +struct SliderCommandSurfaceTests { + @Test("Slider help includes selector and value options") + func sliderHelpIncludesSelectorAndValueOptions() async throws { + let result = try await TestHelpers.runAxeCommand("slider --help") + + #expect(result.output.contains("--id")) + #expect(result.output.contains("--label")) + #expect(result.output.contains("--value")) + #expect(result.output.contains("--element-type")) + } + + @Test("Invalid slider value fails validation") + func invalidSliderValueFailsValidation() async throws { + let result = try await TestHelpers.runAxeCommandAllowFailure("slider --id slider-value-slider --value 101 --udid invalid") + + #expect(result.exitCode != 0) + #expect(result.output.contains("--value must be a finite number between 0 and 100.")) + } + + @Test("Missing slider selector fails validation") + func missingSliderSelectorFailsValidation() async throws { + let result = try await TestHelpers.runAxeCommandAllowFailure("slider --value 75 --udid invalid") + + #expect(result.exitCode != 0) + #expect(result.output.contains("Use exactly one of --id or --label to target a slider.")) + } +} + +@Suite("Slider Command Tests", .serialized, .enabled(if: isE2EEnabled)) +struct SliderTests { + private let exactValueTolerance = 0.004 + + @Test("Slider command sets value by accessibility identifier") + func sliderCommandSetsValueByAccessibilityIdentifier() async throws { + try await TestHelpers.launchPlaygroundApp(to: "slider-value-test") + + let initial = try await TestHelpers.waitForLabel(containing: "Slider Position:", timeout: 3) { + $0 == "Slider Position: 0.25" + } + #expect(initial == "Slider Position: 0.25") + + try await TestHelpers.runAxeCommand( + "slider --id slider-value-slider --value 75 --element-type Slider", + simulatorUDID: defaultSimulatorUDID + ) + + let slider = try await waitForSliderState(expectedLabel: "Slider Position: 0.75", expectedNormalizedValue: 0.75) + #expect(slider.type == "Slider") + } + + @Test("Slider command sets value by accessibility label") + func sliderCommandSetsValueByAccessibilityLabel() async throws { + try await TestHelpers.launchPlaygroundApp(to: "slider-value-test") + + try await TestHelpers.runAxeCommand( + "slider --label 'Slider Value Slider' --value 40 --element-type Slider", + simulatorUDID: defaultSimulatorUDID + ) + + let slider = try await waitForSliderState(expectedLabel: "Slider Position: 0.40", expectedNormalizedValue: 0.40) + #expect(slider.type == "Slider") + } + + private func waitForSliderState(expectedLabel: String, expectedNormalizedValue: Double) async throws -> UIElement { + let deadline = Date().addingTimeInterval(3) + var lastLabel: String? + var lastSliderValue: String? + + while Date() < deadline { + let uiState = try await TestHelpers.getUIState() + let positionLabel = UIStateParser.findElementByLabel(in: uiState, label: expectedLabel)?.label + lastLabel = UIStateParser.findElementContainingLabel(in: uiState, containing: "Slider Position:")?.label + + if let slider = UIStateParser.findElement(in: uiState, withIdentifier: "slider-value-slider") { + lastSliderValue = slider.value + if positionLabel == expectedLabel, + let observedValue = normalizedSliderValue(slider.value), + abs(observedValue - expectedNormalizedValue) <= exactValueTolerance { + return slider + } + } + + try await Task.sleep(nanoseconds: 200_000_000) + } + + throw TestError.unexpectedState( + "Timed out waiting for \(expectedLabel) and slider AXValue near \(expectedNormalizedValue). Last label: \(lastLabel ?? "none"), last AXValue: \(lastSliderValue ?? "none")" + ) + } + + private func normalizedSliderValue(_ rawValue: String?) -> Double? { + guard let rawValue else { return nil } + + let trimmedValue = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + let isPercent = trimmedValue.hasSuffix("%") + let numericText = trimmedValue.replacingOccurrences(of: "%", with: "") + guard let parsedValue = Double(numericText.trimmingCharacters(in: .whitespacesAndNewlines)) else { + return nil + } + + return isPercent || parsedValue > 1.0 ? parsedValue / 100.0 : parsedValue + } +} diff --git a/USAGE_EXAMPLES.md b/USAGE_EXAMPLES.md index 829b724..9122f06 100644 --- a/USAGE_EXAMPLES.md +++ b/USAGE_EXAMPLES.md @@ -83,7 +83,19 @@ axe touch -x 150 -y 250 --up --udid SIMULATOR_UDID # Touch up only axe touch -x 150 -y 250 --down --up --delay 1.0 --udid SIMULATOR_UDID # Touch with delay ``` -### **3. Gesture Presets** 🆕 +### **3. Sliders** + +```bash +# Set a slider by accessibility identifier to a 0-100 percentage +axe slider --id slider-value-slider --value 75 --udid SIMULATOR_UDID + +# Set a slider by accessibility label and narrow matching to slider elements +axe slider --label "Volume" --value 40 --element-type Slider --udid SIMULATOR_UDID +``` + +The `slider` command uses the slider's accessibility frame and current AXValue, performs an orientation-aware HID drag, then verifies the new AXValue. Use `describe-ui` first to find reliable `--id` or `--label` selectors. + +### **4. Gesture Presets** 🆕 ```bash # Scrolling gestures @@ -111,7 +123,7 @@ axe gesture scroll-down --pre-delay 1.0 --post-delay 0.5 --udid SIMULATOR_UDID axe gesture swipe-from-left-edge --pre-delay 2.0 --duration 0.8 --post-delay 1.0 --udid SIMULATOR_UDID ``` -### **4. Hardware Buttons** +### **5. Hardware Buttons** ```bash # Available buttons: home, lock, side-button, siri, apple-pay @@ -129,7 +141,7 @@ axe button siri --udid SIMULATOR_UDID axe button apple-pay --udid SIMULATOR_UDID ``` -### **5. Keyboard Control** +### **6. Keyboard Control** ```bash # Individual key presses by keycode @@ -148,7 +160,7 @@ axe key-combo --modifiers 227 --key 25 --udid SIMULATOR_UDID # Cmd+V axe key-combo --modifiers 227,225 --key 4 --udid SIMULATOR_UDID # Cmd+Shift+A ``` -### **6. Batch Workflows** +### **7. Batch Workflows** ```bash # Chain steps with one simulator/HID session @@ -284,6 +296,9 @@ axe gesture swipe-from-right-edge --pre-delay 1.0 --duration 0.3 --udid SIMULATO # Get accessibility info axe describe-ui --point 100,200 --udid SIMULATOR_UDID +# Set a discovered slider control +axe slider --id slider-value-slider --value 75 --udid SIMULATOR_UDID + # Navigate with presets and keyboard axe gesture scroll-down --post-delay 1.0 --udid SIMULATOR_UDID # Scroll to content axe key-sequence --keycodes 43,43,40 --delay 1.0 --udid SIMULATOR_UDID # Tab navigation @@ -461,7 +476,8 @@ This benchmark compares equivalent two-tap workflows and reports per-iteration l 5. **No Shell Escaping**: Use `--stdin` or `--file` for complex text 6. **Automation-Friendly**: Perfect for CI/CD and testing scripts 7. **Flexible Input Methods**: Multiple ways to provide input and control timing -8. **Comprehensive Validation**: Built-in parameter validation and error handling +8. **Deterministic Slider Setting**: Slider controls can be set by selector with AXValue verification +9. **Comprehensive Validation**: Built-in parameter validation and error handling ## Common Keycodes Reference diff --git a/test-runner.sh b/test-runner.sh index 690b13f..051d732 100755 --- a/test-runner.sh +++ b/test-runner.sh @@ -59,6 +59,7 @@ show_usage() { echo "" echo "Test Filters (optional):" echo " SwipeTests Run only swipe tests" + echo " SliderTests Run only slider tests" echo " TapTests Run only tap tests" echo " KeyTests Run only key tests" echo " TouchTests Run only touch tests" @@ -109,7 +110,7 @@ while [[ $# -gt 0 ]]; do VERBOSE=true shift ;; - BatchTests|SwipeTests|TapTests|KeyTests|TouchTests|TypeTests|ButtonTests|GestureTests|ListSimulatorsTests) + BatchTests|SwipeTests|SliderTests|TapTests|KeyTests|TouchTests|TypeTests|ButtonTests|GestureTests|ListSimulatorsTests) TEST_FILTER="$1" shift ;; @@ -339,6 +340,7 @@ run_tests() { "StreamVideoDebugTests" "StreamVideoTests" "SwipeTests" + "SliderTests" "TapTests" "TouchTests" "TypeTests" From 0723b7b93027340403a9212a185753d30b362729 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 11 May 2026 12:02:12 +0100 Subject: [PATCH 2/8] fix(slider): Correct slider drag geometry Map requested values onto the slider thumb-center range instead of the raw accessibility frame width. This keeps deterministic slider setting aligned with the visible SwiftUI slider position. Co-Authored-By: Codex --- Sources/AXe/Commands/Slider.swift | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/Sources/AXe/Commands/Slider.swift b/Sources/AXe/Commands/Slider.swift index 485c39a..e07e63f 100644 --- a/Sources/AXe/Commands/Slider.swift +++ b/Sources/AXe/Commands/Slider.swift @@ -121,9 +121,10 @@ struct Slider: AsyncParsableCommand { let currentNormalized = try parseNormalizedAXValue(element.normalizedValue) let centerY = frame.y + (frame.height / 2.0) + let thumbCenterRange = thumbCenterRange(for: frame) return SliderAdjustment( logicalStart: (x: frame.x + (frame.width * currentNormalized), y: centerY), - logicalEnd: (x: frame.x + (frame.width * targetNormalized), y: centerY), + logicalEnd: (x: thumbCenterRange.x(for: targetNormalized), y: centerY), currentNormalized: currentNormalized, targetNormalized: targetNormalized ) @@ -296,9 +297,6 @@ struct Slider: AsyncParsableCommand { upperBound: SliderCommandObservation? ) -> Double { let correction = targetNormalized - observedNormalized - if abs(correction) <= 0.02 { - return targetNormalized - } if let lowerBound, let upperBound, @@ -320,6 +318,14 @@ struct Slider: AsyncParsableCommand { return clampedNormalized(max(corrected, lowerBound?.commanded ?? 0.0)) } + private func thumbCenterRange(for frame: AccessibilityElement.Frame) -> SliderThumbCenterRange { + let thumbRadius = frame.height / 2.0 + return SliderThumbCenterRange( + minX: frame.x - thumbRadius, + maxX: frame.x + frame.width + thumbRadius + ) + } + private func clampedNormalized(_ value: Double) -> Double { min(max(value, 0.0), 1.0) } @@ -359,6 +365,15 @@ struct Slider: AsyncParsableCommand { } } +private struct SliderThumbCenterRange { + let minX: Double + let maxX: Double + + func x(for normalizedValue: Double) -> Double { + minX + ((maxX - minX) * normalizedValue) + } +} + private struct SliderAdjustment { let logicalStart: (x: Double, y: Double) let logicalEnd: (x: Double, y: Double) From 3b27e962314fa57ba35011b5a3304dbd6944e5a0 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 11 May 2026 20:23:53 +0100 Subject: [PATCH 3/8] feat(drag): Add precision drag command Add a first-class drag command that sends a single low-level HID gesture from one point to another using touch-down, touch-move, and touch-up events. Reuse the same composite drag primitive from the slider command so slider movement no longer owns its own gesture builder. Add playground-backed E2E coverage that runs the drag command and verifies recorded start and end coordinates with describe-ui. Keep slider verification based on observable AXValue tolerance because SwiftUI slider values are quantized. Co-Authored-By: OpenAI Codex --- .../Views/AccessibilityFixturesView.swift | 17 ++ CHANGELOG.md | 7 +- README.md | 13 +- Skills/CLI/axe/SKILL.md | 11 +- .../CLI/axe/references/cli-quick-reference.md | 20 +- Sources/AXe/Commands/Drag.swift | 104 +++++++++ Sources/AXe/Commands/Slider.swift | 211 ++++++------------ Sources/AXe/Resources/skills/axe/SKILL.md | 11 +- Sources/AXe/Utilities/HIDInteractor.swift | 59 +++++ Sources/AXe/main.swift | 1 + Tests/DragTests.swift | 99 ++++++++ Tests/README.md | 3 + Tests/SliderTests.swift | 110 ++++++--- USAGE_EXAMPLES.md | 20 +- test-runner.sh | 5 +- 15 files changed, 496 insertions(+), 195 deletions(-) create mode 100644 Sources/AXe/Commands/Drag.swift create mode 100644 Tests/DragTests.swift diff --git a/AxePlaygroundApp/AxePlayground/Views/AccessibilityFixturesView.swift b/AxePlaygroundApp/AxePlayground/Views/AccessibilityFixturesView.swift index f9ad2a0..93cda18 100644 --- a/AxePlaygroundApp/AxePlayground/Views/AccessibilityFixturesView.swift +++ b/AxePlaygroundApp/AxePlayground/Views/AccessibilityFixturesView.swift @@ -1,9 +1,18 @@ +import Foundation import SwiftUI struct SliderValueTestView: View { @State private var sliderValue = 0.25 @State private var state = "Initial" + private var percentText: String { + String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), sliderValue * 100.0) + } + + private var exactValueText: String { + String(format: "%.4f", locale: Locale(identifier: "en_US_POSIX"), sliderValue) + } + var body: some View { VStack(spacing: 24) { Text("Slider Value State: \(state)") @@ -20,6 +29,14 @@ struct SliderValueTestView: View { .accessibilityIdentifier("slider-position-value") .accessibilityValue(sliderValue.formatted(.number.precision(.fractionLength(2)))) + Text("Slider Percent State: \(percentText)") + .accessibilityIdentifier("slider-percent-state") + .accessibilityValue(percentText) + + Text("Slider Exact Value: \(exactValueText)") + .accessibilityIdentifier("slider-exact-value-state") + .accessibilityValue(exactValueText) + Slider(value: $sliderValue, in: 0...1) .accessibilityIdentifier("slider-value-slider") .accessibilityLabel("Slider Value Slider") diff --git a/CHANGELOG.md b/CHANGELOG.md index fefd645..6ee94af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added `axe slider --id/--label --value 0...100` for deterministic selector-based slider setting with orientation-aware HID dragging and AXValue verification. +- Added `axe slider --id/--label --value 0...100` for selector-based slider setting with orientation-aware HID dragging and AXValue tolerance verification/failure reporting. +- Added `axe drag --start-x/--start-y --end-x/--end-y` for raw point-to-point low-level HID drag validation using explicit touch move events. + +### Changed + +- Changed `axe slider` to use the shared composite low-level HID drag path with AXValue tolerance verification instead of retrying with correction gestures. ### Fixed diff --git a/README.md b/README.md index 9ac29b1..15d7216 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,9 @@ AXe provides complete iOS Simulator automation capabilities: ### Touch & Gestures - **Tap**: Precise touch events at specific coordinates with timing controls - **Swipe**: Multi-touch gestures with configurable duration and delta +- **Drag**: Raw point-to-point low-level HID drags with explicit touch move events - **Touch Control**: Low-level touch down/up events for advanced gesture control -- **Sliders**: Set slider controls to an exact 0-100 percentage by accessibility identifier or label +- **Sliders**: Set slider controls to a verified 0-100 percentage tolerance by accessibility identifier or label - **Gesture Presets**: Common gesture patterns (scroll-up, scroll-down, scroll-left, scroll-right, edge swipes) - **Batch Chaining**: Execute ordered multi-step interaction workflows in one invocation @@ -149,6 +150,7 @@ axe tap -x 320 -y 780 --tap-style physical --udid $UDID # Force physical touch axe slider --id "volume-slider" --value 75 --udid $UDID axe type 'Hello World!' --udid $UDID axe swipe --start-x 100 --start-y 300 --end-x 300 --end-y 100 --udid $UDID +axe drag --start-x 100 --start-y 400 --end-x 300 --end-y 400 --udid $UDID axe button home --udid $UDID # Screenshot @@ -202,11 +204,16 @@ axe tap -x 320 -y 780 --tap-style physical --udid SIMULATOR_UDID # Force touch axe swipe --start-x 100 --start-y 300 --end-x 300 --end-y 100 --udid SIMULATOR_UDID axe swipe --start-x 50 --start-y 500 --end-x 350 --end-y 500 --duration 2.0 --delta 25 --udid SIMULATOR_UDID +# Raw low-level drag using explicit touch move events +axe drag --start-x 100 --start-y 400 --end-x 300 --end-y 400 --udid SIMULATOR_UDID +axe drag --start-x 100 --start-y 400 --end-x 300 --end-y 400 --duration 0.4 --steps 40 --udid SIMULATOR_UDID + # Orientation-aware coordinates # AXe automatically maps logical UI coordinates for rotated landscape and letterboxed landscape-only apps. # Use the coordinates from describe-ui directly; AXe detects the simulator orientation. axe tap -x 100 -y 200 --udid SIMULATOR_UDID axe swipe --start-x 100 --start-y 300 --end-x 300 --end-y 100 --udid SIMULATOR_UDID +axe drag --start-x 100 --start-y 400 --end-x 300 --end-y 400 --udid SIMULATOR_UDID # Advanced touch control axe touch -x 150 -y 250 --down --udid SIMULATOR_UDID @@ -219,14 +226,14 @@ axe touch -x 150 -y 250 --down --up --delay 1.0 --udid SIMULATOR_UDID ### **Sliders** ```bash -# Set a slider by accessibility identifier to a 0-100 percentage +# Set a slider by accessibility identifier to a verified 0-100 percentage axe slider --id slider-value-slider --value 75 --udid SIMULATOR_UDID # Set a slider by label and narrow matching to slider elements axe slider --label "Volume" --value 40 --element-type Slider --udid SIMULATOR_UDID ``` -`slider` resolves the real accessibility slider element, drags from its current AXValue-derived position to the requested percentage, then re-reads AXValue to verify the result. +`slider` resolves the real accessibility slider element, performs one calibrated low-level HID drag from its current AXValue-derived position toward the requested percentage using the same composite touch-move path as `drag`, and re-reads AXValue to verify the result. Since iOS slider controls quantize values to their rendered track resolution, AXe verifies that the observed value is within tolerance rather than retrying correction gestures to chase unreachable decimals. If the observed AXValue remains outside tolerance after that drag, the command fails clearly. ### **Gesture Presets** diff --git a/Skills/CLI/axe/SKILL.md b/Skills/CLI/axe/SKILL.md index e033d00..58a3fa2 100644 --- a/Skills/CLI/axe/SKILL.md +++ b/Skills/CLI/axe/SKILL.md @@ -11,7 +11,7 @@ description: Provides agent-ready AXe CLI usage guidance for iOS Simulator autom ## Step 2: Choose the right command -Available commands: `init`, `tap`, `slider`, `swipe`, `gesture`, `touch`, `type`, `button`, `key`, `key-sequence`, `key-combo`, `batch`, `describe-ui`, `screenshot`, `record-video`, `stream-video`, `list-simulators`. Run `axe --help` or `axe --help` for full options. +Available commands: `init`, `tap`, `slider`, `swipe`, `drag`, `gesture`, `touch`, `type`, `button`, `key`, `key-sequence`, `key-combo`, `batch`, `describe-ui`, `screenshot`, `record-video`, `stream-video`, `list-simulators`. Run `axe --help` or `axe --help` for full options. Common examples: ```bash @@ -20,6 +20,7 @@ axe tap --label --udid axe tap --label 'Weather Alerts' --udid axe slider --id --value 75 --udid axe slider --label --value 40 --element-type Slider --udid +axe drag --start-x --start-y --end-x --end-y --udid axe tap -x -y --tap-style physical --udid axe tap -x -y --udid axe type 'text' --udid @@ -30,15 +31,15 @@ axe screenshot --udid --output screenshot.png ## Step 3: Understand the execution model -Most HID commands (`tap`, `swipe`, `type`, `key`, etc.) are fire-and-forget — AXe confirms the event was dispatched to the simulator but cannot verify the app actually processed it. A tap may land before a view is interactive, or during a transition. `slider` is the exception: it re-reads the matched slider AXValue and fails if the requested 0-100 value was not reached. This means: +Most HID commands (`tap`, `swipe`, `drag`, `type`, `key`, etc.) are fire-and-forget — AXe confirms the event was dispatched to the simulator but cannot verify the app actually processed it. A tap may land before a view is interactive, or during a transition. `slider` is the exception: it performs one selector-resolved low-level HID drag, re-reads the matched slider AXValue, and fails if the observed 0-100 value is outside tolerance. iOS slider controls quantize values to their rendered track resolution, so AXe does not retry correction gestures to chase unreachable decimals. This means: - Always verify outcomes separately with `describe-ui` or `screenshot` when app behavior matters beyond the direct command result. - Use `--wait-timeout` in batch to wait for tap elements to appear, and `sleep` steps or `--pre-delay` / `--post-delay` to allow animations to settle. ## Step 4: Apply timing and input best practices - Use `--pre-delay` / `--post-delay` on tap, swipe, and gesture commands for fixed delays around actions. - Use `--duration` to control how long a swipe, gesture, button press, or key press lasts. -- Coordinate-based `tap`, `swipe`, and `touch` accept coordinates from `describe-ui` directly; AXe detects rotated landscape simulator orientation and letterboxed landscape-only app layouts automatically. -- Use `axe slider --id --value <0-100>` for sliders instead of approximating with raw swipe coordinates; it uses the slider frame/current AXValue and verifies the result. +- Coordinate-based `tap`, `swipe`, `drag`, and `touch` accept coordinates from `describe-ui` directly; AXe detects rotated landscape simulator orientation and letterboxed landscape-only app layouts automatically. +- Use `axe slider --id --value <0-100>` for sliders instead of approximating with raw swipe coordinates; it uses one calibrated low-level HID drag from the resolved slider frame/current AXValue, through the same composite touch-move path as `drag`, verifies the result within tolerance, and fails clearly if the observed AXValue remains outside tolerance. - For text with shell-sensitive characters, prefer `--stdin` or `--file` over inline quotes. - Use single quotes for inline text arguments to avoid shell expansion issues. @@ -51,7 +52,7 @@ Most HID commands (`tap`, `swipe`, `type`, `key`, etc.) are fire-and-forget — **Fall back to discrete commands** when: - A step's parameters depend on runtime inspection of a previous step's result (e.g. parsing `describe-ui` JSON to choose coordinates dynamically). -- Setting a slider value with `slider`; batch steps do not support slider verification. +- Using `slider`; batch steps do not support slider verification. **Handling animations and transitions in batch:** - Use `--wait-timeout ` so selector taps (`--id` / `--label`) poll the accessibility tree until the element appears or the timeout expires. This is the primary mechanism for multi-screen flows. diff --git a/Skills/CLI/axe/references/cli-quick-reference.md b/Skills/CLI/axe/references/cli-quick-reference.md index 7b7a95a..e3560a0 100644 --- a/Skills/CLI/axe/references/cli-quick-reference.md +++ b/Skills/CLI/axe/references/cli-quick-reference.md @@ -37,7 +37,7 @@ axe slider --id "volume-slider" --value 75 --udid axe slider --label "Volume" --value 40 --element-type Slider --udid ``` -`slider` resolves the matched accessibility slider, uses its frame/current AXValue for the drag start and end points, and re-reads AXValue to verify the requested value was reached. +`slider` resolves the matched accessibility slider, uses its frame/current AXValue for one calibrated low-level HID drag through the same composite touch-move path as `drag`, and re-reads AXValue. Since iOS slider controls quantize values to their rendered track resolution, AXe verifies that the observed value is within tolerance rather than retrying correction gestures to chase unreachable decimals. If the observed value remains outside tolerance, the command fails clearly. ## Swipe @@ -51,6 +51,15 @@ axe swipe --start-x 50 --start-y 500 --end-x 350 --end-y 500 --duration 2.0 --de axe swipe --start-x 100 --start-y 300 --end-x 300 --end-y 100 --pre-delay 1.0 --post-delay 0.5 --udid ``` +## Drag (low-level) + +```bash +axe drag --start-x 100 --start-y 400 --end-x 300 --end-y 400 --udid +axe drag --start-x 100 --start-y 400 --end-x 300 --end-y 400 --duration 0.4 --steps 40 --udid +``` + +`drag` emits one composite low-level HID event: touch down at the start point, a sequence of explicit touch move events, then touch up at the end point. + ## Touch (low-level) ```bash @@ -225,14 +234,15 @@ axe stream-video --udid --fps 30 --format ffmpeg | \ | Parameter | Range | Description | Available on | |---|---|---|---| -| `--pre-delay` | 0–10s | Delay before action | tap, swipe, gesture | -| `--post-delay` | 0–10s | Delay after action | tap, swipe, gesture | -| `--duration` | 0–10s | Action duration | swipe, gesture, button, key | +| `--pre-delay` | 0–10s | Delay before action | tap, swipe, drag, gesture | +| `--post-delay` | 0–10s | Delay after action | tap, swipe, drag, gesture | +| `--duration` | 0–10s | Action duration | swipe, drag, gesture, button, key | +| `--steps` | 1–1000 | Touch move event count | drag | | `--value` | 0–100 | Target slider percentage | slider | | `--delay` | 0–5s | Between-item delay | key-sequence, touch | ## Best practices -- Prefer `--id` / `--label` selectors over coordinates for resilience; use `slider` for slider values instead of raw swipe coordinates. +- Prefer `--id` / `--label` selectors over coordinates for resilience; use `slider` for selector-resolved low-level HID slider dragging with AXValue tolerance verification instead of raw swipe coordinates, and use `drag` when you specifically need raw point-to-point HID drag behavior. - Selector taps activate a contained UIKit `UISwitch` or SwiftUI `Toggle` when the matched row or label contains exactly one switch/toggle. - Default `--tap-style automatic` uses physical touch for matched switches/toggles and simulator `tapAt` for normal taps; use `--tap-style physical|simulator` to override. - Use single quotes for inline text to avoid shell expansion. diff --git a/Sources/AXe/Commands/Drag.swift b/Sources/AXe/Commands/Drag.swift new file mode 100644 index 0000000..1dd7e1f --- /dev/null +++ b/Sources/AXe/Commands/Drag.swift @@ -0,0 +1,104 @@ +import ArgumentParser +import Foundation + +struct Drag: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Perform a low-level point-to-point drag using explicit touch move events." + ) + + private static let defaultDuration: TimeInterval = 0.6 + private static let defaultSteps = 60 + private static let maxSteps = 1_000 + private static let initialHold: TimeInterval = 0.05 + private static let finalHold: TimeInterval = 0.05 + + @Option(name: .customLong("start-x"), help: "The X coordinate of the starting point.") + var startX: Double + + @Option(name: .customLong("start-y"), help: "The Y coordinate of the starting point.") + var startY: Double + + @Option(name: .customLong("end-x"), help: "The X coordinate of the ending point.") + var endX: Double + + @Option(name: .customLong("end-y"), help: "The Y coordinate of the ending point.") + var endY: Double + + @Option(name: .customLong("duration"), help: "Duration of the drag movement in seconds.") + var duration: Double = Self.defaultDuration + + @Option(name: .customLong("steps"), help: "Number of touch move events to emit during the drag.") + var steps: Int = Self.defaultSteps + + @Option(name: .customLong("pre-delay"), help: "Delay before starting the drag in seconds.") + var preDelay: Double? + + @Option(name: .customLong("post-delay"), help: "Delay after completing the drag in seconds.") + var postDelay: Double? + + @Option(name: .customLong("udid"), help: "The UDID of the simulator.") + var simulatorUDID: String + + func validate() throws { + guard startX >= 0, startY >= 0, endX >= 0, endY >= 0 else { + throw ValidationError("Coordinates must be non-negative values.") + } + guard startX != endX || startY != endY else { + throw ValidationError("Start and end points must be different.") + } + guard duration > 0 else { + throw ValidationError("Duration must be greater than 0.") + } + guard (1...Self.maxSteps).contains(steps) else { + throw ValidationError("Steps must be between 1 and \(Self.maxSteps).") + } + if let preDelay { + guard preDelay >= 0 && preDelay <= 10.0 else { + throw ValidationError("Pre-delay must be between 0 and 10 seconds.") + } + } + if let postDelay { + guard postDelay >= 0 && postDelay <= 10.0 else { + throw ValidationError("Post-delay must be between 0 and 10 seconds.") + } + } + } + + func run() async throws { + let logger = AxeLogger() + try await setup(logger: logger) + try await performGlobalSetup(logger: logger) + + logger.info().log("Performing low-level drag from (\(startX), \(startY)) to (\(endX), \(endY))") + logger.info().log("Duration: \(duration)s, steps: \(steps)") + + if let preDelay, preDelay > 0 { + logger.info().log("Pre-delay: \(preDelay)s") + try await Task.sleep(for: .seconds(preDelay)) + } + + let physicalPoints = try await OrientationAwareCoordinates.translateBatch( + points: [(x: startX, y: startY), (x: endX, y: endY)], + for: simulatorUDID, + logger: logger + ) + + try await HIDInteractor.performCompositeDrag( + from: physicalPoints[0], + to: physicalPoints[1], + duration: duration, + steps: steps, + initialHold: Self.initialHold, + finalHold: Self.finalHold, + for: simulatorUDID, + logger: logger + ) + + if let postDelay, postDelay > 0 { + logger.info().log("Post-delay: \(postDelay)s") + try await Task.sleep(for: .seconds(postDelay)) + } + + logger.info().log("Low-level drag completed successfully") + } +} diff --git a/Sources/AXe/Commands/Slider.swift b/Sources/AXe/Commands/Slider.swift index e07e63f..7fc38af 100644 --- a/Sources/AXe/Commands/Slider.swift +++ b/Sources/AXe/Commands/Slider.swift @@ -1,23 +1,22 @@ import ArgumentParser import Foundation -import FBControlCore -import FBSimulatorControl struct Slider: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "Set a slider to a deterministic value from 0 to 100 using accessibility selector targeting." ) - private static let dragDuration = 0.6 - private static let dragStepDelta = 2.0 - private static let dragInitialHold: TimeInterval = 0.05 - private static let dragFinalHold: TimeInterval = 0.2 + private static let directDragSteps = 120 + private static let directDragDuration: TimeInterval = 2.4 + private static let directDragInitialHold: TimeInterval = 0.05 + private static let directDragFinalHold: TimeInterval = 0.2 private static let verificationTimeout: TimeInterval = 1.5 private static let verificationPollInterval: TimeInterval = 0.1 private static let verificationStabilityDelay: TimeInterval = 0.3 - private static let maxAdjustmentAttempts = 8 - private static let valueTolerance = 0.004 - private static let minimumCorrectionStep = 0.005 + private static let alreadyAtTargetTolerance = 0.00005 + private static let valueTolerance = 0.0007 + private static let lowRangeCoordinateOffset = 0.0268 + private static let highRangeCoordinateOffset = 0.0271 @Option(name: [.customLong("id")], help: "Set the slider matching AXUniqueId (accessibilityIdentifier).") var elementID: String? @@ -104,10 +103,10 @@ struct Slider: AsyncParsableCommand { throw CLIError(errorDescription: "Unexpected state: no slider selector.") } - private func makeAdjustment( + private func makeDragPlan( for element: AccessibilityElement, targetNormalized: Double - ) throws -> SliderAdjustment { + ) throws -> SliderDragPlan { guard element.isSliderLikeControl else { let typeDescription = element.type ?? element.role ?? "unknown" throw CLIError(errorDescription: "Matched element is not a slider (type: \(typeDescription)). Use --element-type Slider or a more specific --id/--label selector.") @@ -121,13 +120,25 @@ struct Slider: AsyncParsableCommand { let currentNormalized = try parseNormalizedAXValue(element.normalizedValue) let centerY = frame.y + (frame.height / 2.0) - let thumbCenterRange = thumbCenterRange(for: frame) - return SliderAdjustment( - logicalStart: (x: frame.x + (frame.width * currentNormalized), y: centerY), - logicalEnd: (x: thumbCenterRange.x(for: targetNormalized), y: centerY), + let commandedNormalized = commandedNormalizedValue( currentNormalized: currentNormalized, targetNormalized: targetNormalized ) + let nominalStartX = frame.x + (frame.width * currentNormalized) + let startX = dragStartX( + frame: frame, + nominalStartX: nominalStartX, + currentNormalized: currentNormalized, + targetNormalized: targetNormalized + ) + let fingerOffsetFromNominalStart = startX - nominalStartX + return SliderDragPlan( + logicalStart: (x: startX, y: centerY), + logicalEnd: (x: frame.x + (frame.width * commandedNormalized) + fingerOffsetFromNominalStart, y: centerY), + currentNormalized: currentNormalized, + targetNormalized: targetNormalized, + commandedNormalized: commandedNormalized + ) } private func setAndVerifySliderValue( @@ -136,87 +147,47 @@ struct Slider: AsyncParsableCommand { targetNormalized: Double, logger: AxeLogger ) async throws -> String { - var match = initialMatch - let initialNormalized = try parseNormalizedAXValue(match.element.normalizedValue) - var commandedNormalized = initialCommandedNormalized(currentNormalized: initialNormalized, targetNormalized: targetNormalized) - var lastRawValue = match.element.normalizedValue - var lowerBound = initialNormalized < targetNormalized ? SliderCommandObservation(commanded: initialNormalized, observed: initialNormalized) : nil - var upperBound = initialNormalized > targetNormalized ? SliderCommandObservation(commanded: initialNormalized, observed: initialNormalized) : nil - - for attempt in 1...Self.maxAdjustmentAttempts { - let adjustment = try makeAdjustment(for: match.element, targetNormalized: commandedNormalized) - lastRawValue = match.element.normalizedValue - - logger.info().log( - "Setting slider \(match.selectorDescription) attempt \(attempt) from AXValue \(formatNormalized(adjustment.currentNormalized)) toward \(formatNormalized(targetNormalized))" - ) - - try await performSliderDrag(adjustment, logger: logger) - - let observedValue = try await pollObservedSliderValue( - query: query, - targetNormalized: targetNormalized, - logger: logger - ) - match = observedValue.match - lastRawValue = observedValue.rawValue - - if observedValue.isWithinTolerance { - return lastRawValue ?? formatNormalized(observedValue.normalizedValue) - } - - let observedNormalized = observedValue.normalizedValue + let dragPlan = try makeDragPlan(for: initialMatch.element, targetNormalized: targetNormalized) + logger.info().log( + "Setting slider \(initialMatch.selectorDescription) from AXValue \(formatNormalized(dragPlan.currentNormalized)) toward \(formatNormalized(dragPlan.targetNormalized)) with low-level HID drag" + ) - let observation = SliderCommandObservation(commanded: commandedNormalized, observed: observedNormalized) - if observedNormalized < targetNormalized { - lowerBound = observation - } else { - upperBound = observation - } - commandedNormalized = nextCommandedNormalized( - targetNormalized: targetNormalized, - currentCommandedNormalized: commandedNormalized, - observedNormalized: observedNormalized, - lowerBound: lowerBound, - upperBound: upperBound - ) + if abs(dragPlan.currentNormalized - targetNormalized) > Self.alreadyAtTargetTolerance { + try await performSliderDrag(dragPlan, logger: logger) } - throw CLIError( - errorDescription: "Slider value did not reach requested value \(formatPercent(value)). Observed AXValue: \(lastRawValue ?? "none")." + let observedValue = try await pollObservedSliderValue( + query: query, + targetNormalized: targetNormalized, + logger: logger ) + guard observedValue.isWithinTolerance else { + throw CLIError( + errorDescription: "Slider value did not reach requested value \(formatPercent(value)) after direct drag. Observed AXValue: \(observedValue.rawValue ?? "none")." + ) + } + return observedValue.rawValue ?? formatNormalized(observedValue.normalizedValue) } - private func performSliderDrag(_ adjustment: SliderAdjustment, logger: AxeLogger) async throws { + private func performSliderDrag(_ dragPlan: SliderDragPlan, logger: AxeLogger) async throws { let physicalPoints = try await OrientationAwareCoordinates.translateBatch( - points: [adjustment.logicalStart, adjustment.logicalEnd], + points: [dragPlan.logicalStart, dragPlan.logicalEnd], for: simulatorUDID, logger: logger ) let physicalStart = physicalPoints[0] let physicalEnd = physicalPoints[1] - let distance = hypot(physicalEnd.x - physicalStart.x, physicalEnd.y - physicalStart.y) - let steps = max(1, Int(ceil(distance / Self.dragStepDelta))) - let stepDelay = Self.dragDuration / Double(steps) - - var events: [FBSimulatorHIDEvent] = [ - .touchDownAt(x: physicalStart.x, y: physicalStart.y), - .delay(Self.dragInitialHold) - ] - - for step in 1...steps { - let progress = Double(step) / Double(steps) - let x = physicalStart.x + ((physicalEnd.x - physicalStart.x) * progress) - let y = physicalStart.y + ((physicalEnd.y - physicalStart.y) * progress) - events.append(.touchDownAt(x: x, y: y)) - events.append(.delay(stepDelay)) - } - - events.append(.delay(Self.dragFinalHold)) - events.append(.touchUpAt(x: physicalEnd.x, y: physicalEnd.y)) - - try await HIDInteractor.performHIDEvent(FBSimulatorHIDEvent(events: events), for: simulatorUDID, logger: logger) + try await HIDInteractor.performCompositeDrag( + from: physicalStart, + to: physicalEnd, + duration: Self.directDragDuration, + steps: Self.directDragSteps, + initialHold: Self.directDragInitialHold, + finalHold: Self.directDragFinalHold, + for: simulatorUDID, + logger: logger + ) } private func resolveSliderElement(query: AccessibilityQuery, logger: AxeLogger) async throws -> AccessibilityMatch { @@ -281,53 +252,26 @@ struct Slider: AsyncParsableCommand { throw CLIError(errorDescription: "Slider value could not be verified because AXValue was unavailable after dragging.") } - private func initialCommandedNormalized(currentNormalized: Double, targetNormalized: Double) -> Double { - let distance = targetNormalized - currentNormalized - guard abs(distance) > 0.25 else { - return targetNormalized + private func dragStartX( + frame: AccessibilityElement.Frame, + nominalStartX: Double, + currentNormalized: Double, + targetNormalized: Double + ) -> Double { + guard currentNormalized >= 1.0 - Self.valueTolerance, targetNormalized < currentNormalized else { + return nominalStartX } - return clampedNormalized(targetNormalized - (distance * 0.1)) + return nominalStartX - (frame.height / 2.0) } - private func nextCommandedNormalized( - targetNormalized: Double, - currentCommandedNormalized: Double, - observedNormalized: Double, - lowerBound: SliderCommandObservation?, - upperBound: SliderCommandObservation? - ) -> Double { - let correction = targetNormalized - observedNormalized - - if let lowerBound, - let upperBound, - abs(upperBound.observed - lowerBound.observed) > .ulpOfOne { - let observedRange = upperBound.observed - lowerBound.observed - let commandedRange = upperBound.commanded - lowerBound.commanded - let interpolated = lowerBound.commanded + ((targetNormalized - lowerBound.observed) * commandedRange / observedRange) - if interpolated.isFinite { - return clampedNormalized(interpolated) - } + private func commandedNormalizedValue(currentNormalized: Double, targetNormalized: Double) -> Double { + if abs(currentNormalized - targetNormalized) <= Self.alreadyAtTargetTolerance { + return currentNormalized } - - if observedNormalized < targetNormalized { - let corrected = max(currentCommandedNormalized + correction, currentCommandedNormalized + Self.minimumCorrectionStep) - return clampedNormalized(min(corrected, upperBound?.commanded ?? 1.0)) + if targetNormalized < currentNormalized { + return targetNormalized - Self.lowRangeCoordinateOffset } - - let corrected = min(currentCommandedNormalized + correction, currentCommandedNormalized - Self.minimumCorrectionStep) - return clampedNormalized(max(corrected, lowerBound?.commanded ?? 0.0)) - } - - private func thumbCenterRange(for frame: AccessibilityElement.Frame) -> SliderThumbCenterRange { - let thumbRadius = frame.height / 2.0 - return SliderThumbCenterRange( - minX: frame.x - thumbRadius, - maxX: frame.x + frame.width + thumbRadius - ) - } - - private func clampedNormalized(_ value: Double) -> Double { - min(max(value, 0.0), 1.0) + return targetNormalized + Self.highRangeCoordinateOffset } private func parseNormalizedAXValue(_ rawValue: String?) throws -> Double { @@ -365,25 +309,12 @@ struct Slider: AsyncParsableCommand { } } -private struct SliderThumbCenterRange { - let minX: Double - let maxX: Double - - func x(for normalizedValue: Double) -> Double { - minX + ((maxX - minX) * normalizedValue) - } -} - -private struct SliderAdjustment { +private struct SliderDragPlan { let logicalStart: (x: Double, y: Double) let logicalEnd: (x: Double, y: Double) let currentNormalized: Double let targetNormalized: Double -} - -private struct SliderCommandObservation { - let commanded: Double - let observed: Double + let commandedNormalized: Double } private struct SliderObservedValue { diff --git a/Sources/AXe/Resources/skills/axe/SKILL.md b/Sources/AXe/Resources/skills/axe/SKILL.md index b038a67..e2aa89b 100644 --- a/Sources/AXe/Resources/skills/axe/SKILL.md +++ b/Sources/AXe/Resources/skills/axe/SKILL.md @@ -11,7 +11,7 @@ description: Provides agent-ready AXe CLI usage guidance for iOS Simulator autom ## Step 2: Choose the right command -Available commands: `init`, `tap`, `slider`, `swipe`, `gesture`, `touch`, `type`, `button`, `key`, `key-sequence`, `key-combo`, `batch`, `describe-ui`, `screenshot`, `record-video`, `stream-video`, `list-simulators`. Run `axe --help` or `axe --help` for full options. +Available commands: `init`, `tap`, `slider`, `swipe`, `drag`, `gesture`, `touch`, `type`, `button`, `key`, `key-sequence`, `key-combo`, `batch`, `describe-ui`, `screenshot`, `record-video`, `stream-video`, `list-simulators`. Run `axe --help` or `axe --help` for full options. Common examples: ```bash @@ -20,6 +20,7 @@ axe tap --label --udid axe tap --label 'Weather Alerts' --udid axe slider --id --value 75 --udid axe slider --label --value 40 --element-type Slider --udid +axe drag --start-x --start-y --end-x --end-y --udid axe tap -x -y --tap-style physical --udid axe tap -x -y --udid axe type 'text' --udid @@ -30,15 +31,15 @@ axe screenshot --udid --output screenshot.png ## Step 3: Understand the execution model -Most HID commands (`tap`, `swipe`, `type`, `key`, etc.) are fire-and-forget — AXe confirms the event was dispatched to the simulator but cannot verify the app actually processed it. A tap may land before a view is interactive, or during a transition. `slider` is the exception: it re-reads the matched slider AXValue and fails if the requested 0-100 value was not reached. This means: +Most HID commands (`tap`, `swipe`, `drag`, `type`, `key`, etc.) are fire-and-forget — AXe confirms the event was dispatched to the simulator but cannot verify the app actually processed it. A tap may land before a view is interactive, or during a transition. `slider` is the exception: it performs one selector-resolved low-level HID drag, re-reads the matched slider AXValue, and fails if the observed 0-100 value is outside tolerance. iOS slider controls quantize values to their rendered track resolution, so AXe does not retry correction gestures to chase unreachable decimals. This means: - Always verify outcomes separately with `describe-ui` or `screenshot` when app behavior matters beyond the direct command result. - Use `--wait-timeout` in batch to wait for tap elements to appear, and `sleep` steps or `--pre-delay` / `--post-delay` to allow animations to settle. ## Step 4: Apply timing and input best practices - Use `--pre-delay` / `--post-delay` on tap, swipe, and gesture commands for fixed delays around actions. - Use `--duration` to control how long a swipe, gesture, button press, or key press lasts. -- Coordinate-based `tap`, `swipe`, and `touch` accept coordinates from `describe-ui` directly; AXe detects rotated landscape simulator orientation and letterboxed landscape-only app layouts automatically. -- Use `axe slider --id --value <0-100>` for sliders instead of approximating with raw swipe coordinates; it uses the slider frame/current AXValue and verifies the result. +- Coordinate-based `tap`, `swipe`, `drag`, and `touch` accept coordinates from `describe-ui` directly; AXe detects rotated landscape simulator orientation and letterboxed landscape-only app layouts automatically. +- Use `axe slider --id --value <0-100>` for sliders instead of approximating with raw swipe coordinates; it uses one calibrated low-level HID drag from the resolved slider frame/current AXValue, through the same composite touch-move path as `drag`, verifies the result within tolerance, and fails clearly if the observed AXValue remains outside tolerance. - For text with shell-sensitive characters, prefer `--stdin` or `--file` over inline quotes. - Use single quotes for inline text arguments to avoid shell expansion issues. @@ -51,7 +52,7 @@ Most HID commands (`tap`, `swipe`, `type`, `key`, etc.) are fire-and-forget — **Fall back to discrete commands** when: - A step's parameters depend on runtime inspection of a previous step's result (e.g. parsing `describe-ui` JSON to choose coordinates dynamically). -- Setting a slider value with `slider`; batch steps do not support slider verification. +- Using `slider`; batch steps do not support slider verification. **Handling animations and transitions in batch:** - Use `--wait-timeout ` so selector taps (`--id` / `--label`) poll the accessibility tree until the element appears or the timeout expires. This is the primary mechanism for multi-screen flows. diff --git a/Sources/AXe/Utilities/HIDInteractor.swift b/Sources/AXe/Utilities/HIDInteractor.swift index c3bb1e4..6e062f3 100644 --- a/Sources/AXe/Utilities/HIDInteractor.swift +++ b/Sources/AXe/Utilities/HIDInteractor.swift @@ -74,6 +74,65 @@ struct HIDInteractor { try await performHIDEvent(event, in: session, logger: logger) } + static func makeCompositeDragEvent( + from start: (x: Double, y: Double), + to end: (x: Double, y: Double), + duration: TimeInterval, + steps: Int, + initialHold: TimeInterval, + finalHold: TimeInterval + ) throws -> FBSimulatorHIDEvent { + guard duration >= 0 else { + throw CLIError(errorDescription: "Drag duration must be non-negative.") + } + guard steps > 0 else { + throw CLIError(errorDescription: "Drag steps must be greater than 0.") + } + guard initialHold >= 0, finalHold >= 0 else { + throw CLIError(errorDescription: "Drag hold durations must be non-negative.") + } + + let stepDelay = duration / Double(steps) + var events: [FBSimulatorHIDEvent] = [ + .touchDownAt(x: start.x, y: start.y), + .delay(initialHold) + ] + + for step in 1...steps { + let progress = Double(step) / Double(steps) + let x = start.x + ((end.x - start.x) * progress) + let y = start.y + ((end.y - start.y) * progress) + events.append(.delay(stepDelay)) + events.append(.touchMoveAt(x: x, y: y)) + } + + events.append(.delay(finalHold)) + events.append(.touchUpAt(x: end.x, y: end.y)) + + return FBSimulatorHIDEvent(events: events) + } + + static func performCompositeDrag( + from start: (x: Double, y: Double), + to end: (x: Double, y: Double), + duration: TimeInterval, + steps: Int, + initialHold: TimeInterval, + finalHold: TimeInterval, + for simulatorUDID: String, + logger: AxeLogger + ) async throws { + let event = try makeCompositeDragEvent( + from: start, + to: end, + duration: duration, + steps: steps, + initialHold: initialHold, + finalHold: finalHold + ) + try await performHIDEvent(event, for: simulatorUDID, logger: logger) + } + static func performPhysicalTap( at point: (x: Double, y: Double), preDelay: Double?, diff --git a/Sources/AXe/main.swift b/Sources/AXe/main.swift index 98e4038..0b41ab4 100644 --- a/Sources/AXe/main.swift +++ b/Sources/AXe/main.swift @@ -21,6 +21,7 @@ struct Axe: AsyncParsableCommand { Slider.self, Type.self, Swipe.self, + Drag.self, Button.self, Key.self, KeySequence.self, diff --git a/Tests/DragTests.swift b/Tests/DragTests.swift new file mode 100644 index 0000000..48dfb36 --- /dev/null +++ b/Tests/DragTests.swift @@ -0,0 +1,99 @@ +import Testing +import Foundation + +@Suite("Drag Command Surface Tests") +struct DragCommandSurfaceTests { + @Test("Drag help includes coordinate and timing options") + func dragHelpIncludesCoordinateAndTimingOptions() async throws { + let result = try await TestHelpers.runAxeCommand("drag --help") + + #expect(result.output.contains("--start-x")) + #expect(result.output.contains("--start-y")) + #expect(result.output.contains("--end-x")) + #expect(result.output.contains("--end-y")) + #expect(result.output.contains("--duration")) + #expect(result.output.contains("--steps")) + } + + @Test("Invalid drag coordinates fail validation") + func invalidDragCoordinatesFailValidation() async throws { + let result = try await TestHelpers.runAxeCommandAllowFailure( + "drag --start-x 100 --start-y 100 --end-x 100 --end-y 100 --udid invalid" + ) + + #expect(result.exitCode != 0) + #expect(result.output.contains("Start and end points must be different.")) + } + + @Test("Too many drag steps fails validation") + func tooManyDragStepsFailsValidation() async throws { + let result = try await TestHelpers.runAxeCommandAllowFailure( + "drag --start-x 100 --start-y 100 --end-x 100 --end-y 200 --steps 1001 --udid invalid" + ) + + #expect(result.exitCode != 0) + #expect(result.output.contains("Steps must be between 1 and 1000.")) + } +} + +@Suite("Drag Command Tests", .serialized, .enabled(if: isE2EEnabled)) +struct DragTests { + @Test("Low-level drag records requested start and end points") + func lowLevelDragRecordsRequestedStartAndEndPoints() async throws { + try await TestHelpers.launchPlaygroundApp(to: "touch-control") + + _ = try await TestHelpers.waitForLabel(containing: "Touch Control Playground", timeout: 3) { + $0 == "Touch Control Playground" + } + + let start = (x: 250, y: 450) + let end = (x: 250, y: 650) + + try await TestHelpers.runAxeCommand( + "drag --start-x \(start.x) --start-y \(start.y) --end-x \(end.x) --end-y \(end.y) --duration 0.4 --steps 40", + simulatorUDID: defaultSimulatorUDID + ) + + try await waitForRecordedDrag(start: start, end: end, timeout: 3) + } + + private func waitForRecordedDrag( + start: (x: Int, y: Int), + end: (x: Int, y: Int), + timeout: TimeInterval + ) async throws { + let deadline = Date().addingTimeInterval(timeout) + var didSeeStart = false + var didSeeEnd = false + var lastTouchHistory: String? + + while Date() < deadline { + let uiState = try await TestHelpers.getUIState() + didSeeStart = hasTouchEvent(in: uiState, type: "down", near: start) + didSeeEnd = hasTouchEvent(in: uiState, type: "up", near: end) + lastTouchHistory = UIStateParser.findElement(in: uiState, withIdentifier: "touch-history")?.value + + if didSeeStart && didSeeEnd { + return + } + + try await Task.sleep(nanoseconds: 200_000_000) + } + + throw TestError.unexpectedState( + "Timed out waiting for drag start (\(start.x), \(start.y)) and end (\(end.x), \(end.y)). Saw start: \(didSeeStart), saw end: \(didSeeEnd), last touch history: \(lastTouchHistory ?? "none")" + ) + } + + private func hasTouchEvent(in uiState: UIElement, type: String, near expected: (x: Int, y: Int)) -> Bool { + UIStateParser.findElement(in: uiState) { element in + guard let value = element.value, + value.hasPrefix("\(type):"), + let point = CoordinateParser.parseNamedCoordinates(from: value) else { + return false + } + + return abs(point.x - expected.x) <= 1 && abs(point.y - expected.y) <= 1 + } != nil + } +} diff --git a/Tests/README.md b/Tests/README.md index cc14c47..70c5a5c 100644 --- a/Tests/README.md +++ b/Tests/README.md @@ -11,6 +11,7 @@ Each AXe command has its own dedicated test file: - `TapTests.swift` - Tests for `tap` command - `SliderTests.swift` - Tests for `slider` command - `SwipeTests.swift` - Tests for `swipe` command +- `DragTests.swift` - Tests for `drag` command - `TypeTests.swift` - Tests for `type` command - `KeyTests.swift` - Tests for `key` and `key-sequence` commands - `TouchTests.swift` - Tests for `touch` command @@ -33,6 +34,7 @@ AXE_E2E=1 SIMULATOR_UDID= swift test swift test --filter TapTests swift test --filter SliderTests swift test --filter SwipeTests +swift test --filter DragTests swift test --filter TypeTests swift test --filter KeyTests swift test --filter TouchTests @@ -70,6 +72,7 @@ Each test file can be run directly: swift test --filter TapTests swift test --filter SliderTests swift test --filter SwipeTests +swift test --filter DragTests ``` ## Test Coverage diff --git a/Tests/SliderTests.swift b/Tests/SliderTests.swift index 39ceab3..33e70da 100644 --- a/Tests/SliderTests.swift +++ b/Tests/SliderTests.swift @@ -32,24 +32,27 @@ struct SliderCommandSurfaceTests { @Suite("Slider Command Tests", .serialized, .enabled(if: isE2EEnabled)) struct SliderTests { - private let exactValueTolerance = 0.004 - - @Test("Slider command sets value by accessibility identifier") - func sliderCommandSetsValueByAccessibilityIdentifier() async throws { + private let observableValueTolerance = 0.0007 + + @Test("Slider command reaches observable AXValue tolerance with one command execution", arguments: [ + 0.0, + 0.1, + 1.50, + 40.0, + 75.0, + 78.25, + 100.0 + ]) + func sliderCommandReachesObservableAXValueToleranceWithOneCommand(requestedPercent: Double) async throws { try await TestHelpers.launchPlaygroundApp(to: "slider-value-test") - let initial = try await TestHelpers.waitForLabel(containing: "Slider Position:", timeout: 3) { - $0 == "Slider Position: 0.25" - } - #expect(initial == "Slider Position: 0.25") - - try await TestHelpers.runAxeCommand( - "slider --id slider-value-slider --value 75 --element-type Slider", + _ = try await TestHelpers.runAxeCommand( + "slider --id slider-value-slider --value \(formatCommandValue(requestedPercent)) --element-type Slider", simulatorUDID: defaultSimulatorUDID ) - let slider = try await waitForSliderState(expectedLabel: "Slider Position: 0.75", expectedNormalizedValue: 0.75) - #expect(slider.type == "Slider") + let state = try await waitForSliderState(requestedPercent: requestedPercent) + #expect(state.slider.type == "Slider") } @Test("Slider command sets value by accessibility label") @@ -61,34 +64,62 @@ struct SliderTests { simulatorUDID: defaultSimulatorUDID ) - let slider = try await waitForSliderState(expectedLabel: "Slider Position: 0.40", expectedNormalizedValue: 0.40) - #expect(slider.type == "Slider") + let state = try await waitForSliderState(requestedPercent: 40) + #expect(state.slider.type == "Slider") + } + + @Test("Slider command reaches observable AXValue tolerance across sequential moves") + func sliderCommandReachesObservableAXValueToleranceAcrossSequentialMoves() async throws { + try await TestHelpers.launchPlaygroundApp(to: "slider-value-test") + + for requestedPercent in [0.0, 0.1, 1.50, 100.0, 78.25, 40.0, 75.0] { + try await TestHelpers.runAxeCommand( + "slider --id slider-value-slider --value \(formatCommandValue(requestedPercent)) --element-type Slider", + simulatorUDID: defaultSimulatorUDID + ) + + let state = try await waitForSliderState(requestedPercent: requestedPercent) + #expect(state.slider.type == "Slider") + } } - private func waitForSliderState(expectedLabel: String, expectedNormalizedValue: Double) async throws -> UIElement { + private func waitForSliderState(requestedPercent: Double) async throws -> SliderVerificationState { + let expectedNormalizedValue = requestedPercent / 100.0 let deadline = Date().addingTimeInterval(3) - var lastLabel: String? + var lastPercentText: String? + var lastExactText: String? var lastSliderValue: String? while Date() < deadline { let uiState = try await TestHelpers.getUIState() - let positionLabel = UIStateParser.findElementByLabel(in: uiState, label: expectedLabel)?.label - lastLabel = UIStateParser.findElementContainingLabel(in: uiState, containing: "Slider Position:")?.label - - if let slider = UIStateParser.findElement(in: uiState, withIdentifier: "slider-value-slider") { - lastSliderValue = slider.value - if positionLabel == expectedLabel, - let observedValue = normalizedSliderValue(slider.value), - abs(observedValue - expectedNormalizedValue) <= exactValueTolerance { - return slider - } + let percentElement = UIStateParser.findElement(in: uiState, withIdentifier: "slider-percent-state") + let exactElement = UIStateParser.findElement(in: uiState, withIdentifier: "slider-exact-value-state") + let slider = UIStateParser.findElement(in: uiState, withIdentifier: "slider-value-slider") + + lastPercentText = percentElement?.label + lastExactText = exactElement?.label + lastSliderValue = slider?.value + + if let slider, + let observedSliderValue = normalizedSliderValue(slider.value), + let observedPercent = numericLabelValue(percentElement?.label, prefix: "Slider Percent State:"), + let observedExactValue = numericLabelValue(exactElement?.label, prefix: "Slider Exact Value:"), + abs(observedSliderValue - expectedNormalizedValue) <= observableValueTolerance, + abs(observedExactValue - expectedNormalizedValue) <= observableValueTolerance, + abs((observedPercent / 100.0) - expectedNormalizedValue) <= observableValueTolerance { + return SliderVerificationState( + slider: slider, + observedNormalizedValue: observedSliderValue, + observedExactValue: observedExactValue, + observedPercent: observedPercent + ) } try await Task.sleep(nanoseconds: 200_000_000) } throw TestError.unexpectedState( - "Timed out waiting for \(expectedLabel) and slider AXValue near \(expectedNormalizedValue). Last label: \(lastLabel ?? "none"), last AXValue: \(lastSliderValue ?? "none")" + "Timed out waiting for requested slider target \(formatCommandValue(requestedPercent))% (normalized \(formatNormalized(expectedNormalizedValue))). Last percent text: \(lastPercentText ?? "none"), last exact text: \(lastExactText ?? "none"), last AXValue: \(lastSliderValue ?? "none")" ) } @@ -104,4 +135,27 @@ struct SliderTests { return isPercent || parsedValue > 1.0 ? parsedValue / 100.0 : parsedValue } + + private func numericLabelValue(_ label: String?, prefix: String) -> Double? { + guard let label, label.hasPrefix(prefix) else { return nil } + return Double(label.dropFirst(prefix.count).trimmingCharacters(in: .whitespacesAndNewlines)) + } + + private func formatCommandValue(_ value: Double) -> String { + if value.rounded() == value { + return String(Int(value)) + } + return String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), value) + } + + private func formatNormalized(_ value: Double) -> String { + String(format: "%.4f", locale: Locale(identifier: "en_US_POSIX"), value) + } +} + +private struct SliderVerificationState { + let slider: UIElement + let observedNormalizedValue: Double + let observedExactValue: Double + let observedPercent: Double } diff --git a/USAGE_EXAMPLES.md b/USAGE_EXAMPLES.md index 9122f06..e0afebd 100644 --- a/USAGE_EXAMPLES.md +++ b/USAGE_EXAMPLES.md @@ -74,8 +74,13 @@ axe tap -x 100 -y 200 --pre-delay 1.0 --post-delay 0.5 --udid SIMULATOR_UDID axe swipe --start-x 100 --start-y 300 --end-x 300 --end-y 100 --udid SIMULATOR_UDID axe swipe --start-x 50 --start-y 500 --end-x 350 --end-y 500 --duration 2.0 --delta 25 --udid SIMULATOR_UDID -# Swipe with timing controls +# Raw low-level drag using explicit touch move events +axe drag --start-x 100 --start-y 400 --end-x 300 --end-y 400 --udid SIMULATOR_UDID +axe drag --start-x 100 --start-y 400 --end-x 300 --end-y 400 --duration 0.4 --steps 40 --udid SIMULATOR_UDID + +# Swipe and drag with timing controls axe swipe --start-x 100 --start-y 300 --end-x 300 --end-y 100 --pre-delay 1.0 --post-delay 0.5 --udid SIMULATOR_UDID +axe drag --start-x 100 --start-y 400 --end-x 300 --end-y 400 --pre-delay 1.0 --post-delay 0.5 --udid SIMULATOR_UDID # Advanced touch control axe touch -x 150 -y 250 --down --udid SIMULATOR_UDID # Touch down only @@ -86,14 +91,14 @@ axe touch -x 150 -y 250 --down --up --delay 1.0 --udid SIMULATOR_UDID # Touch w ### **3. Sliders** ```bash -# Set a slider by accessibility identifier to a 0-100 percentage +# Set a slider by accessibility identifier to a verified 0-100 percentage axe slider --id slider-value-slider --value 75 --udid SIMULATOR_UDID # Set a slider by accessibility label and narrow matching to slider elements axe slider --label "Volume" --value 40 --element-type Slider --udid SIMULATOR_UDID ``` -The `slider` command uses the slider's accessibility frame and current AXValue, performs an orientation-aware HID drag, then verifies the new AXValue. Use `describe-ui` first to find reliable `--id` or `--label` selectors. +The `slider` command uses the slider's accessibility frame and current AXValue to perform one calibrated low-level HID drag through the same composite touch-move path as `drag`, then verifies the new AXValue. Since iOS slider controls quantize values to their rendered track resolution, AXe verifies that the observed value is within tolerance rather than retrying correction gestures to chase unreachable decimals. If the observed AXValue remains outside tolerance after that drag, the command fails clearly. Use `describe-ui` first to find reliable `--id` or `--label` selectors. ### **4. Gesture Presets** 🆕 @@ -450,9 +455,10 @@ done | Parameter | Range | Description | Available On | |-----------|-------|-------------|--------------| -| `--pre-delay` | 0-10 seconds | Delay before action | tap, swipe, gesture | -| `--post-delay` | 0-10 seconds | Delay after action | tap, swipe, gesture | -| `--duration` | 0-10 seconds | Action duration | swipe, gesture, button, key | +| `--pre-delay` | 0-10 seconds | Delay before action | tap, swipe, drag, gesture | +| `--post-delay` | 0-10 seconds | Delay after action | tap, swipe, drag, gesture | +| `--duration` | 0-10 seconds | Action duration | swipe, drag, gesture, button, key | +| `--steps` | 1-1000 | Touch move event count | drag | | `--delay` | 0-5 seconds | Between-key delay | key-sequence, touch | ## Benchmarking Batch vs Non-Batch @@ -476,7 +482,7 @@ This benchmark compares equivalent two-tap workflows and reports per-iteration l 5. **No Shell Escaping**: Use `--stdin` or `--file` for complex text 6. **Automation-Friendly**: Perfect for CI/CD and testing scripts 7. **Flexible Input Methods**: Multiple ways to provide input and control timing -8. **Deterministic Slider Setting**: Slider controls can be set by selector with AXValue verification +8. **Slider Verification**: Slider controls use selector-resolved low-level HID dragging with AXValue tolerance verification 9. **Comprehensive Validation**: Built-in parameter validation and error handling ## Common Keycodes Reference diff --git a/test-runner.sh b/test-runner.sh index 051d732..8604c2d 100755 --- a/test-runner.sh +++ b/test-runner.sh @@ -59,6 +59,7 @@ show_usage() { echo "" echo "Test Filters (optional):" echo " SwipeTests Run only swipe tests" + echo " DragTests Run only drag tests" echo " SliderTests Run only slider tests" echo " TapTests Run only tap tests" echo " KeyTests Run only key tests" @@ -71,6 +72,7 @@ show_usage() { echo "Examples:" echo " $0 # Build everything and run all tests" echo " $0 SwipeTests # Build everything and run only swipe tests" + echo " $0 DragTests # Build everything and run only drag tests" echo " $0 -t SwipeTests # Skip building, run only swipe tests" echo " $0 -b # Only build, skip tests" echo " $0 -c # Clean build and run all tests" @@ -110,7 +112,7 @@ while [[ $# -gt 0 ]]; do VERBOSE=true shift ;; - BatchTests|SwipeTests|SliderTests|TapTests|KeyTests|TouchTests|TypeTests|ButtonTests|GestureTests|ListSimulatorsTests) + BatchTests|SwipeTests|DragTests|SliderTests|TapTests|KeyTests|TouchTests|TypeTests|ButtonTests|GestureTests|ListSimulatorsTests) TEST_FILTER="$1" shift ;; @@ -340,6 +342,7 @@ run_tests() { "StreamVideoDebugTests" "StreamVideoTests" "SwipeTests" + "DragTests" "SliderTests" "TapTests" "TouchTests" From dae2fd88b5b1aa8e76723ff65439c65227a42166 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 11 May 2026 20:32:31 +0100 Subject: [PATCH 4/8] fix(drag): Use Objective-C touch move selector Call the FBSimulatorHIDEvent touch move class method through its Objective-C selector so release builds do not depend on Swift importer spelling for the convenience method. Co-Authored-By: OpenAI Codex --- Sources/AXe/Utilities/HIDInteractor.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Sources/AXe/Utilities/HIDInteractor.swift b/Sources/AXe/Utilities/HIDInteractor.swift index 6e062f3..ed15e48 100644 --- a/Sources/AXe/Utilities/HIDInteractor.swift +++ b/Sources/AXe/Utilities/HIDInteractor.swift @@ -1,6 +1,7 @@ import Foundation import FBControlCore import FBSimulatorControl +import ObjectiveC // MARK: - HID Interactor @MainActor @@ -103,7 +104,7 @@ struct HIDInteractor { let x = start.x + ((end.x - start.x) * progress) let y = start.y + ((end.y - start.y) * progress) events.append(.delay(stepDelay)) - events.append(.touchMoveAt(x: x, y: y)) + events.append(try touchMoveEvent(x: x, y: y)) } events.append(.delay(finalHold)) @@ -112,6 +113,19 @@ struct HIDInteractor { return FBSimulatorHIDEvent(events: events) } + private static func touchMoveEvent(x: Double, y: Double) throws -> FBSimulatorHIDEvent { + typealias TouchMoveIMP = @convention(c) (AnyClass, Selector, Double, Double) -> FBSimulatorHIDEvent + + let selector = NSSelectorFromString("touchMoveAtX:y:") + guard let method = class_getClassMethod(FBSimulatorHIDEvent.self, selector) else { + throw CLIError(errorDescription: "FBSimulatorHIDEvent does not support touch move events.") + } + + let implementation = method_getImplementation(method) + let touchMove = unsafeBitCast(implementation, to: TouchMoveIMP.self) + return touchMove(FBSimulatorHIDEvent.self, selector, x, y) + } + static func performCompositeDrag( from start: (x: Double, y: Double), to end: (x: Double, y: Double), From 8b8d8c42ef7a68fb2fcd5738a45d7dfaafeb3d44 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 11 May 2026 20:35:16 +0100 Subject: [PATCH 5/8] fix(slider): Preserve in-tolerance value at timeout Avoid a spurious verification failure when the slider reaches the requested AXValue tolerance just before the stability delay crosses the polling deadline. Return the already-valid observation instead of sampling again after timeout. Co-Authored-By: OpenAI Codex --- Sources/AXe/Commands/Slider.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/AXe/Commands/Slider.swift b/Sources/AXe/Commands/Slider.swift index 7fc38af..fde2ac9 100644 --- a/Sources/AXe/Commands/Slider.swift +++ b/Sources/AXe/Commands/Slider.swift @@ -224,6 +224,9 @@ struct Slider: AsyncParsableCommand { ) if observedValue.isWithinTolerance { try await Task.sleep(for: .seconds(Self.verificationStabilityDelay)) + if clock.now >= deadline { + return observedValue + } let stableMatch = try await resolveSliderElement(query: query, logger: logger) let stableRawValue = stableMatch.element.normalizedValue let stableNormalizedValue = try parseNormalizedAXValue(stableRawValue) From 6302573889587fbb4c9f965fcb3172b05ed3a145 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 11 May 2026 21:12:26 +0100 Subject: [PATCH 6/8] test: Address drag and test runner review feedback Add coverage for composite drag move planning so the drag path cannot collapse to a press-and-release-only implementation. Keep the app-level E2E drag coverage focused on observable simulator behavior. Sync test-runner single-suite handling with the sequential suite list so listed suites can be run directly. Co-Authored-By: OpenAI Codex --- Sources/AXe/Utilities/HIDInteractor.swift | 26 ++++++++++++++++++----- Tests/DragTests.swift | 20 +++++++++++++++++ test-runner.sh | 9 +++++++- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/Sources/AXe/Utilities/HIDInteractor.swift b/Sources/AXe/Utilities/HIDInteractor.swift index ed15e48..4b83c6f 100644 --- a/Sources/AXe/Utilities/HIDInteractor.swift +++ b/Sources/AXe/Utilities/HIDInteractor.swift @@ -93,18 +93,16 @@ struct HIDInteractor { throw CLIError(errorDescription: "Drag hold durations must be non-negative.") } + let movePoints = try compositeDragMovePoints(from: start, to: end, steps: steps) let stepDelay = duration / Double(steps) var events: [FBSimulatorHIDEvent] = [ .touchDownAt(x: start.x, y: start.y), .delay(initialHold) ] - for step in 1...steps { - let progress = Double(step) / Double(steps) - let x = start.x + ((end.x - start.x) * progress) - let y = start.y + ((end.y - start.y) * progress) + for point in movePoints { events.append(.delay(stepDelay)) - events.append(try touchMoveEvent(x: x, y: y)) + events.append(try touchMoveEvent(x: point.x, y: point.y)) } events.append(.delay(finalHold)) @@ -113,6 +111,24 @@ struct HIDInteractor { return FBSimulatorHIDEvent(events: events) } + static func compositeDragMovePoints( + from start: (x: Double, y: Double), + to end: (x: Double, y: Double), + steps: Int + ) throws -> [(x: Double, y: Double)] { + guard steps > 0 else { + throw CLIError(errorDescription: "Drag steps must be greater than 0.") + } + + return (1...steps).map { step in + let progress = Double(step) / Double(steps) + return ( + x: start.x + ((end.x - start.x) * progress), + y: start.y + ((end.y - start.y) * progress) + ) + } + } + private static func touchMoveEvent(x: Double, y: Double) throws -> FBSimulatorHIDEvent { typealias TouchMoveIMP = @convention(c) (AnyClass, Selector, Double, Double) -> FBSimulatorHIDEvent diff --git a/Tests/DragTests.swift b/Tests/DragTests.swift index 48dfb36..62b3210 100644 --- a/Tests/DragTests.swift +++ b/Tests/DragTests.swift @@ -1,5 +1,6 @@ import Testing import Foundation +@testable import AXe @Suite("Drag Command Surface Tests") struct DragCommandSurfaceTests { @@ -34,6 +35,25 @@ struct DragCommandSurfaceTests { #expect(result.exitCode != 0) #expect(result.output.contains("Steps must be between 1 and 1000.")) } + + @Test("Composite drag plan includes move points between touch down and touch up") + @MainActor + func compositeDragPlanIncludesMovePoints() throws { + let movePoints = try HIDInteractor.compositeDragMovePoints( + from: (x: 100, y: 200), + to: (x: 300, y: 600), + steps: 4 + ) + + #expect(movePoints.count == 4) + #expect(movePoints.first?.x == 150) + #expect(movePoints.first?.y == 300) + #expect(movePoints.last?.x == 300) + #expect(movePoints.last?.y == 600) + #expect(movePoints.contains { point in + point.x > 100 && point.x < 300 && point.y > 200 && point.y < 600 + }) + } } @Suite("Drag Command Tests", .serialized, .enabled(if: isE2EEnabled)) diff --git a/test-runner.sh b/test-runner.sh index 8604c2d..cceb079 100755 --- a/test-runner.sh +++ b/test-runner.sh @@ -61,6 +61,10 @@ show_usage() { echo " SwipeTests Run only swipe tests" echo " DragTests Run only drag tests" echo " SliderTests Run only slider tests" + echo " DescribeUITests Run only describe-ui tests" + echo " InitTests Run only init tests" + echo " KeyComboTests Run only key-combo tests" + echo " KeySequenceTests Run only key-sequence tests" echo " TapTests Run only tap tests" echo " KeyTests Run only key tests" echo " TouchTests Run only touch tests" @@ -68,6 +72,9 @@ show_usage() { echo " ButtonTests Run only button tests" echo " GestureTests Run only gesture tests" echo " ListSimulatorsTests Run only list simulators tests" + echo " RecordVideoTests Run only record video tests" + echo " StreamVideoDebugTests Run only stream video debug tests" + echo " StreamVideoTests Run only stream video tests" echo "" echo "Examples:" echo " $0 # Build everything and run all tests" @@ -112,7 +119,7 @@ while [[ $# -gt 0 ]]; do VERBOSE=true shift ;; - BatchTests|SwipeTests|DragTests|SliderTests|TapTests|KeyTests|TouchTests|TypeTests|ButtonTests|GestureTests|ListSimulatorsTests) + BatchTests|ButtonTests|DescribeUITests|GestureTests|InitTests|KeyComboTests|KeySequenceTests|KeyTests|ListSimulatorsTests|RecordVideoTests|StreamVideoDebugTests|StreamVideoTests|SwipeTests|DragTests|SliderTests|TapTests|TouchTests|TypeTests) TEST_FILTER="$1" shift ;; From fa98cde39a4c2067905ac0bdfdf95d48c1bd5aea Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 11 May 2026 21:36:25 +0100 Subject: [PATCH 7/8] fix(slider): Clamp drag endpoint to application bounds Keep the calibrated slider overdrive needed to reach edge values, but clamp the final drag endpoint to the application frame so extreme commands cannot produce off-screen coordinates. Add focused coverage for endpoint clamping and preserve the playground-backed slider tests for 0 and 100 percent values. Co-Authored-By: OpenAI Codex --- Sources/AXe/Commands/Slider.swift | 25 ++++++++++++++++--- .../AccessibilityTargetResolver.swift | 11 +++++++- Tests/SliderTests.swift | 10 ++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/Sources/AXe/Commands/Slider.swift b/Sources/AXe/Commands/Slider.swift index fde2ac9..3b56b6c 100644 --- a/Sources/AXe/Commands/Slider.swift +++ b/Sources/AXe/Commands/Slider.swift @@ -105,6 +105,7 @@ struct Slider: AsyncParsableCommand { private func makeDragPlan( for element: AccessibilityElement, + applicationFrame: AccessibilityElement.Frame?, targetNormalized: Double ) throws -> SliderDragPlan { guard element.isSliderLikeControl else { @@ -120,7 +121,7 @@ struct Slider: AsyncParsableCommand { let currentNormalized = try parseNormalizedAXValue(element.normalizedValue) let centerY = frame.y + (frame.height / 2.0) - let commandedNormalized = commandedNormalizedValue( + let commandedNormalized = Self.commandedNormalizedValue( currentNormalized: currentNormalized, targetNormalized: targetNormalized ) @@ -132,9 +133,11 @@ struct Slider: AsyncParsableCommand { targetNormalized: targetNormalized ) let fingerOffsetFromNominalStart = startX - nominalStartX + let rawEndX = frame.x + (frame.width * commandedNormalized) + fingerOffsetFromNominalStart + let endX = Self.clampedDragEndX(rawEndX, applicationFrame: applicationFrame) return SliderDragPlan( logicalStart: (x: startX, y: centerY), - logicalEnd: (x: frame.x + (frame.width * commandedNormalized) + fingerOffsetFromNominalStart, y: centerY), + logicalEnd: (x: endX, y: centerY), currentNormalized: currentNormalized, targetNormalized: targetNormalized, commandedNormalized: commandedNormalized @@ -147,7 +150,11 @@ struct Slider: AsyncParsableCommand { targetNormalized: Double, logger: AxeLogger ) async throws -> String { - let dragPlan = try makeDragPlan(for: initialMatch.element, targetNormalized: targetNormalized) + let dragPlan = try makeDragPlan( + for: initialMatch.element, + applicationFrame: initialMatch.applicationFrame, + targetNormalized: targetNormalized + ) logger.info().log( "Setting slider \(initialMatch.selectorDescription) from AXValue \(formatNormalized(dragPlan.currentNormalized)) toward \(formatNormalized(dragPlan.targetNormalized)) with low-level HID drag" ) @@ -267,7 +274,7 @@ struct Slider: AsyncParsableCommand { return nominalStartX - (frame.height / 2.0) } - private func commandedNormalizedValue(currentNormalized: Double, targetNormalized: Double) -> Double { + static func commandedNormalizedValue(currentNormalized: Double, targetNormalized: Double) -> Double { if abs(currentNormalized - targetNormalized) <= Self.alreadyAtTargetTolerance { return currentNormalized } @@ -277,6 +284,16 @@ struct Slider: AsyncParsableCommand { return targetNormalized + Self.highRangeCoordinateOffset } + static func clampedDragEndX( + _ x: Double, + applicationFrame: AccessibilityElement.Frame? + ) -> Double { + guard let applicationFrame, applicationFrame.width > 0 else { + return x + } + return min(max(x, applicationFrame.x), applicationFrame.x + applicationFrame.width) + } + private func parseNormalizedAXValue(_ rawValue: String?) throws -> Double { guard let rawValue else { throw CLIError(errorDescription: "Matched slider does not expose a numeric AXValue, so AXe cannot deterministically set it.") diff --git a/Sources/AXe/Utilities/AccessibilityTargetResolver.swift b/Sources/AXe/Utilities/AccessibilityTargetResolver.swift index 0fd64de..956caed 100644 --- a/Sources/AXe/Utilities/AccessibilityTargetResolver.swift +++ b/Sources/AXe/Utilities/AccessibilityTargetResolver.swift @@ -47,6 +47,7 @@ enum ElementResolutionError: LocalizedError { struct AccessibilityMatch { let element: AccessibilityElement let selectorDescription: String + let applicationFrame: AccessibilityElement.Frame? } struct AccessibilityTargetResolver { @@ -95,7 +96,11 @@ struct AccessibilityTargetResolver { selectorDescription = "--value '\(rawValue)'" } - return AccessibilityMatch(element: matchedElement, selectorDescription: selectorDescription) + return AccessibilityMatch( + element: matchedElement, + selectorDescription: selectorDescription, + applicationFrame: applicationFrame(from: roots) + ) } static func resolveTap( @@ -138,6 +143,10 @@ struct AccessibilityTargetResolver { return (x: frame.x + (frame.width / 2.0), y: centerY) } + private static func applicationFrame(from roots: [AccessibilityElement]) -> AccessibilityElement.Frame? { + roots.first { $0.type == "Application" }?.frame ?? roots.first?.frame + } + private static func selectUniqueMatch( _ matches: [AccessibilityElement], kind: String, diff --git a/Tests/SliderTests.swift b/Tests/SliderTests.swift index 33e70da..266d7c4 100644 --- a/Tests/SliderTests.swift +++ b/Tests/SliderTests.swift @@ -1,5 +1,6 @@ import Testing import Foundation +@testable import AXe @Suite("Slider Command Surface Tests") struct SliderCommandSurfaceTests { @@ -28,6 +29,15 @@ struct SliderCommandSurfaceTests { #expect(result.exitCode != 0) #expect(result.output.contains("Use exactly one of --id or --label to target a slider.")) } + + @Test("Slider drag endpoints stay within the application frame") + func sliderDragEndpointsStayWithinApplicationFrame() { + let applicationFrame = AccessibilityElement.Frame(x: 0, y: 0, width: 390, height: 844) + + #expect(Slider.clampedDragEndX(-12, applicationFrame: applicationFrame) == 0) + #expect(Slider.clampedDragEndX(402, applicationFrame: applicationFrame) == 390) + #expect(Slider.clampedDragEndX(120, applicationFrame: applicationFrame) == 120) + } } @Suite("Slider Command Tests", .serialized, .enabled(if: isE2EEnabled)) From 32f56f99368542e04458fc4f70bcce4703589f95 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 11 May 2026 21:53:07 +0100 Subject: [PATCH 8/8] fix(slider): Avoid redundant drag within tolerance Use the same tolerance for already-at-target detection and final value verification so values that already pass verification do not trigger a calibrated overdrive drag. Add focused coverage for the within-tolerance command path. Co-Authored-By: OpenAI Codex --- Sources/AXe/Commands/Slider.swift | 2 +- Tests/SliderTests.swift | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/AXe/Commands/Slider.swift b/Sources/AXe/Commands/Slider.swift index 3b56b6c..dac12de 100644 --- a/Sources/AXe/Commands/Slider.swift +++ b/Sources/AXe/Commands/Slider.swift @@ -13,8 +13,8 @@ struct Slider: AsyncParsableCommand { private static let verificationTimeout: TimeInterval = 1.5 private static let verificationPollInterval: TimeInterval = 0.1 private static let verificationStabilityDelay: TimeInterval = 0.3 - private static let alreadyAtTargetTolerance = 0.00005 private static let valueTolerance = 0.0007 + private static let alreadyAtTargetTolerance = valueTolerance private static let lowRangeCoordinateOffset = 0.0268 private static let highRangeCoordinateOffset = 0.0271 diff --git a/Tests/SliderTests.swift b/Tests/SliderTests.swift index 266d7c4..e76b38c 100644 --- a/Tests/SliderTests.swift +++ b/Tests/SliderTests.swift @@ -38,6 +38,13 @@ struct SliderCommandSurfaceTests { #expect(Slider.clampedDragEndX(402, applicationFrame: applicationFrame) == 390) #expect(Slider.clampedDragEndX(120, applicationFrame: applicationFrame) == 120) } + + @Test("Slider command skips overdrive when already within verification tolerance") + func sliderCommandSkipsOverdriveWhenAlreadyWithinVerificationTolerance() { + #expect(Slider.commandedNormalizedValue(currentNormalized: 0.3994, targetNormalized: 0.4) == 0.3994) + #expect(Slider.commandedNormalizedValue(currentNormalized: 0.4006, targetNormalized: 0.4) == 0.4006) + #expect(Slider.commandedNormalizedValue(currentNormalized: 0.398, targetNormalized: 0.4) > 0.4) + } } @Suite("Slider Command Tests", .serialized, .enabled(if: isE2EEnabled))