Skip to content

axe tap --id fails with typeMismatch whenever the AX tree contains an AXSlider (Number AXValue) #45

@vermont42

Description

@vermont42

Summary

axe tap --id <any-id> fails with Error: typeMismatch(Swift.Dictionary<Swift.String, Any>, Swift.DecodingError.Context(codingPath: [], debugDescription: "Expected to decode Dictionary<String, Any> but found an array instead.", underlyingError: nil)) whenever the rendered AX tree contains any element with role: AXSlider (SwiftUI Slider, Picker(.wheel) over UIPickerView, UISlider directly). The error is identifier-agnostic — every identifier in the tree fails the same way until the slider is removed.

axe describe-ui works on the same tree (it dumps raw JSON without going through the AccessibilityElement decoder), so the failure is in the JSON model, not in FBSimulatorControl.

Repro

Minimal SwiftUI iOS app, root view:

import SwiftUI

@main
struct App: App {
    var body: some Scene { WindowGroup { Root() } }
}

struct Root: View {
    @State private var x: Double = 0.5
    var body: some View {
        VStack(spacing: 24) {
            Text("Hello").accessibilityIdentifier("title")
            Button("Tap") {}.accessibilityIdentifier("btn")
            Slider(value: $x, in: 0...1)   // remove this and tap_id works
        }.padding()
    }
}

Build, install on a booted iPhone simulator (tested: iPhone 17 / iOS 26.3 / Xcode 26.3), launch, then:

$ axe tap --id title --udid <udid>
Error: typeMismatch(Swift.Dictionary<Swift.String, Any>, ...)
$ axe tap --id btn --udid <udid>
Error: typeMismatch(Swift.Dictionary<Swift.String, Any>, ...)

Remove the Slider(...) line and both calls return exit 0.

Cause

Sources/AXe/Utilities/AccessibilityElement.swift:32:

let AXValue: String?

iOS's accessibility serializer emits AXValue as a JSON Number for AXSlider-role elements (Float for SwiftUI Slider / UISlider, Int for UIPickerView-backed Picker(.wheel)). JSONDecoder throws typeMismatch against String? on that field for the slider element, which aborts the whole array decode.

The error message is misleading because of the swallow-and-retry in Sources/AXe/Utilities/AccessibilityFetcher.swift:40-50:

if let roots = try? decoder.decode([AccessibilityElement].self, from: jsonData) {
    return roots
}
let root = try decoder.decode(AccessibilityElement.self, from: jsonData)   // surfaces a "Dictionary vs Array" error

The first decode is where the real Number → String? mismatch happens; the try? discards that error, and the second decode produces the surfaced "Expected to decode Dictionary<String, Any> but found an array instead" — which sent at least one investigation off the trail.

Other findings worth noting from the same investigation:

  • UIPageControl shares the AXSlider role but emits AXValue as a String (e.g., 'page 3 of 5') and does NOT poison. So the trigger is the JSON type of AXValue, not the role.
  • ProgressView, Stepper, Toggle, UIDatePicker all emit String AXValue and are safe.
  • iOS's accessibility serializer for UISlider ignores a Swift-side accessibilityValue: String? override and reads value: Float directly — so adopters can't fix this from their app's side via the documented accessibilityValue protocol.

Suggested fix

Make AXValue a polymorphic field that decodes String, Number, or null:

let AXValue: AXValueField?

enum AXValueField: Decodable {
    case string(String)
    case number(Double)

    init(from decoder: Decoder) throws {
        let c = try decoder.singleValueContainer()
        if let s = try? c.decode(String.self) { self = .string(s); return }
        if let d = try? c.decode(Double.self) { self = .number(d); return }
        throw DecodingError.typeMismatch(
            AXValueField.self,
            .init(codingPath: decoder.codingPath,
                  debugDescription: "AXValue is neither String nor Number"))
    }

    var stringValue: String {
        switch self {
        case .string(let s): return s
        case .number(let d):
            return d.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(d)) : String(d)
        }
    }
}

Plus normalizedValue becomes AXValue?.stringValue.trimming....

Worth noting: the try? swallow on AccessibilityFetcher.swift:44 is locally unreachable for fetchAccessibilityElements (which always passes point: nil, returning array shape) but encodes a real polymorphism for DescribeUI.swift's --point path (which returns dict shape). Don't drop it naively. A cleaner path is to peek the first non-whitespace JSON byte and dispatch explicitly:

let first = jsonData.first(where: { !$0.isASCIIWhitespace })
switch first {
case UInt8(ascii: "["): return try decoder.decode([AccessibilityElement].self, from: jsonData)
case UInt8(ascii: "{"): return [try decoder.decode(AccessibilityElement.self, from: jsonData)]
default: throw CLIError(errorDescription: "Unexpected accessibility JSON shape")
}

This honors the documented dual-shape contract while surfacing the real Number → String? typeMismatch instead of masking it as a Dictionary-vs-Array mismatch. Same pattern would apply to Tests/TestUtilities.swift:157-175.

Environment

  • AXe 1.6.0 (Homebrew) / repo HEAD 550b3ab
  • iPhone 17 simulator, iOS 26.3, Xcode 26.3
  • macOS 15 (Darwin 24.6.0)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions