From c2864c0aac5b636eac119556c965ff77ba7e7b25 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 11 May 2026 09:52:40 +0100 Subject: [PATCH] test(accessibility): Add navigation chrome fixtures Add playground fixtures and E2E coverage for navigation search fields, toolbar segmented pickers, generated navigation back buttons, and numeric AXValue fields in accessibility trees. The tests assert simulator state after AXe actions so they verify the event was processed, not just that the CLI returned success. Co-Authored-By: OpenAI Codex --- .../AxePlayground/ContentView.swift | 11 +++ .../Views/AccessibilityFixturesView.swift | 97 +++++++++++++++++++ CHANGELOG.md | 2 +- Tests/DescribeUITests.swift | 28 ++++++ Tests/TapTests.swift | 58 +++++++++++ Tests/TypeTests.swift | 31 ++++++ 6 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 AxePlaygroundApp/AxePlayground/Views/AccessibilityFixturesView.swift diff --git a/AxePlaygroundApp/AxePlayground/ContentView.swift b/AxePlaygroundApp/AxePlayground/ContentView.swift index 073a3fa..3a8b11e 100644 --- a/AxePlaygroundApp/AxePlayground/ContentView.swift +++ b/AxePlaygroundApp/AxePlayground/ContentView.swift @@ -59,6 +59,12 @@ struct ContentView: View { SwitchTestView() case "tab-view-test": TabViewTestView() + case "slider-value-test": + SliderValueTestView() + case "searchable-test": + SearchableTestView() + case "toolbar-picker-test": + ToolbarPickerTestView() // Input & Text case "text-input": @@ -106,6 +112,11 @@ struct MainMenuView: View { ("Batch", [ ("batch-test", "Batch Test", "State changes + delayed element appearance"), ("batch-login-flow", "Batch Login Flow", "Multi-step login + loading + post-login action") + ]), + ("Accessibility", [ + ("slider-value-test", "Slider Value Test", "Numeric AXValue with selector tap"), + ("searchable-test", "Searchable Test", "Navigation search field targeting"), + ("toolbar-picker-test", "Toolbar Picker Test", "Toolbar segmented picker targeting") ]) ] diff --git a/AxePlaygroundApp/AxePlayground/Views/AccessibilityFixturesView.swift b/AxePlaygroundApp/AxePlayground/Views/AccessibilityFixturesView.swift new file mode 100644 index 0000000..f9ad2a0 --- /dev/null +++ b/AxePlaygroundApp/AxePlayground/Views/AccessibilityFixturesView.swift @@ -0,0 +1,97 @@ +import SwiftUI + +struct SliderValueTestView: View { + @State private var sliderValue = 0.25 + @State private var state = "Initial" + + var body: some View { + VStack(spacing: 24) { + Text("Slider Value State: \(state)") + .accessibilityIdentifier("slider-value-state") + .accessibilityValue(state) + + Button("Slider Value Button") { + state = "Tapped" + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("slider-value-button") + + Text("Slider Position: \(sliderValue.formatted(.number.precision(.fractionLength(2))))") + .accessibilityIdentifier("slider-position-value") + .accessibilityValue(sliderValue.formatted(.number.precision(.fractionLength(2)))) + + Slider(value: $sliderValue, in: 0...1) + .accessibilityIdentifier("slider-value-slider") + .accessibilityLabel("Slider Value Slider") + } + .padding() + .navigationTitle("Slider Value") + .navigationBarTitleDisplayMode(.inline) + } +} + +struct SearchableTestView: View { + @State private var query = "" + + private var rows: [String] { + let allRows = ["Alpha Row", "Beta Row"] + guard !query.isEmpty else { return allRows } + return allRows.filter { $0.localizedCaseInsensitiveContains(query) } + } + + var body: some View { + List { + Text("Search Query: \(query.isEmpty ? "empty" : query)") + .accessibilityIdentifier("searchable-test-query") + .accessibilityValue(query.isEmpty ? "empty" : query) + + ForEach(rows, id: \.self) { row in + Text(row) + .accessibilityIdentifier("searchable-test-\(row.replacingOccurrences(of: " ", with: "-").lowercased())") + } + } + .navigationTitle("Searchable Test") + .navigationBarTitleDisplayMode(.large) + .searchable( + text: $query, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search Books" + ) + } +} + +struct ToolbarPickerTestView: View { + private enum Filter: String, CaseIterable, Identifiable { + case all = "All" + case unread = "Unread" + case read = "Read" + + var id: String { rawValue } + } + + @State private var filter: Filter = .all + + var body: some View { + List { + Text("Toolbar Picker State: \(filter.rawValue)") + .accessibilityIdentifier("toolbar-picker-test-state") + .accessibilityValue(filter.rawValue) + + Text("Toolbar Picker Detail Body") + .accessibilityIdentifier("toolbar-picker-test-body") + } + .navigationTitle("Toolbar Picker") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Picker("Filter", selection: $filter) { + ForEach(Filter.allCases) { filter in + Text(filter.rawValue).tag(filter) + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("toolbar-picker-test-filter") + } + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index a16d421..ba8efe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Fixed `describe-ui` and selector-based `tap --label` exposing and activating real SwiftUI `TabView` tab items from the CoreSimulator accessibility bridge. +- 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. - Fixed `tap`, `touch`, `swipe`, and matching batch steps dispatching logical UI coordinates directly to FBSimulatorHIDEvent without landscape rotation or letterbox correction, causing interactions to land in the wrong location in rotated landscape simulators and portrait-hardware landscape-only apps. AXe now detects the simulator UI orientation automatically instead of requiring callers to pass landscape flags ([#5](https://github.com/cameroncooke/AXe/issues/5) by [@Nitewriter](https://github.com/Nitewriter)) - Fixed selector-based `tap` and batch tap steps so UIKit `UISwitch` and SwiftUI `Toggle` controls can be activated reliably, including when a matched row or label contains a single switch/toggle control. Added `--tap-style` so switch/toggle taps can use physical touch automatically while normal taps keep the simulator `tapAt` path by default ([#46](https://github.com/cameroncooke/AXe/pull/46)). - Fixed element comparison in `AccessibilityTargetResolver` to prevent distinct elements with the same type and frame but lacking labels/values from being incorrectly identified as identical during ancestor tree traversal. diff --git a/Tests/DescribeUITests.swift b/Tests/DescribeUITests.swift index ef7b29a..95bb44e 100644 --- a/Tests/DescribeUITests.swift +++ b/Tests/DescribeUITests.swift @@ -65,6 +65,34 @@ struct DescribeUITests { #expect(settingsTab?.frame != nil) } + @Test("Describe-ui exposes navigation searchable field") + func describeUIExposesNavigationSearchableField() async throws { + try await TestHelpers.launchPlaygroundApp(to: "searchable-test") + + let uiState = try await TestHelpers.getUIState() + let searchField = UIStateParser.findElement(in: uiState) { element in + element.type == "TextField" && element.value == "Search Books" + } + + #expect(searchField?.frame != nil) + } + + @Test("Describe-ui exposes toolbar segmented picker and navigation back button") + func describeUIExposesToolbarPickerAndBackButton() async throws { + try await TestHelpers.launchPlaygroundApp(to: "toolbar-picker-test") + + let uiState = try await TestHelpers.getUIState() + let backButton = UIStateParser.findElement(in: uiState, withIdentifier: "BackButton") + let allOption = UIStateParser.findElementByLabel(in: uiState, label: "All") + let unreadOption = UIStateParser.findElementByLabel(in: uiState, label: "Unread") + let readOption = UIStateParser.findElementByLabel(in: uiState, label: "Read") + + #expect(backButton?.type == "Button") + #expect(allOption?.type == "RadioButton") + #expect(unreadOption?.type == "RadioButton") + #expect(readOption?.type == "RadioButton") + } + @Test("Describe-ui --point returns the targeted element") func describeUIAtPoint() async throws { let simulatorUDID = try TestHelpers.requireSimulatorUDID() diff --git a/Tests/TapTests.swift b/Tests/TapTests.swift index 7725617..8dcaebe 100644 --- a/Tests/TapTests.swift +++ b/Tests/TapTests.swift @@ -222,6 +222,64 @@ struct TapTests { #expect(selectedState == "Current Tab: Settings") } + @Test("Selector tap works when the accessibility tree contains numeric AXValue") + func selectorTapWorksWhenAccessibilityTreeContainsNumericAXValue() async throws { + try await TestHelpers.launchPlaygroundApp(to: "slider-value-test") + + let initialState = try await TestHelpers.waitForLabel(containing: "Slider Value State:", timeout: 3) { + $0 == "Slider Value State: Initial" + } + #expect(initialState == "Slider Value State: Initial") + + let uiState = try await TestHelpers.getUIState() + let positionText = UIStateParser.findElementByLabel(in: uiState, label: "Slider Position: 0.25") + let slider = UIStateParser.findElement(in: uiState) { element in + element.type == "Slider" && element.value == "0.25" + } + #expect(positionText?.value == "0.25") + #expect(slider != nil) + + try await TestHelpers.runAxeCommand("tap --id slider-value-button", simulatorUDID: defaultSimulatorUDID) + + let tappedState = try await TestHelpers.waitForLabel(containing: "Slider Value State:", timeout: 3) { + $0 == "Slider Value State: Tapped" + } + #expect(tappedState == "Slider Value State: Tapped") + } + + @Test("Selector tap switches toolbar segmented picker") + func selectorTapSwitchesToolbarSegmentedPicker() async throws { + try await TestHelpers.launchPlaygroundApp(to: "toolbar-picker-test") + + let initialState = try await TestHelpers.waitForLabel(containing: "Toolbar Picker State:", timeout: 3) { + $0 == "Toolbar Picker State: All" + } + #expect(initialState == "Toolbar Picker State: All") + + try await TestHelpers.runAxeCommand("tap --label Unread --element-type RadioButton", simulatorUDID: defaultSimulatorUDID) + + let selectedState = try await TestHelpers.waitForLabel(containing: "Toolbar Picker State:", timeout: 3) { + $0 == "Toolbar Picker State: Unread" + } + #expect(selectedState == "Toolbar Picker State: Unread") + } + + @Test("Selector tap activates generated navigation back button") + func selectorTapActivatesGeneratedNavigationBackButton() async throws { + try await TestHelpers.launchPlaygroundApp(to: "toolbar-picker-test") + + let uiState = try await TestHelpers.getUIState() + let backButton = UIStateParser.findElement(in: uiState, withIdentifier: "BackButton") + #expect(backButton?.type == "Button") + + try await TestHelpers.runAxeCommand("tap --id BackButton", simulatorUDID: defaultSimulatorUDID) + + let menuState = try await TestHelpers.waitForLabel(containing: "Touch & Gestures", timeout: 3) { + $0 == "Touch & Gestures" + } + #expect(menuState == "Touch & Gestures") + } + @Test("Selector tap toggles SwiftUI Toggle") func selectorTapTogglesSwiftUIToggle() async throws { try await TestHelpers.launchPlaygroundApp(to: "switch-test") diff --git a/Tests/TypeTests.swift b/Tests/TypeTests.swift index ca47898..87e8bd3 100644 --- a/Tests/TypeTests.swift +++ b/Tests/TypeTests.swift @@ -169,6 +169,37 @@ struct TypeTests { #expect(textFieldElement?.value == textToType, "Text should still be typed with manual delays") } + @Test("Typing into navigation searchable field filters visible state") + func typingIntoNavigationSearchableFieldFiltersVisibleState() async throws { + try await TestHelpers.launchPlaygroundApp(to: "searchable-test") + + let initialState = try await TestHelpers.waitForLabel(containing: "Search Query:", timeout: 3) { + $0 == "Search Query: empty" + } + #expect(initialState == "Search Query: empty") + + let uiState = try await TestHelpers.getUIState() + let searchField = UIStateParser.findElement(in: uiState) { element in + element.type == "TextField" && element.value == "Search Books" + } + #expect(searchField?.frame != nil) + + try await TestHelpers.runAxeCommand("tap --value 'Search Books' --element-type TextField", simulatorUDID: defaultSimulatorUDID) + try await Task.sleep(nanoseconds: 400_000_000) + try await TestHelpers.runAxeCommand("type Alpha", simulatorUDID: defaultSimulatorUDID) + + let filteredState = try await TestHelpers.waitForLabel(containing: "Search Query:", timeout: 3) { + $0 == "Search Query: Alpha" + } + #expect(filteredState == "Search Query: Alpha") + + let filteredUIState = try await TestHelpers.getUIState() + let alphaRow = UIStateParser.findElementByLabel(in: filteredUIState, label: "Alpha Row") + let betaRow = UIStateParser.findElementByLabel(in: filteredUIState, label: "Beta Row") + #expect(alphaRow != nil) + #expect(betaRow == nil) + } + @Test("Unsupported characters throw error") func unsupportedCharactersError() async throws { // Arrange