Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 98 additions & 26 deletions AxePlaygroundApp/AxePlayground/Views/AccessibilityFixturesView.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import SwiftUI
import UIKit

struct SliderValueTestView: View {
@State private var sliderValue = 0.25
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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<String>

init(selectedRow: Binding<String>) {
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]
}
}
}
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
162 changes: 162 additions & 0 deletions Tests/PresentationFixtureTests.swift
Original file line number Diff line number Diff line change
@@ -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")")
}
}
Loading