diff --git a/AxePlaygroundApp/AxePlayground/Views/AccessibilityFixturesView.swift b/AxePlaygroundApp/AxePlayground/Views/AccessibilityFixturesView.swift index a2697e5..ee896cc 100644 --- a/AxePlaygroundApp/AxePlayground/Views/AccessibilityFixturesView.swift +++ b/AxePlaygroundApp/AxePlayground/Views/AccessibilityFixturesView.swift @@ -1,5 +1,6 @@ import Foundation import SwiftUI +import UIKit struct SliderValueTestView: View { @State private var sliderValue = 0.25 @@ -202,20 +203,23 @@ struct ContextMenuTestView: View { .accessibilityIdentifier("context-menu-test-state") .accessibilityValue(state) - Text("Long Press Target") - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - .background(.blue.opacity(0.15), in: RoundedRectangle(cornerRadius: 12)) - .accessibilityIdentifier("context-menu-test-target") - .contextMenu { - Button("Favorite") { - state = "Favorited" - } - Button("Archive") { - state = "Archived" - } + Button("Long Press Target") { + state = "Tapped" + } + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + .background(.blue.opacity(0.15), in: RoundedRectangle(cornerRadius: 12)) + .buttonStyle(.plain) + .accessibilityIdentifier("context-menu-test-target") + .contextMenu { + Button("Favorite") { + state = "Favorited" } + Button("Archive") { + state = "Archived" + } + } } .padding() .navigationTitle("Context Menu") @@ -281,24 +285,92 @@ struct ModalNavigationTestView: View { struct LongScrollTestView: View { private let rows = Array(1...80) + @State private var selectedRow = "None" var body: some View { - List { - Text("Long Scroll Start") - .font(.headline) - .accessibilityIdentifier("long-scroll-test-start") - - ForEach(rows, id: \.self) { row in - Text("Long Scroll Row \(row)") - .accessibilityIdentifier("long-scroll-test-row-\(row)") - } + VStack(spacing: 0) { + Text("Long Scroll Selected: \(selectedRow)") + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .accessibilityIdentifier("long-scroll-test-state") + .accessibilityValue(selectedRow) - Text("Long Scroll End") - .font(.headline) - .accessibilityIdentifier("long-scroll-test-end") + LongScrollTableView(rows: rows, selectedRow: $selectedRow) } .navigationTitle("Long Scroll") .navigationBarTitleDisplayMode(.inline) - .accessibilityIdentifier("long-scroll-test-list") + } +} + +private struct LongScrollTableView: UIViewRepresentable { + let rows: [Int] + @Binding var selectedRow: String + + func makeCoordinator() -> Coordinator { + Coordinator(selectedRow: $selectedRow) + } + + func makeUIView(context: Context) -> UITableView { + let tableView = UITableView(frame: .zero, style: .plain) + tableView.dataSource = context.coordinator + tableView.delegate = context.coordinator + tableView.rowHeight = 64 + tableView.accessibilityIdentifier = "long-scroll-test-scroll-view" + tableView.register(UITableViewCell.self, forCellReuseIdentifier: Coordinator.cellReuseIdentifier) + return tableView + } + + func updateUIView(_ tableView: UITableView, context: Context) { + context.coordinator.rows = rows + context.coordinator.selectedRow = $selectedRow + tableView.reloadData() + } + + final class Coordinator: NSObject, UITableViewDataSource, UITableViewDelegate { + static let cellReuseIdentifier = "LongScrollRowCell" + + var rows: [Int] = [] + var selectedRow: Binding + + init(selectedRow: Binding) { + self.selectedRow = selectedRow + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + rows.count + 2 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: Self.cellReuseIdentifier, for: indexPath) + let title = title(for: indexPath.row) + cell.textLabel?.text = title + cell.accessibilityLabel = title + cell.accessibilityIdentifier = identifier(for: indexPath.row) + cell.accessibilityTraits.insert(.button) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard let row = rowNumber(for: indexPath.row) else { return } + selectedRow.wrappedValue = "Row \(row)" + } + + private func title(for index: Int) -> String { + if index == 0 { return "Long Scroll Start" } + if index == rows.count + 1 { return "Long Scroll End" } + return "Long Scroll Row \(rows[index - 1])" + } + + private func identifier(for index: Int) -> String { + if index == 0 { return "long-scroll-test-start" } + if index == rows.count + 1 { return "long-scroll-test-end" } + return "long-scroll-test-row-\(rows[index - 1])" + } + + private func rowNumber(for index: Int) -> Int? { + guard index > 0, index <= rows.count else { return nil } + return rows[index - 1] + } } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ab7d97..858acb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the AXe iOS testing framework will be documented in this The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Added AxePlayground alert, sheet, context menu, modal navigation, and long-scroll fixtures for UI automation regression coverage. + ## [v1.7.0] - 2026-05-11 ### Added diff --git a/Tests/PresentationFixtureTests.swift b/Tests/PresentationFixtureTests.swift new file mode 100644 index 0000000..4223d1c --- /dev/null +++ b/Tests/PresentationFixtureTests.swift @@ -0,0 +1,162 @@ +import Testing +import Foundation + +@Suite("Presentation Fixture Tests", .serialized, .enabled(if: isE2EEnabled)) +struct PresentationFixtureTests { + @Test("Describe-ui exposes new presentation fixture routes", arguments: [ + (screen: "alert-test", identifier: "alert-test-show-alert", label: "Show Alert"), + (screen: "sheet-test", identifier: "sheet-test-open-sheet", label: "Open Sheet"), + (screen: "context-menu-test", identifier: "context-menu-test-target", label: "Long Press Target"), + (screen: "modal-navigation-test", identifier: "modal-navigation-test-open", label: "Open Modal Flow"), + (screen: "long-scroll-test", identifier: "long-scroll-test-scroll-view", label: "Long Scroll Row 1") + ]) + func describeUIExposesPresentationFixtureRoutes( + fixture: (screen: String, identifier: String, label: String) + ) async throws { + try await TestHelpers.launchPlaygroundApp(to: fixture.screen) + + let uiState = try await TestHelpers.getUIState() + let identifiedElement = UIStateParser.findElement(in: uiState, withIdentifier: fixture.identifier) + let labeledElement = UIStateParser.findElementByLabel(in: uiState, label: fixture.label) + + #expect(identifiedElement?.frame != nil) + #expect(labeledElement?.frame != nil) + } + + @Test("Alert fixture exposes alert controls and applies selected action") + func alertFixtureExposesAlertControlsAndAction() async throws { + try await TestHelpers.launchPlaygroundApp(to: "alert-test") + + try await TestHelpers.runAxeCommand("tap --id alert-test-show-alert", simulatorUDID: defaultSimulatorUDID) + + _ = try await waitForElement(timeout: 3) { uiState in + UIStateParser.findElementByLabel(in: uiState, label: "Delete Draft?") + } + let deleteButton = try await waitForElement(timeout: 3) { uiState in + UIStateParser.findElementByLabel(in: uiState, label: "Delete") + } + #expect(deleteButton.type == "Button") + + try await TestHelpers.runAxeCommand("tap --label Delete --element-type Button", simulatorUDID: defaultSimulatorUDID) + + let state = try await TestHelpers.waitForLabel(containing: "Alert State:", timeout: 3) { + $0 == "Alert State: Deleted" + } + #expect(state == "Alert State: Deleted") + } + + @Test("Sheet fixture exposes sheet content and returns updated state") + func sheetFixtureExposesSheetContentAndState() async throws { + try await TestHelpers.launchPlaygroundApp(to: "sheet-test") + + try await TestHelpers.runAxeCommand("tap --id sheet-test-open-sheet", simulatorUDID: defaultSimulatorUDID) + + _ = try await waitForElement(timeout: 3) { uiState in + UIStateParser.findElementByLabel(in: uiState, label: "Sheet Fixture") + } + let actionButton = try await waitForElement(timeout: 3) { uiState in + UIStateParser.findElementByLabel(in: uiState, label: "Run Sheet Action") + } + #expect(actionButton.type == "Button") + + try await TestHelpers.runAxeCommand("tap --label 'Run Sheet Action' --element-type Button", simulatorUDID: defaultSimulatorUDID) + try await TestHelpers.runAxeCommand("tap --label 'Close Sheet' --element-type Button", simulatorUDID: defaultSimulatorUDID) + + let state = try await TestHelpers.waitForLabel(containing: "Sheet State:", timeout: 3) { + $0 == "Sheet State: Sheet action tapped" + } + #expect(state == "Sheet State: Sheet action tapped") + } + + @Test("Context menu fixture exposes menu actions after long press") + func contextMenuFixtureExposesMenuActions() async throws { + try await TestHelpers.launchPlaygroundApp(to: "context-menu-test") + + let initialState = try await TestHelpers.getUIState() + let target = try #require(UIStateParser.findElement(in: initialState, withIdentifier: "context-menu-test-target")) + let frame = try #require(target.frame) + let centerX = Int(frame.x + frame.width / 2) + let centerY = Int(frame.y + frame.height / 2) + + try await TestHelpers.runAxeCommand( + "touch -x \(centerX) -y \(centerY) --down --up --delay 1.0", + simulatorUDID: defaultSimulatorUDID + ) + + let favoriteAction = try await waitForElement(timeout: 3) { uiState in + UIStateParser.findElementByLabel(in: uiState, label: "Favorite") + } + #expect(favoriteAction.frame != nil) + + try await TestHelpers.runAxeCommand("tap --label Favorite", simulatorUDID: defaultSimulatorUDID) + + let state = try await TestHelpers.waitForLabel(containing: "Context Menu State:", timeout: 3) { + $0 == "Context Menu State: Favorited" + } + #expect(state == "Context Menu State: Favorited") + } + + @Test("Modal navigation fixture exposes nested route actions") + func modalNavigationFixtureExposesNestedRouteActions() async throws { + try await TestHelpers.launchPlaygroundApp(to: "modal-navigation-test") + + try await TestHelpers.runAxeCommand("tap --id modal-navigation-test-open", simulatorUDID: defaultSimulatorUDID) + + _ = try await waitForElement(timeout: 3) { uiState in + UIStateParser.findElementByLabel(in: uiState, label: "Modal Flow") + } + let detailLink = try await waitForElement(timeout: 3) { uiState in + UIStateParser.findElement(in: uiState, withIdentifier: "modal-navigation-test-detail-link") + } + #expect(detailLink.frame != nil) + + try await TestHelpers.runAxeCommand("tap --id modal-navigation-test-detail-link", simulatorUDID: defaultSimulatorUDID) + + _ = try await waitForElement(timeout: 3) { uiState in + UIStateParser.findElement(in: uiState, withIdentifier: "modal-navigation-test-detail") + } + let completeButton = try await waitForElement(timeout: 3) { uiState in + UIStateParser.findElement(in: uiState, withIdentifier: "modal-navigation-test-complete") + } + #expect(completeButton.type == "Button") + } + + @Test("Long scroll fixture exposes a scroll container and tappable rows") + func longScrollFixtureExposesScrollContainerAndRows() async throws { + try await TestHelpers.launchPlaygroundApp(to: "long-scroll-test") + + let uiState = try await TestHelpers.getUIState() + let scrollView = UIStateParser.findElement(in: uiState, withIdentifier: "long-scroll-test-scroll-view") + let firstRow = UIStateParser.findElement(in: uiState, withIdentifier: "long-scroll-test-row-1") + + #expect(scrollView?.frame != nil) + #expect(firstRow?.label == "Long Scroll Row 1") + #expect(firstRow?.frame != nil) + + try await TestHelpers.runAxeCommand("tap --id long-scroll-test-row-1", simulatorUDID: defaultSimulatorUDID) + + let state = try await TestHelpers.waitForLabel(containing: "Long Scroll Selected:", timeout: 3) { + $0 == "Long Scroll Selected: Row 1" + } + #expect(state == "Long Scroll Selected: Row 1") + } + + private func waitForElement( + timeout: TimeInterval, + matching predicate: (UIElement) -> UIElement? + ) async throws -> UIElement { + let deadline = Date().addingTimeInterval(timeout) + var lastRootType: String? + + while Date() < deadline { + let uiState = try await TestHelpers.getUIState() + lastRootType = uiState.type + if let element = predicate(uiState) { + return element + } + try await Task.sleep(nanoseconds: 200_000_000) + } + + throw TestError.unexpectedState("Timed out waiting for presentation fixture element. Last root type: \(lastRootType ?? "none")") + } +}