From 570d4f30983914c75e09b40d37f7301d41a11dff Mon Sep 17 00:00:00 2001 From: Chris Stroud Date: Wed, 20 May 2026 12:25:20 -0400 Subject: [PATCH 1/8] Modernize library for Swift 6, iOS 16+; add @AXScreen macro, dynamic component enhancements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump swift-tools-version to 6.0, minimum deployment to iOS 16 / macOS 13 - Full Swift 6 strict concurrency compliance (Sendable, @MainActor) - Add @AXScreen macro for automatic screenIdentifier synthesis - Introduce AXIdentifierConvertible protocol, collapsing 4× overload explosion - Refactor navigator: static .navigator property, rename performNavigation→navigate, add tap convenience - Add optional-value and prefixed automationComponent view modifiers for dynamic components - Add firstElement(anyOf:) and tapFirst(anyOf:) for prefix-based dynamic element queries - Make AXDynamicComponent.prefix public for downstream query use - Add navigator screen-appear logging via NSLog - Add animation suppression system for UIKit, Core Animation, and SwiftUI - Refactor AXFailure to avoid double failure output - Fix accessibility modifiers interfering with VoiceOver - Fix NSPredicate injection in tab query - Improve scroll gesture reliability - Remove deprecated XCTestCase async setUp bridge --- Package.swift | 27 +- .../AXDynamicComponent.swift | 47 +--- .../Dynamic Components/AXDynamicValue.swift | 6 +- .../AXIdentifierConvertible.swift | 59 +++++ .../Static Components/AXComponent.swift | 2 +- .../Static Components/AXScrollView.swift | 2 +- .../Configuration/AXAutomation.swift | 31 +++ .../Configuration/AnimationSuppressor.swift | 23 ++ .../Extensions/App UI/View+AXComponent.swift | 4 +- .../App UI/View+AXDynamicComponent.swift | 125 +++------ .../Extensions/App UI/View+AXScrollView.swift | 1 - .../App UI/View+AnimationSuppression.swift | 42 +++ .../App UI/View+ScreenIdentityModifier.swift | 3 +- .../Temporal/Measurement+TimeInterval.swift | 2 - .../Screen Models/AXScreen.swift | 2 +- .../AXScreenModel+Components.swift | 67 +---- .../AXScreenMacroDeclaration.swift | 9 + .../AXComponentKitMacroSupport/Exports.swift | 1 + .../AXComponentKitMacros/AXScreenMacro.swift | 103 ++++++++ Sources/AXComponentKitMacros/Plugin.swift | 9 + .../AXComponentKitTestSupport/AXFailure.swift | 43 +-- .../AXScreenModel+AssumedElements.swift | 247 ++---------------- .../AXScreenModel+DynamicElements.swift | 143 +--------- .../AXScreenModel+FirstDynamicElement.swift | 83 ++++++ .../AXScreenModel+TabComponents.swift | 2 +- .../Element Querying/AXScreenModel+Tap.swift | 28 ++ .../Existence/XCUIElement+Existence.swift | 4 +- .../XCUIApplication+AutomationLaunch.swift | 27 ++ .../AXScreenNavigator+Scrolling.swift | 160 ++---------- .../Navigation/AXScreenNavigator.swift | 31 +-- .../Navigation/AXTabBarNavigable.swift | 31 +-- .../Navigation/AXTabComponent.swift | 4 +- .../Navigation/ScrollTransaction.swift | 8 +- .../XCTestCase+AsyncSetup.swift | 39 --- 34 files changed, 583 insertions(+), 832 deletions(-) create mode 100644 Sources/AXComponentKit/AX Components/Dynamic Components/AXIdentifierConvertible.swift create mode 100644 Sources/AXComponentKit/Configuration/AXAutomation.swift create mode 100644 Sources/AXComponentKit/Configuration/AnimationSuppressor.swift create mode 100644 Sources/AXComponentKit/Extensions/App UI/View+AnimationSuppression.swift create mode 100644 Sources/AXComponentKitMacroSupport/AXScreenMacroDeclaration.swift create mode 100644 Sources/AXComponentKitMacroSupport/Exports.swift create mode 100644 Sources/AXComponentKitMacros/AXScreenMacro.swift create mode 100644 Sources/AXComponentKitMacros/Plugin.swift create mode 100644 Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+FirstDynamicElement.swift create mode 100644 Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+Tap.swift create mode 100644 Sources/AXComponentKitTestSupport/Launch/XCUIApplication+AutomationLaunch.swift delete mode 100644 Sources/AXComponentKitTestSupport/XCTestCase+AsyncSetup.swift diff --git a/Package.swift b/Package.swift index 3657fd6..a026825 100644 --- a/Package.swift +++ b/Package.swift @@ -1,10 +1,11 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 6.0 +import CompilerPluginSupport import PackageDescription let package = Package( name: "AXComponentKit", - platforms: [.iOS(.v14)], + platforms: [.iOS(.v16), .macOS(.v13)], products: [ .library( name: "AXComponentKit", @@ -14,8 +15,14 @@ let package = Package( name: "AXComponentKitTestSupport", targets: ["AXComponentKitTestSupport"] ), + .library( + name: "AXComponentKitMacroSupport", + targets: ["AXComponentKitMacroSupport"] + ), + ], + dependencies: [ + .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.0"), ], - dependencies: [], targets: [ .target( name: "AXComponentKit", @@ -25,5 +32,19 @@ let package = Package( name: "AXComponentKitTestSupport", dependencies: [.targetItem(name: "AXComponentKit", condition: .none)] ), + .macro( + name: "AXComponentKitMacros", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ] + ), + .target( + name: "AXComponentKitMacroSupport", + dependencies: [ + "AXComponentKit", + "AXComponentKitMacros", + ] + ), ] ) diff --git a/Sources/AXComponentKit/AX Components/Dynamic Components/AXDynamicComponent.swift b/Sources/AXComponentKit/AX Components/Dynamic Components/AXDynamicComponent.swift index 514e9c7..88f963f 100644 --- a/Sources/AXComponentKit/AX Components/Dynamic Components/AXDynamicComponent.swift +++ b/Sources/AXComponentKit/AX Components/Dynamic Components/AXDynamicComponent.swift @@ -10,9 +10,9 @@ import Foundation /// may not be knowable at compile-time. For example, list rows can have /// fully unique identifiers for each row if the `Value` is something unique /// such as a row index, or model object UUID. -public struct AXDynamicComponent: ExpressibleByStringLiteral { +public struct AXDynamicComponent: ExpressibleByStringLiteral, Sendable { /// The static prefix for this component - internal let prefix: String + public let prefix: String /// Creates a new `AXDynamicComponent` with the given prefix, /// which will be dynamically concatenated to a suffix at runtime. @@ -36,7 +36,7 @@ public struct AXDynamicComponent: ExpressibleByStringLiteral { } } -public extension AXDynamicComponent where Value: AXDynamicValue { +public extension AXDynamicComponent { /// Resolves an `AXComponent` by combining the existing prefix /// with the provided suffix. /// @@ -45,45 +45,6 @@ public extension AXDynamicComponent where Value: AXDynamicValue { /// - Returns: /// A new, fully qualified `AXComponent` func resolve(_ suffix: Value) -> AXComponent { - resolve(with: suffix.automationDynamicValue) - } -} - -public extension AXDynamicComponent where Value: StringProtocol { - /// Resolves an `AXComponent` by combining the existing prefix - /// with the provided suffix. - /// - /// - Parameter suffix: - /// The trailing end of the computed identifier - /// - Returns: - /// A new, fully qualified `AXComponent` - func resolve(_ suffix: Value) -> AXComponent { - resolve(with: String(suffix)) - } -} - -public extension AXDynamicComponent where Value: SignedInteger { - /// Resolves an `AXComponent` by combining the existing prefix - /// with the provided suffix. - /// - /// - Parameter suffix: - /// The trailing end of the computed identifier - /// - Returns: - /// A new, fully qualified `AXComponent` - func resolve(_ suffix: Value) -> AXComponent { - resolve(with: String(suffix)) - } -} - -public extension AXDynamicComponent where Value: UnsignedInteger { - /// Resolves an `AXComponent` by combining the existing prefix - /// with the provided suffix. - /// - /// - Parameter suffix: - /// The trailing end of the computed identifier - /// - Returns: - /// A new, fully qualified `AXComponent` - func resolve(_ suffix: Value) -> AXComponent { - resolve(with: String(suffix)) + resolve(with: suffix.automationIdentifier) } } diff --git a/Sources/AXComponentKit/AX Components/Dynamic Components/AXDynamicValue.swift b/Sources/AXComponentKit/AX Components/Dynamic Components/AXDynamicValue.swift index 8697373..1b93f5a 100644 --- a/Sources/AXComponentKit/AX Components/Dynamic Components/AXDynamicValue.swift +++ b/Sources/AXComponentKit/AX Components/Dynamic Components/AXDynamicValue.swift @@ -13,8 +13,12 @@ import Foundation /// } /// } /// ``` -public protocol AXDynamicValue { +public protocol AXDynamicValue: AXIdentifierConvertible { /// A unique value that can be used to identify a corresponding /// `AXComponent` at runtime var automationDynamicValue: String { get } } + +public extension AXDynamicValue { + var automationIdentifier: String { automationDynamicValue } +} diff --git a/Sources/AXComponentKit/AX Components/Dynamic Components/AXIdentifierConvertible.swift b/Sources/AXComponentKit/AX Components/Dynamic Components/AXIdentifierConvertible.swift new file mode 100644 index 0000000..0b5f6cb --- /dev/null +++ b/Sources/AXComponentKit/AX Components/Dynamic Components/AXIdentifierConvertible.swift @@ -0,0 +1,59 @@ +import Foundation + +/// A type whose values can be converted to a string suitable for use +/// as part of an automation identifier. +/// +/// Conform custom types to this protocol to use them as the `Value` +/// parameter of `AXDynamicComponent`. Standard library types like +/// `String`, `Int`, and `UInt` already conform. +public protocol AXIdentifierConvertible: Sendable { + var automationIdentifier: String { get } +} + +extension String: AXIdentifierConvertible { + public var automationIdentifier: String { self } +} + +extension Substring: AXIdentifierConvertible { + public var automationIdentifier: String { String(self) } +} + +extension Int: AXIdentifierConvertible { + public var automationIdentifier: String { String(self) } +} + +extension UInt: AXIdentifierConvertible { + public var automationIdentifier: String { String(self) } +} + +extension Int8: AXIdentifierConvertible { + public var automationIdentifier: String { String(self) } +} + +extension UInt8: AXIdentifierConvertible { + public var automationIdentifier: String { String(self) } +} + +extension Int16: AXIdentifierConvertible { + public var automationIdentifier: String { String(self) } +} + +extension UInt16: AXIdentifierConvertible { + public var automationIdentifier: String { String(self) } +} + +extension Int32: AXIdentifierConvertible { + public var automationIdentifier: String { String(self) } +} + +extension UInt32: AXIdentifierConvertible { + public var automationIdentifier: String { String(self) } +} + +extension Int64: AXIdentifierConvertible { + public var automationIdentifier: String { String(self) } +} + +extension UInt64: AXIdentifierConvertible { + public var automationIdentifier: String { String(self) } +} diff --git a/Sources/AXComponentKit/AX Components/Static Components/AXComponent.swift b/Sources/AXComponentKit/AX Components/Static Components/AXComponent.swift index bfbddc8..ba7654a 100644 --- a/Sources/AXComponentKit/AX Components/Static Components/AXComponent.swift +++ b/Sources/AXComponentKit/AX Components/Static Components/AXComponent.swift @@ -4,7 +4,7 @@ import Foundation /// an application's view hierarchy. This represents an element with /// a fully qualified identifier, be it a static element that is predefined, /// or the result of resolving an `AXDynamicComponent` with some value. -public struct AXComponent: ExpressibleByStringLiteral { +public struct AXComponent: ExpressibleByStringLiteral, Sendable { /// The identifier for the component public let id: String diff --git a/Sources/AXComponentKit/AX Components/Static Components/AXScrollView.swift b/Sources/AXComponentKit/AX Components/Static Components/AXScrollView.swift index ef7a47d..2214119 100644 --- a/Sources/AXComponentKit/AX Components/Static Components/AXScrollView.swift +++ b/Sources/AXComponentKit/AX Components/Static Components/AXScrollView.swift @@ -4,7 +4,7 @@ import Foundation /// sense. This tells `AXComponentKit` that the element in question is /// scrollable without forcing any heuristic to try and detect the most relevant /// scroll view. -public struct AXScrollView: ExpressibleByStringLiteral { +public struct AXScrollView: ExpressibleByStringLiteral, Sendable { public let id: String public init(stringLiteral value: StringLiteralType) { diff --git a/Sources/AXComponentKit/Configuration/AXAutomation.swift b/Sources/AXComponentKit/Configuration/AXAutomation.swift new file mode 100644 index 0000000..6acf07d --- /dev/null +++ b/Sources/AXComponentKit/Configuration/AXAutomation.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Provides detection and optimization for AXComponentKit automation test runs. +public enum AXAutomation { + package static let runnerArgument = "-AXComponentKitRunnerActive" + + /// Whether the app was launched by an AXComponentKit test runner. + public static var isActive: Bool { + ProcessInfo.processInfo.arguments.contains(runnerArgument) + } + + /// Suppresses animations across UIKit, Core Animation, and SwiftUI + /// when the automation runner is active. No-op in production. + /// + /// Call this from `AppDelegate.application(_:didFinishLaunchingWithOptions:)` + /// or from your SwiftUI `App.init()` for UIKit/hybrid apps. For pure SwiftUI + /// apps, prefer the `.automationOptimized()` view modifier instead. + @MainActor + public static func suppressAnimationsIfNeeded() { + guard isActive else { return } + #if canImport(UIKit) + AnimationSuppressor.shared.activate() + #endif + } + + @available(*, deprecated, renamed: "suppressAnimationsIfNeeded") + @MainActor + public static func optimizeIfNeeded() { + suppressAnimationsIfNeeded() + } +} diff --git a/Sources/AXComponentKit/Configuration/AnimationSuppressor.swift b/Sources/AXComponentKit/Configuration/AnimationSuppressor.swift new file mode 100644 index 0000000..70865c1 --- /dev/null +++ b/Sources/AXComponentKit/Configuration/AnimationSuppressor.swift @@ -0,0 +1,23 @@ +#if canImport(UIKit) +import UIKit + +@MainActor +final class AnimationSuppressor { + static let shared = AnimationSuppressor() + private var isActivated = false + + func activate() { + guard !isActivated else { return } + isActivated = true + UIView.setAnimationsEnabled(false) + NotificationCenter.default.addObserver( + forName: UIWindow.didBecomeVisibleNotification, + object: nil, + queue: .main + ) { notification in + guard let window = notification.object as? UIWindow else { return } + window.layer.speed = 100 + } + } +} +#endif diff --git a/Sources/AXComponentKit/Extensions/App UI/View+AXComponent.swift b/Sources/AXComponentKit/Extensions/App UI/View+AXComponent.swift index 0dcc53d..7d4d1d3 100644 --- a/Sources/AXComponentKit/Extensions/App UI/View+AXComponent.swift +++ b/Sources/AXComponentKit/Extensions/App UI/View+AXComponent.swift @@ -23,7 +23,7 @@ public extension View { /// An `AXComponent` that provides an identity for the modified view. /// - Returns: /// The view after applying the `accessibilityIdentifier` modifier. - func automationComponent(_ component: AXComponent?) -> some View { - accessibilityIdentifier(component?.id ?? "undefined") + func automationComponent(_ component: AXComponent) -> some View { + accessibilityIdentifier(component.id) } } diff --git a/Sources/AXComponentKit/Extensions/App UI/View+AXDynamicComponent.swift b/Sources/AXComponentKit/Extensions/App UI/View+AXDynamicComponent.swift index ad586b8..244b738 100644 --- a/Sources/AXComponentKit/Extensions/App UI/View+AXDynamicComponent.swift +++ b/Sources/AXComponentKit/Extensions/App UI/View+AXDynamicComponent.swift @@ -14,124 +14,69 @@ public extension View { func automationComponent( _ path: KeyPath>, value: Value - ) -> some View where Model: AXScreen, Value: AXDynamicValue { + ) -> some View where Model: AXScreen, Value: AXIdentifierConvertible { accessibilityIdentifier(Model()[keyPath: path].resolve(value).id) } - /// Assigns an accessibility identifier according to what is defined - /// in the `AXDynamicComponent` identified by the given key path. + /// Assigns an accessibility identifier only when the value is non-nil. /// - /// - Parameters: - /// - path: - /// `KeyPath` relative to some `AXScreen` that identifies an `AXDynamicComponent`. - /// - value: - /// The dynamic value to use while resolving the component. - /// - Returns: - /// The view after applying the `accessibilityIdentifier` modifier. - func automationComponent( - _ component: AXDynamicComponent, - value: Value - ) -> some View where Value: AXDynamicValue { - accessibilityIdentifier(component.resolve(value).id) - } - - // MARK: StringProtocol - - /// Assigns an accessibility identifier according to what is defined - /// in the `AXDynamicComponent` identified by the given key path. + /// When `value` is `nil`, no `accessibilityIdentifier` is applied and the + /// element is invisible to `AXDynamicComponent`-based test queries. Use this + /// when the view may be in a state (e.g. loading, placeholder) where it should + /// not participate in automation. /// /// - Parameters: /// - path: /// `KeyPath` relative to some `AXScreen` that identifies an `AXDynamicComponent`. /// - value: - /// The dynamic value to use while resolving the component. + /// The dynamic value, or `nil` to suppress the identifier. /// - Returns: - /// The view after applying the `accessibilityIdentifier` modifier. + /// The view, with an `accessibilityIdentifier` applied only if `value` is non-nil. + @ViewBuilder func automationComponent( _ path: KeyPath>, - value: Value - ) -> some View where Model: AXScreen, Value: StringProtocol { - accessibilityIdentifier(Model()[keyPath: path].resolve(value).id) + value: Value? + ) -> some View where Model: AXScreen, Value: AXIdentifierConvertible { + if let value { + accessibilityIdentifier(Model()[keyPath: path].resolve(value).id) + } else { + self + } } - /// Assigns an accessibility identifier according to what is defined - /// in the `AXDynamicComponent` identified by the given key path. + /// Assigns an accessibility identifier with a custom prefix prepended to + /// the component's standard prefix. /// - /// - Parameters: - /// - path: - /// `KeyPath` relative to some `AXScreen` that identifies an `AXDynamicComponent`. - /// - value: - /// The dynamic value to use while resolving the component. - /// - Returns: - /// The view after applying the `accessibilityIdentifier` modifier. - func automationComponent( - _ component: AXDynamicComponent, - value: Value - ) -> some View where Value: StringProtocol { - accessibilityIdentifier(component.resolve(value).id) - } - - // MARK: Signed Integer - - /// Assigns an accessibility identifier according to what is defined - /// in the `AXDynamicComponent` identified by the given key path. + /// Produces an identifier of the form `"{prefix}-{componentPrefix}_{value}"`. + /// Use this to distinguish elements that share a component definition but + /// represent a different semantic state — for example, loading shimmers + /// vs loaded content. /// /// - Parameters: /// - path: /// `KeyPath` relative to some `AXScreen` that identifies an `AXDynamicComponent`. /// - value: /// The dynamic value to use while resolving the component. + /// - prefix: + /// A distinguishing prefix to prepend to the component's standard prefix. /// - Returns: - /// The view after applying the `accessibilityIdentifier` modifier. + /// The view after applying the prefixed `accessibilityIdentifier`. func automationComponent( _ path: KeyPath>, - value: Value - ) -> some View where Model: AXScreen, Value: SignedInteger { - accessibilityIdentifier(Model()[keyPath: path].resolve(value).id) + value: Value, + prefix: String + ) -> some View where Model: AXScreen, Value: AXIdentifierConvertible { + let component = Model()[keyPath: path] + let prefixedName = [prefix, component.prefix].filter { !$0.isEmpty }.joined(separator: "-") + return accessibilityIdentifier("\(prefixedName)_\(value.automationIdentifier)") } /// Assigns an accessibility identifier according to what is defined - /// in the `AXDynamicComponent` identified by the given key path. + /// in the given `AXDynamicComponent`. /// /// - Parameters: - /// - path: - /// `KeyPath` relative to some `AXScreen` that identifies an `AXDynamicComponent`. - /// - value: - /// The dynamic value to use while resolving the component. - /// - Returns: - /// The view after applying the `accessibilityIdentifier` modifier. - func automationComponent( - _ component: AXDynamicComponent, - value: Value - ) -> some View where Value: SignedInteger { - accessibilityIdentifier(component.resolve(value).id) - } - - // MARK: Unsigned Integer - - /// Assigns an accessibility identifier according to what is defined - /// in the `AXDynamicComponent` identified by the given key path. - /// - /// - Parameters: - /// - path: - /// `KeyPath` relative to some `AXScreen` that identifies an `AXDynamicComponent`. - /// - value: - /// The dynamic value to use while resolving the component. - /// - Returns: - /// The view after applying the `accessibilityIdentifier` modifier. - func automationComponent( - _ path: KeyPath>, - value: Value - ) -> some View where Model: AXScreen, Value: UnsignedInteger { - accessibilityIdentifier(Model()[keyPath: path].resolve(value).id) - } - - /// Assigns an accessibility identifier according to what is defined - /// in the `AXDynamicComponent` identified by the given key path. - /// - /// - Parameters: - /// - path: - /// `KeyPath` relative to some `AXScreen` that identifies an `AXDynamicComponent`. + /// - component: + /// An `AXDynamicComponent` that provides an identity for the modified view. /// - value: /// The dynamic value to use while resolving the component. /// - Returns: @@ -139,7 +84,7 @@ public extension View { func automationComponent( _ component: AXDynamicComponent, value: Value - ) -> some View where Value: UnsignedInteger { + ) -> some View where Value: AXIdentifierConvertible { accessibilityIdentifier(component.resolve(value).id) } } diff --git a/Sources/AXComponentKit/Extensions/App UI/View+AXScrollView.swift b/Sources/AXComponentKit/Extensions/App UI/View+AXScrollView.swift index 95477a1..275c7b4 100644 --- a/Sources/AXComponentKit/Extensions/App UI/View+AXScrollView.swift +++ b/Sources/AXComponentKit/Extensions/App UI/View+AXScrollView.swift @@ -16,6 +16,5 @@ public extension View { _ path: KeyPath ) -> some View where Model: AXScreen { accessibilityIdentifier(Model()[keyPath: path].id) - .accessibilityElement(children: .contain) } } diff --git a/Sources/AXComponentKit/Extensions/App UI/View+AnimationSuppression.swift b/Sources/AXComponentKit/Extensions/App UI/View+AnimationSuppression.swift new file mode 100644 index 0000000..586b1c2 --- /dev/null +++ b/Sources/AXComponentKit/Extensions/App UI/View+AnimationSuppression.swift @@ -0,0 +1,42 @@ +import SwiftUI + +public extension View { + /// Suppresses animations across UIKit, Core Animation, and SwiftUI + /// when the app is launched by an AXComponentKit test runner. + /// + /// In production (when the runner flag is absent), this is a no-op + /// with zero runtime cost. + /// + /// Apply this modifier at your app's root view: + /// ```swift + /// @main + /// struct MyApp: App { + /// var body: some Scene { + /// WindowGroup { + /// ContentView() + /// .automationOptimized() + /// } + /// } + /// } + /// ``` + func automationOptimized() -> some View { + modifier(AutomationOptimizedModifier()) + } +} + +private struct AutomationOptimizedModifier: ViewModifier { + @State private var didActivate = false + + func body(content: Content) -> some View { + let isOptimized = AXAutomation.isActive + content + .transaction { transaction in + if isOptimized { transaction.disablesAnimations = true } + } + .onAppear { + guard isOptimized, !didActivate else { return } + didActivate = true + AXAutomation.suppressAnimationsIfNeeded() + } + } +} diff --git a/Sources/AXComponentKit/Extensions/App UI/View+ScreenIdentityModifier.swift b/Sources/AXComponentKit/Extensions/App UI/View+ScreenIdentityModifier.swift index 8a040bf..1fa00fa 100644 --- a/Sources/AXComponentKit/Extensions/App UI/View+ScreenIdentityModifier.swift +++ b/Sources/AXComponentKit/Extensions/App UI/View+ScreenIdentityModifier.swift @@ -6,7 +6,6 @@ private struct ScreenIdentityModifier: ViewModifier { func body(content: Content) -> some View { content.background( Color.clear - .accessibilityElement(children: .contain) .accessibilityIdentifier(identifier) ) } @@ -15,7 +14,7 @@ private struct ScreenIdentityModifier: ViewModifier { public extension View { /// Sets the `ScreenIdentifier` on a view, which is used to /// verify the app's location defined in - /// `AXScreenNavigator.performNavigation(...`. + /// `AXScreenNavigator.navigate(...`. /// /// This modifier should be set only on views that act as a viewController/"screen" /// and is not intended for use on contained views. diff --git a/Sources/AXComponentKit/Extensions/Temporal/Measurement+TimeInterval.swift b/Sources/AXComponentKit/Extensions/Temporal/Measurement+TimeInterval.swift index bb13563..d522f31 100644 --- a/Sources/AXComponentKit/Extensions/Temporal/Measurement+TimeInterval.swift +++ b/Sources/AXComponentKit/Extensions/Temporal/Measurement+TimeInterval.swift @@ -1,7 +1,5 @@ import Foundation -extension Measurement: Sendable {} - public extension Measurement where UnitType == UnitDuration { /// Creates a `Measurement` from a millisecond value /// diff --git a/Sources/AXComponentKit/Screen Models/AXScreen.swift b/Sources/AXComponentKit/Screen Models/AXScreen.swift index 50a039b..f04d39f 100644 --- a/Sources/AXComponentKit/Screen Models/AXScreen.swift +++ b/Sources/AXComponentKit/Screen Models/AXScreen.swift @@ -5,7 +5,7 @@ import Foundation /// AXScreen provides a lightweight definition for what constitutes a "screen" /// worth of content. Each screen has an identifier to help assist with navigation /// while running tests. -public protocol AXScreen { +public protocol AXScreen: Sendable { /// Screen must be generically instantiable init() diff --git a/Sources/AXComponentKit/Screen Models/AXScreenModel+Components.swift b/Sources/AXComponentKit/Screen Models/AXScreenModel+Components.swift index 7754841..8a6122c 100644 --- a/Sources/AXComponentKit/Screen Models/AXScreenModel+Components.swift +++ b/Sources/AXComponentKit/Screen Models/AXScreenModel+Components.swift @@ -5,9 +5,6 @@ public extension AXScreen { /// Fetches an `AXComponent` from an instance of `Self` defined by the given keyPath. /// - /// Generally only used for implementation details of helper functions and shouldn't be called - /// directly from application code or from UI tests. - /// /// - Parameter path: keyPath to the desired component /// - Returns: an `AXComponent` that can be used for `XCUIElement` queries static func component( @@ -20,9 +17,6 @@ public extension AXScreen { /// Fetches an `AXScrollView` from an instance of `Self` defined by the given keyPath. /// - /// Generally only used for implementation details of helper functions and shouldn't be called - /// directly from application code or from UI tests. - /// /// - Parameter path: keyPath to the desired scrollView /// - Returns: an `AXScrollView` that can be used for `XCUIElement` queries static func component( @@ -31,75 +25,18 @@ public extension AXScreen { .init(stringLiteral: Self()[keyPath: path].id) } - // MARK: AXDynamicValue + // MARK: Dynamic Component /// Fetches an `AXComponent` from an instance of `Self` defined by the given keyPath. /// The value should be something unique to the component, such as a row index or UUID string. /// - /// Generally only used for implementation details of helper functions and shouldn't be called - /// directly from application code or from UI tests. - /// - /// - Parameter path: keyPath to the desired dynamic component - /// - Parameter value: the dynamic value for which the component should resolve from - /// - Returns: an `AXComponent` that can be used for `XCUIElement` queries - static func component( - _ path: KeyPath>, - value: Value - ) -> AXComponent where Value: AXDynamicValue { - Self()[keyPath: path].resolve(value) - } - - // MARK: StringProtocol - - /// Fetches an `AXComponent` from an instance of `Self` defined by the given keyPath. - /// The value should be something unique to the component, such as a row index or UUID string. - /// - /// Generally only used for implementation details of helper functions and shouldn't be called - /// directly from application code or from UI tests. - /// - /// - Parameter path: keyPath to the desired dynamic component - /// - Parameter value: the dynamic value for which the component should resolve from - /// - Returns: an `AXComponent` that can be used for `XCUIElement` queries - static func component( - _ path: KeyPath>, - value: Value - ) -> AXComponent where Value: StringProtocol { - Self()[keyPath: path].resolve(value) - } - - // MARK: Signed Integer - - /// Fetches an `AXComponent` from an instance of `Self` defined by the given keyPath. - /// The value should be something unique to the component, such as a row index or UUID string. - /// - /// Generally only used for implementation details of helper functions and shouldn't be called - /// directly from application code or from UI tests. - /// - /// - Parameter path: keyPath to the desired dynamic component - /// - Parameter value: the dynamic value for which the component should resolve from - /// - Returns: an `AXComponent` that can be used for `XCUIElement` queries - static func component( - _ path: KeyPath>, - value: Value - ) -> AXComponent where Value: SignedInteger { - Self()[keyPath: path].resolve(value) - } - - // MARK: Unsigned Integer - - /// Fetches an `AXComponent` from an instance of `Self` defined by the given keyPath. - /// The value should be something unique to the component, such as a row index or UUID string. - /// - /// Generally only used for implementation details of helper functions and shouldn't be called - /// directly from application code or from UI tests. - /// /// - Parameter path: keyPath to the desired dynamic component /// - Parameter value: the dynamic value for which the component should resolve from /// - Returns: an `AXComponent` that can be used for `XCUIElement` queries static func component( _ path: KeyPath>, value: Value - ) -> AXComponent where Value: UnsignedInteger { + ) -> AXComponent where Value: AXIdentifierConvertible { Self()[keyPath: path].resolve(value) } } diff --git a/Sources/AXComponentKitMacroSupport/AXScreenMacroDeclaration.swift b/Sources/AXComponentKitMacroSupport/AXScreenMacroDeclaration.swift new file mode 100644 index 0000000..1394a5c --- /dev/null +++ b/Sources/AXComponentKitMacroSupport/AXScreenMacroDeclaration.swift @@ -0,0 +1,9 @@ +import AXComponentKit + +@attached(member, names: named(screenIdentifier)) +@attached(extension, conformances: AXScreen) +public macro AXScreen() = #externalMacro(module: "AXComponentKitMacros", type: "AXScreenMacro") + +@attached(member, names: named(screenIdentifier)) +@attached(extension, conformances: AXScreen) +public macro AXScreen(identifier: String) = #externalMacro(module: "AXComponentKitMacros", type: "AXScreenMacro") diff --git a/Sources/AXComponentKitMacroSupport/Exports.swift b/Sources/AXComponentKitMacroSupport/Exports.swift new file mode 100644 index 0000000..b6d2700 --- /dev/null +++ b/Sources/AXComponentKitMacroSupport/Exports.swift @@ -0,0 +1 @@ +@_exported import AXComponentKit diff --git a/Sources/AXComponentKitMacros/AXScreenMacro.swift b/Sources/AXComponentKitMacros/AXScreenMacro.swift new file mode 100644 index 0000000..a909f09 --- /dev/null +++ b/Sources/AXComponentKitMacros/AXScreenMacro.swift @@ -0,0 +1,103 @@ +import SwiftSyntax +import SwiftSyntaxMacros + +public struct AXScreenMacro: MemberMacro, ExtensionMacro { + + // MARK: - MemberMacro + + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard let structDecl = declaration.as(StructDeclSyntax.self) else { + throw AXScreenMacroError.notAStruct + } + + let typeName = structDecl.name.trimmedDescription + let identifier = extractIdentifier(from: node) ?? pascalCaseToKebabCase(typeName) + + return [ + "static let screenIdentifier = \(literal: identifier)" + ] + } + + // MARK: - ExtensionMacro + + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + guard declaration.as(StructDeclSyntax.self) != nil else { + throw AXScreenMacroError.notAStruct + } + + let extensionDecl: DeclSyntax = "extension \(type.trimmed): AXScreen {}" + guard let ext = extensionDecl.as(ExtensionDeclSyntax.self) else { + return [] + } + return [ext] + } + + // MARK: - Helpers + + /// Extracts a custom identifier string from `@AXScreen(identifier: "custom-id")`, if present. + private static func extractIdentifier(from node: AttributeSyntax) -> String? { + guard let arguments = node.arguments?.as(LabeledExprListSyntax.self) else { + return nil + } + for argument in arguments { + if argument.label?.trimmedDescription == "identifier", + let stringLiteral = argument.expression.as(StringLiteralExprSyntax.self), + let segment = stringLiteral.segments.first?.as(StringSegmentSyntax.self) { + return segment.content.text + } + } + return nil + } + + /// Converts a PascalCase name to kebab-case. + /// + /// Algorithm: + /// - Insert a hyphen before each uppercase letter that follows a lowercase letter. + /// - Insert a hyphen before an uppercase letter that is followed by a lowercase letter + /// (handles acronyms like "UIKit" -> "ui-kit"). + /// - Lowercase everything. + static func pascalCaseToKebabCase(_ name: String) -> String { + let characters = Array(name) + var result = "" + + for (index, char) in characters.enumerated() { + if char.isUppercase { + let previousIsLower = index > 0 && characters[index - 1].isLowercase + let nextIsLower = index + 1 < characters.count && characters[index + 1].isLowercase + + if previousIsLower { + // e.g., "tS" in "FirstScreen" -> "t-s" + result.append("-") + } else if nextIsLower && index > 0 { + // e.g., "IK" followed by "it" in "UIKit" -> "...-ki" + result.append("-") + } + } + result.append(char.lowercased()) + } + + return result + } +} + +enum AXScreenMacroError: Error, CustomStringConvertible { + case notAStruct + + var description: String { + switch self { + case .notAStruct: + return "@AXScreen can only be applied to a struct" + } + } +} diff --git a/Sources/AXComponentKitMacros/Plugin.swift b/Sources/AXComponentKitMacros/Plugin.swift new file mode 100644 index 0000000..489a530 --- /dev/null +++ b/Sources/AXComponentKitMacros/Plugin.swift @@ -0,0 +1,9 @@ +import SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct AXComponentKitMacrosPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + AXScreenMacro.self, + ] +} diff --git a/Sources/AXComponentKitTestSupport/AXFailure.swift b/Sources/AXComponentKitTestSupport/AXFailure.swift index 0cc7b99..931d33b 100644 --- a/Sources/AXComponentKitTestSupport/AXFailure.swift +++ b/Sources/AXComponentKitTestSupport/AXFailure.swift @@ -1,23 +1,21 @@ import Foundation import XCTest -/// An easy to throw error that can be caught by XCTest automatically -/// while also reporting a test failure at the specified file and line -final class AXFailure: NSError { - /// Creates a new error that will fail at the given file/line with - /// the specified message - /// - /// - Parameters: - /// - message: - /// The message to display as the failure description - /// - file: - /// The file to present an error in if a failure occurs. - /// The default is the filename of the test case where you call this function. - /// - line: - /// The line number to present an error on if a failure occurs. - /// The default is the line number of the test case where you call this function. +/// A test failure error that carries source location for call-site attribution. +/// +/// `AXFailure` does not call `XCTFail` in its initializer — failure recording +/// happens at the throw site so that caught errors do not produce spurious +/// test failures. +/// +/// `@unchecked Sendable` is safe: all stored properties (`StaticString`, `UInt`) +/// are immutable value types, and `NSError` is itself `@unchecked Sendable`. +final class AXFailure: NSError, @unchecked Sendable { + let sourceFile: StaticString + let sourceLine: UInt + init(_ message: String, file: StaticString, line: UInt) { - XCTFail(message, file: file, line: line) + self.sourceFile = file + self.sourceLine = line super.init( domain: "com.axcomponentkit.testsupport", code: 1, @@ -32,3 +30,16 @@ final class AXFailure: NSError { fatalError() } } + +extension AXFailure { + /// Records the failure via `XCTFail` and throws. Call this instead of + /// constructing + throwing separately so the failure is reported exactly once. + @MainActor static func fail( + _ message: String, + file: StaticString = #filePath, + line: UInt = #line + ) throws -> Never { + XCTFail(message, file: file, line: line) + throw AXFailure(message, file: file, line: line) + } +} diff --git a/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+AssumedElements.swift b/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+AssumedElements.swift index bc92fc2..ebef347 100644 --- a/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+AssumedElements.swift +++ b/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+AssumedElements.swift @@ -7,29 +7,17 @@ public extension AXScreen { /// Fetches an `XCUIElement` represented by the given `KeyPath`. /// /// This element is assumed to exist, therefore no guarantees are made - /// about the existence of the element on screen. For example, one might use this - /// to reference an offscreen element and scroll in a direction until that element - /// comes into existence. - /// - /// For most use cases, prefer `element(_:timeout:file:line)` as it gives you - /// assurances about the existence of the element before returning. - /// - /// ``` - /// let button = ExampleScreen.assumedElement(\.exampleButton) - /// while !button.exists { - /// XCUIApplication().scroll(byDeltaX: 0, deltaY: 100) - /// } - /// ``` + /// about the existence of the element on screen. For most use cases, + /// prefer `element(_:timeout:file:line)` as it gives you assurances + /// about the existence of the element before returning. /// /// - Parameters: /// - path: /// `KeyPath` relative to `Self` that identifies an `AXComponent` /// - file: /// The file to present an error in if a failure occurs. - /// The default is the filename of the test case where you call this function. /// - line: /// The line number to present an error on if a failure occurs. - /// The default is the line number of the test case where you call this function. /// - Returns: /// A resolved `XCUIElement` with no guarantees about its existence static func assumedElement( @@ -47,33 +35,19 @@ public extension AXScreen { /// which matches the given dynamic value. /// /// The returned element is assumed to exist, therefore no guarantees are made - /// about the existence of the element on screen. For example, one might use this - /// to reference an offscreen element and scroll in a direction until that element - /// comes into existence. - /// - /// For most use cases, prefer `element(_:value:timeout:)` as it gives you - /// assurances about the existence of the element before returning. - /// - /// ``` - /// // Fetch an imaginary cell at row 20 - /// let cell = ExampleScreen.assumedElement(\.exampleCell, value: 20) - /// while !cell.exists { - /// XCUIApplication().scroll(byDeltaX: 0, deltaY: 100) - /// } - /// cell.tap() - /// ``` + /// about the existence of the element on screen. For most use cases, + /// prefer `element(_:value:timeout:)` as it gives you assurances + /// about the existence of the element before returning. /// /// - Parameters: /// - path: - /// `KeyPath` relative to `Self` that identifies an `AXComponent` + /// `KeyPath` relative to `Self` that identifies an `AXDynamicComponent` /// - value: /// The dynamic value to use while resolving the component /// - file: /// The file to present an error in if a failure occurs. - /// The default is the filename of the test case where you call this function. /// - line: /// The line number to present an error on if a failure occurs. - /// The default is the line number of the test case where you call this function. /// - Returns: /// A resolved `XCUIElement` with no guarantees about its existence static func assumedElement( @@ -81,42 +55,26 @@ public extension AXScreen { value: Value, file: StaticString = #file, line: UInt = #line - ) -> XCUIElement where Value: AXDynamicValue { + ) -> XCUIElement where Value: AXIdentifierConvertible { let identifier = Self.component(path, value: value).id return assumedElement(matching: identifier, file: file, line: line) } - /// Fetches an `XCUIElement` represented by the given `KeyPath` + /// Fetches an `XCUIElement` represented by the given `AXDynamicComponent` /// which matches the given dynamic value. /// /// The returned element is assumed to exist, therefore no guarantees are made - /// about the existence of the element on screen. For example, one might use this - /// to reference an offscreen element and scroll in a direction until that element - /// comes into existence. - /// - /// For most use cases, prefer `element(_:value:timeout:)` as it gives you - /// assurances about the existence of the element before returning. - /// - /// ``` - /// // Fetch an imaginary cell at row 20 - /// let cell = ExampleScreen.assumedElement(\.exampleCell, value: 20) - /// while !cell.exists { - /// XCUIApplication().scroll(byDeltaX: 0, deltaY: 100) - /// } - /// cell.tap() - /// ``` + /// about the existence of the element on screen. /// /// - Parameters: - /// - path: - /// `KeyPath` relative to `Self` that identifies an `AXComponent` + /// - component: + /// An `AXDynamicComponent` to resolve /// - value: /// The dynamic value to use while resolving the component /// - file: /// The file to present an error in if a failure occurs. - /// The default is the filename of the test case where you call this function. /// - line: /// The line number to present an error on if a failure occurs. - /// The default is the line number of the test case where you call this function. /// - Returns: /// A resolved `XCUIElement` with no guarantees about its existence static func assumedElement( @@ -124,179 +82,22 @@ public extension AXScreen { value: Value, file: StaticString = #file, line: UInt = #line - ) -> XCUIElement where Value: AXDynamicValue { + ) -> XCUIElement where Value: AXIdentifierConvertible { let identifier = component.resolve(value).id return assumedElement(matching: identifier, file: file, line: line) } - // MARK: Dynamic Components + StringProtocol - - /// Fetches an `XCUIElement` represented by the given `KeyPath` - /// which matches the given dynamic value. - /// - /// The returned element is assumed to exist, therefore no guarantees are made - /// about the existence of the element on screen. For example, one might use this - /// to reference an offscreen element and scroll in a direction until that element - /// comes into existence. - /// - /// For most use cases, prefer `element(_:value:timeout:)` as it gives you - /// assurances about the existence of the element before returning. - /// - /// ``` - /// // Fetch an imaginary cell at row 20 - /// let cell = ExampleScreen.assumedElement(\.exampleCell, value: 20) - /// while !cell.exists { - /// XCUIApplication().scroll(byDeltaX: 0, deltaY: 100) - /// } - /// cell.tap() - /// ``` - /// - /// - Parameters: - /// - path: - /// `KeyPath` relative to `Self` that identifies an `AXComponent` - /// - value: - /// The dynamic value to use while resolving the component - /// - file: - /// The file to present an error in if a failure occurs. - /// The default is the filename of the test case where you call this function. - /// - line: - /// The line number to present an error on if a failure occurs. - /// The default is the line number of the test case where you call this function. - /// - Returns: - /// A resolved `XCUIElement` with no guarantees about its existence - static func assumedElement( - _ path: KeyPath>, - value: Value, - file: StaticString = #file, - line: UInt = #line - ) -> XCUIElement where Value: StringProtocol { - let identifier = Self.component(path, value: value).id - let element = assumedElement(matching: identifier, file: file, line: line) - return element - } - - // MARK: Dynamic Components + SignedInteger - - /// Fetches an `XCUIElement` represented by the given `KeyPath` - /// which matches the given dynamic value. - /// - /// The returned element is assumed to exist, therefore no guarantees are made - /// about the existence of the element on screen. For example, one might use this - /// to reference an offscreen element and scroll in a direction until that element - /// comes into existence. - /// - /// For most use cases, prefer `element(_:value:timeout:)` as it gives you - /// assurances about the existence of the element before returning. - /// - /// ``` - /// // Fetch an imaginary cell at row 20 - /// let cell = ExampleScreen.assumedElement(\.exampleCell, value: 20) - /// while !cell.exists { - /// XCUIApplication().scroll(byDeltaX: 0, deltaY: 100) - /// } - /// cell.tap() - /// ``` - /// - /// - Parameters: - /// - path: - /// `KeyPath` relative to `Self` that identifies an `AXComponent` - /// - value: - /// The dynamic value to use while resolving the component - /// - file: - /// The file to present an error in if a failure occurs. - /// The default is the filename of the test case where you call this function. - /// - line: - /// The line number to present an error on if a failure occurs. - /// The default is the line number of the test case where you call this function. - /// - Returns: - /// A resolved `XCUIElement` with no guarantees about its existence - static func assumedElement( - _ path: KeyPath>, - value: Value, - file: StaticString = #file, - line: UInt = #line - ) -> XCUIElement where Value: SignedInteger { - let identifier = Self.component(path, value: value).id - let element = assumedElement(matching: identifier, file: file, line: line) - return element - } - - // MARK: Dynamic Components + UnsignedInteger - - /// Fetches an `XCUIElement` represented by the given `KeyPath` - /// which matches the given dynamic value. - /// - /// The returned element is assumed to exist, therefore no guarantees are made - /// about the existence of the element on screen. For example, one might use this - /// to reference an offscreen element and scroll in a direction until that element - /// comes into existence. - /// - /// For most use cases, prefer `element(_:value:timeout:)` as it gives you - /// assurances about the existence of the element before returning. - /// - /// ``` - /// // Fetch an imaginary cell at row 20 - /// let cell = ExampleScreen.assumedElement(\.exampleCell, value: 20) - /// while !cell.exists { - /// XCUIApplication().scroll(byDeltaX: 0, deltaY: 100) - /// } - /// cell.tap() - /// ``` - /// - /// - Parameters: - /// - path: - /// `KeyPath` relative to `Self` that identifies an `AXComponent` - /// - value: - /// The dynamic value to use while resolving the component - /// - file: - /// The file to present an error in if a failure occurs. - /// The default is the filename of the test case where you call this function. - /// - line: - /// The line number to present an error on if a failure occurs. - /// The default is the line number of the test case where you call this function. - /// - Returns: - /// A resolved `XCUIElement` with no guarantees about its existence - static func assumedElement( - _ path: KeyPath>, - value: Value, - file: StaticString = #file, - line: UInt = #line - ) -> XCUIElement where Value: UnsignedInteger { - let identifier = Self.component(path, value: value).id - let element = assumedElement(matching: identifier, file: file, line: line) - return element - } + // MARK: XCUIElement /// Fetches an `XCUIElement` represented by the given `KeyPath`. /// - /// Sometimes a protocol extension is provided that can only vend an XCUIElement - /// because iOS does not provide a means to manage accessibility identifiers for the - /// view in question. For example, a tab bar item, or a navigation bar element. This - /// allows a uniform API to exist so that `XCTestCase` tests don't need to differentiate - /// between `AXComponent`s and `XCUIElement`s when composing tests. - /// - /// This element is assumed to exist, therefore no guarantees are made - /// about the existence of the element on screen. For example, one might use this - /// to reference an offscreen element and scroll in a direction until that element - /// comes into existence. - /// - /// For most use cases, prefer `element(_:timeout:file:line)` as it gives you - /// assurances about the existence of the element before returning. - /// - /// ``` - /// let tab = ExampleScreen.assumedElement(\.tabItem) - /// tab.tap() - /// ``` + /// Sometimes a protocol extension provides an `XCUIElement` directly + /// because iOS does not allow managing accessibility identifiers for + /// the view in question (e.g., a tab bar item or navigation bar element). /// /// - Parameters: /// - path: - /// `KeyPath` relative to `Self` that identifies an `AXComponent` - /// - file: - /// The file to present an error in if a failure occurs. - /// The default is the filename of the test case where you call this function. - /// - line: - /// The line number to present an error on if a failure occurs. - /// The default is the line number of the test case where you call this function. + /// `KeyPath` relative to `Self` that identifies an `XCUIElement` /// - Returns: /// A resolved `XCUIElement` with no guarantees about its existence static func assumedElement( @@ -308,18 +109,6 @@ public extension AXScreen { } /// Allows for global `XCUIElement` querying based on unique identifiers - /// - /// - Parameters: - /// - identifier: - /// The unique identifier for the element in question - /// - file: - /// The file to present an error in if a failure occurs. - /// The default is the filename of the test case where you call this function. - /// - line: - /// The line number to present an error on if a failure occurs. - /// The default is the line number of the test case where you call this function. - /// - Returns: - /// An `XCUIElement` that matches the given identifier internal static func assumedElement( matching identifier: String, file _: StaticString = #file, diff --git a/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+DynamicElements.swift b/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+DynamicElements.swift index 0d27d67..1fe42af 100644 --- a/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+DynamicElements.swift +++ b/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+DynamicElements.swift @@ -4,146 +4,6 @@ import XCTest @MainActor public extension AXScreen { - // MARK: AXDynamicValue - - /// Asynchronously fetches an `XCUIElement` represented by the given `KeyPath` - /// which matches the given dynamic value. The returned element is guaranteed to exist - /// when this function returns and can be safely interacted with. - /// - /// This is the preferred way to interact with UI elements, as not waiting for them to exist - /// is a common source of flaky test failures due to laggy interactions in slow execution - /// environments, such as a virtual instance in a CI pipeline. - /// - /// ``` - /// // Fetch an imaginary cell at row 20 - /// let cell = try await ExampleScreen.element(\.exampleCell, value: 20) - /// cell.tap() - /// ``` - /// - /// - Parameters: - /// - path: - /// `KeyPath` relative to `Self` that identifies an `AXDynamicComponent` - /// - value: - /// The dynamic value to use while resolving the component - /// - timeout: - /// Duration of time that this call should wait for the element to come into existence. - /// The default is 10 seconds. - /// - file: - /// The file to present an error in if a failure occurs. - /// The default is the filename of the test case where you call this function. - /// - line: - /// The line number to present an error on if a failure occurs. - /// The default is the line number of the test case where you call this function. - /// - Returns: - /// A resolved `XCUIElement` whose existence has been ensured - @discardableResult - static func element( - _ path: KeyPath>, - value: Value, - timeout: Measurement = .seconds(10), - file: StaticString = #file, - line: UInt = #line - ) async throws -> XCUIElement where Value: AXDynamicValue { - let identifier = Self.component(path, value: value).id - let element = assumedElement(matching: identifier, file: file, line: line) - let message = "Element not found with identifier: \"\(identifier)\"" - return try element.awaitingExistence(timeout: timeout, message, file: file, line: line) - } - - // MARK: StringProtocol - - /// Asynchronously fetches an `XCUIElement` represented by the given `KeyPath` - /// which matches the given dynamic value. The returned element is guaranteed to exist - /// when this function returns and can be safely interacted with. - /// - /// This is the preferred way to interact with UI elements, as not waiting for them to exist - /// is a common source of flaky test failures due to laggy interactions in slow execution - /// environments, such as a virtual instance in a CI pipeline. - /// - /// ``` - /// // Fetch an imaginary cell at row 20 - /// let cell = try await ExampleScreen.element(\.exampleCell, value: 20) - /// cell.tap() - /// ``` - /// - /// - Parameters: - /// - path: - /// `KeyPath` relative to `Self` that identifies an `AXDynamicComponent` - /// - value: - /// The dynamic value to use while resolving the component - /// - timeout: - /// Duration of time that this call should wait for the element to come into existence. - /// The default is 10 seconds. - /// - file: - /// The file to present an error in if a failure occurs. - /// The default is the filename of the test case where you call this function. - /// - line: - /// The line number to present an error on if a failure occurs. - /// The default is the line number of the test case where you call this function. - /// - Returns: - /// A resolved `XCUIElement` whose existence has been ensured - @discardableResult - static func element( - _ path: KeyPath>, - value: Value, - timeout: Measurement = .seconds(10), - file: StaticString = #file, - line: UInt = #line - ) async throws -> XCUIElement where Value: StringProtocol { - let identifier = Self.component(path, value: value).id - let element = assumedElement(matching: identifier, file: file, line: line) - let message = "Element not found with identifier: \"\(identifier)\"" - return try element.awaitingExistence(timeout: timeout, message, file: file, line: line) - } - - // MARK: SignedInteger - - /// Asynchronously fetches an `XCUIElement` represented by the given `KeyPath` - /// which matches the given dynamic value. The returned element is guaranteed to exist - /// when this function returns and can be safely interacted with. - /// - /// This is the preferred way to interact with UI elements, as not waiting for them to exist - /// is a common source of flaky test failures due to laggy interactions in slow execution - /// environments, such as a virtual instance in a CI pipeline. - /// - /// ``` - /// // Fetch an imaginary cell at row 20 - /// let cell = try await ExampleScreen.element(\.exampleCell, value: 20) - /// cell.tap() - /// ``` - /// - /// - Parameters: - /// - path: - /// `KeyPath` relative to `Self` that identifies an `AXDynamicComponent` - /// - value: - /// The dynamic value to use while resolving the component - /// - timeout: - /// Duration of time that this call should wait for the element to come into existence. - /// The default is 10 seconds. - /// - file: - /// The file to present an error in if a failure occurs. - /// The default is the filename of the test case where you call this function. - /// - line: - /// The line number to present an error on if a failure occurs. - /// The default is the line number of the test case where you call this function. - /// - Returns: - /// A resolved `XCUIElement` whose existence has been ensured - @discardableResult - static func element( - _ path: KeyPath>, - value: Value, - timeout: Measurement = .seconds(10), - file: StaticString = #file, - line: UInt = #line - ) async throws -> XCUIElement where Value: SignedInteger { - let identifier = Self.component(path, value: value).id - let element = assumedElement(matching: identifier, file: file, line: line) - let message = "Element not found with identifier: \"\(identifier)\"" - return try element.awaitingExistence(timeout: timeout, message, file: file, line: line) - } - - // MARK: UnsignedInteger - /// Asynchronously fetches an `XCUIElement` represented by the given `KeyPath` /// which matches the given dynamic value. The returned element is guaranteed to exist /// when this function returns and can be safely interacted with. @@ -153,7 +13,6 @@ public extension AXScreen { /// environments, such as a virtual instance in a CI pipeline. /// /// ``` - /// // Fetch an imaginary cell at row 20 /// let cell = try await ExampleScreen.element(\.exampleCell, value: 20) /// cell.tap() /// ``` @@ -181,7 +40,7 @@ public extension AXScreen { timeout: Measurement = .seconds(10), file: StaticString = #file, line: UInt = #line - ) async throws -> XCUIElement where Value: UnsignedInteger { + ) async throws -> XCUIElement where Value: AXIdentifierConvertible { let identifier = Self.component(path, value: value).id let element = assumedElement(matching: identifier, file: file, line: line) let message = "Element not found with identifier: \"\(identifier)\"" diff --git a/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+FirstDynamicElement.swift b/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+FirstDynamicElement.swift new file mode 100644 index 0000000..8cc35d5 --- /dev/null +++ b/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+FirstDynamicElement.swift @@ -0,0 +1,83 @@ +import AXComponentKit +import Foundation +import XCTest + +@MainActor +public extension AXScreen { + /// Returns the first element whose accessibility identifier begins with the dynamic + /// component's prefix, regardless of the specific value suffix. Waits for existence + /// before returning. + /// + /// Use when the exact dynamic value is not known at test time — for example, tapping + /// the first item in a list of server-returned cards or rows. + /// + /// ```swift + /// let card = try await CatalogScreen.firstElement(anyOf: \.categoryCard) + /// card.tap() + /// ``` + /// + /// - Parameters: + /// - path: + /// `KeyPath` relative to `Self` that identifies an `AXDynamicComponent` + /// - timeout: + /// Duration of time that this call should wait for the element to come into existence. + /// The default is 10 seconds. + /// - file: + /// The file to present an error in if a failure occurs. + /// The default is the filename of the test case where you call this function. + /// - line: + /// The line number to present an error on if a failure occurs. + /// The default is the line number of the test case where you call this function. + /// - Returns: + /// The first `XCUIElement` whose identifier begins with the component's prefix, + /// guaranteed to exist when this function returns. + @discardableResult + static func firstElement( + anyOf path: KeyPath>, + timeout: Measurement = .seconds(10), + file: StaticString = #file, + line: UInt = #line + ) async throws -> XCUIElement where Value: AXIdentifierConvertible { + let prefix = Self()[keyPath: path].prefix + let predicate = NSPredicate(format: "identifier BEGINSWITH %@", prefix) + let element = XCUIApplication() + .descendants(matching: .any) + .matching(predicate) + .firstMatch + let message = "No element found with identifier beginning with: \"\(prefix)\"" + return try element.awaitingExistence(timeout: timeout, message, file: file, line: line) + } + + /// Taps the first element whose accessibility identifier begins with the dynamic + /// component's prefix, regardless of the specific value suffix. Waits for existence + /// before tapping. + /// + /// Use when the exact dynamic value is not known at test time — for example, tapping + /// the first item in a list of server-returned cards or rows. + /// + /// ```swift + /// try await CatalogScreen.tapFirst(anyOf: \.categoryCard) + /// ``` + /// + /// - Parameters: + /// - path: + /// `KeyPath` relative to `Self` that identifies an `AXDynamicComponent` + /// - timeout: + /// Duration of time that this call should wait for the element to come into existence. + /// The default is 10 seconds. + /// - file: + /// The file to present an error in if a failure occurs. + /// The default is the filename of the test case where you call this function. + /// - line: + /// The line number to present an error on if a failure occurs. + /// The default is the line number of the test case where you call this function. + static func tapFirst( + anyOf path: KeyPath>, + timeout: Measurement = .seconds(10), + file: StaticString = #file, + line: UInt = #line + ) async throws where Value: AXIdentifierConvertible { + let element = try await firstElement(anyOf: path, timeout: timeout, file: file, line: line) + element.tap() + } +} diff --git a/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+TabComponents.swift b/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+TabComponents.swift index 26ee1dd..9eb61ba 100644 --- a/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+TabComponents.swift +++ b/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+TabComponents.swift @@ -79,7 +79,7 @@ public extension AXScreen { line _: UInt = #line ) -> XCUIElement where Screen: AXScreen { let component = Self()[keyPath: path] - let predicate = NSPredicate(format: "label LIKE[c] '\(component.name)'") + let predicate = NSPredicate(format: "label ==[c] %@", component.name) let tabItem = XCUIApplication().tabBars.buttons.matching(predicate).firstMatch return tabItem } diff --git a/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+Tap.swift b/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+Tap.swift new file mode 100644 index 0000000..e2aac0e --- /dev/null +++ b/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+Tap.swift @@ -0,0 +1,28 @@ +import AXComponentKit +import Foundation +import XCTest + +@MainActor +public extension AXScreen { + /// Finds the element identified by the given key path, waits for it to + /// exist, then taps it. + static func tap( + _ path: KeyPath, + timeout: Measurement = .seconds(10), + file: StaticString = #file, + line: UInt = #line + ) async throws { + try await element(path, timeout: timeout, file: file, line: line).tap() + } + + /// Finds the dynamic element, waits for it to exist, then taps it. + static func tap( + _ path: KeyPath>, + value: Value, + timeout: Measurement = .seconds(10), + file: StaticString = #file, + line: UInt = #line + ) async throws { + try await element(path, value: value, timeout: timeout, file: file, line: line).tap() + } +} diff --git a/Sources/AXComponentKitTestSupport/Existence/XCUIElement+Existence.swift b/Sources/AXComponentKitTestSupport/Existence/XCUIElement+Existence.swift index 008cddf..c03bcb2 100644 --- a/Sources/AXComponentKitTestSupport/Existence/XCUIElement+Existence.swift +++ b/Sources/AXComponentKitTestSupport/Existence/XCUIElement+Existence.swift @@ -35,9 +35,9 @@ internal extension XCUIElement { file: StaticString = #file, line: UInt = #line ) throws -> Self { - if !waitForExistence(timeout: timeout.timeInterval) { + if !exists, !waitForExistence(timeout: timeout.timeInterval) { let output = message() ?? "Element not found matching identifier: \"\(identifier)\"" - throw AXFailure(output, file: file, line: line) + try AXFailure.fail(output, file: file, line: line) } return self } diff --git a/Sources/AXComponentKitTestSupport/Launch/XCUIApplication+AutomationLaunch.swift b/Sources/AXComponentKitTestSupport/Launch/XCUIApplication+AutomationLaunch.swift new file mode 100644 index 0000000..032d63d --- /dev/null +++ b/Sources/AXComponentKitTestSupport/Launch/XCUIApplication+AutomationLaunch.swift @@ -0,0 +1,27 @@ +import AXComponentKit +import XCTest + +public extension XCUIApplication { + /// Returns a new `XCUIApplication` pre-configured with AXComponentKit's + /// launch arguments for animation suppression and test optimization. + static func automationConfigured() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments += [AXAutomation.runnerArgument] + return app + } + + /// Launches a pre-configured application for automation testing. + /// + /// This is the recommended way to launch the app in UI tests: + /// ```swift + /// override func setUp() async throws { + /// XCUIApplication.automationLaunch() + /// } + /// ``` + @discardableResult + static func automationLaunch() -> XCUIApplication { + let app = automationConfigured() + app.launch() + return app + } +} diff --git a/Sources/AXComponentKitTestSupport/Navigation/AXScreenNavigator+Scrolling.swift b/Sources/AXComponentKitTestSupport/Navigation/AXScreenNavigator+Scrolling.swift index 51800a1..333c3dc 100644 --- a/Sources/AXComponentKitTestSupport/Navigation/AXScreenNavigator+Scrolling.swift +++ b/Sources/AXComponentKitTestSupport/Navigation/AXScreenNavigator+Scrolling.swift @@ -11,26 +11,23 @@ public enum ScrollDirection { public extension AXScreenNavigator { /// Scrolls the given scroll view in the specified direction until the desired - /// `XCUIElement` comes into existence. If the element is not found before - /// the timeout expires, an error is thrown. + /// static `AXComponent` comes into existence. /// /// - Parameters: /// - direction: /// The direction in which the scroll view should be scrolled. /// The default is `.down`. /// - elementPath: - /// `KeyPath` relative to `Self` that identifies an `AXComponent` + /// `KeyPath` relative to `Source` that identifies an `AXComponent` /// - path: /// The key path that identifies an `AXScrollView` which contains `element` /// - timeout: /// Duration of time that this call should wait for the element to come into existence. - /// The default is 10 seconds. + /// The default is 30 seconds. /// - file: /// The file to present an error in if a failure occurs. - /// The default is the filename of the test case where you call this function. /// - line: /// The line number to present an error on if a failure occurs. - /// The default is the line number of the test case where you call this function. func scroll( _ direction: ScrollDirection = .down, to elementPath: KeyPath, @@ -44,28 +41,25 @@ public extension AXScreenNavigator { } /// Scrolls the given scroll view in the specified direction until the desired - /// `XCUIElement` comes into existence. If the element is not found before - /// the timeout expires, an error is thrown. + /// dynamic component comes into existence. /// /// - Parameters: /// - direction: /// The direction in which the scroll view should be scrolled. /// The default is `.down`. /// - elementPath: - /// `KeyPath` relative to `Self` that identifies an `AXDynamicComponent` + /// `KeyPath` relative to `Source` that identifies an `AXDynamicComponent` /// - value: /// The dynamic value to use while resolving the component /// - path: /// The key path that identifies an `AXScrollView` which contains `element` /// - timeout: /// Duration of time that this call should wait for the element to come into existence. - /// The default is 10 seconds. + /// The default is 30 seconds. /// - file: /// The file to present an error in if a failure occurs. - /// The default is the filename of the test case where you call this function. /// - line: /// The line number to present an error on if a failure occurs. - /// The default is the line number of the test case where you call this function. func scroll( _ direction: ScrollDirection = .down, to elementPath: KeyPath>, @@ -74,122 +68,13 @@ public extension AXScreenNavigator { timeout: Measurement = .seconds(30), file: StaticString = #file, line: UInt = #line - ) async throws where Value: AXDynamicValue { + ) async throws where Value: AXIdentifierConvertible { let target = Source.assumedElement(elementPath, value: value, file: file, line: line) try await scroll(direction, to: target, in: path, timeout: timeout, file: file, line: line) } /// Scrolls the given scroll view in the specified direction until the desired - /// `XCUIElement` comes into existence. If the element is not found before - /// the timeout expires, an error is thrown. - /// - /// - Parameters: - /// - direction: - /// The direction in which the scroll view should be scrolled. - /// The default is `.down`. - /// - elementPath: - /// `KeyPath` relative to `Self` that identifies an `AXDynamicComponent` - /// - value: - /// The dynamic value to use while resolving the component - /// - path: - /// The key path that identifies an `AXScrollView` which contains `element` - /// - timeout: - /// Duration of time that this call should wait for the element to come into existence. - /// The default is 10 seconds. - /// - file: - /// The file to present an error in if a failure occurs. - /// The default is the filename of the test case where you call this function. - /// - line: - /// The line number to present an error on if a failure occurs. - /// The default is the line number of the test case where you call this function. - func scroll( - _ direction: ScrollDirection = .down, - to elementPath: KeyPath>, - value: Value, - in path: KeyPath, - timeout: Measurement = .seconds(30), - file: StaticString = #file, - line: UInt = #line - ) async throws where Value: SignedInteger { - let target = Source.assumedElement(elementPath, value: value, file: file, line: line) - try await scroll(direction, to: target, in: path, timeout: timeout, file: file, line: line) - } - - /// Scrolls the given scroll view in the specified direction until the desired - /// `XCUIElement` comes into existence. If the element is not found before - /// the timeout expires, an error is thrown. - /// - /// - Parameters: - /// - direction: - /// The direction in which the scroll view should be scrolled. - /// The default is `.down`. - /// - elementPath: - /// `KeyPath` relative to `Self` that identifies an `AXDynamicComponent` - /// - value: - /// The dynamic value to use while resolving the component - /// - path: - /// The key path that identifies an `AXScrollView` which contains `element` - /// - timeout: - /// Duration of time that this call should wait for the element to come into existence. - /// The default is 10 seconds. - /// - file: - /// The file to present an error in if a failure occurs. - /// The default is the filename of the test case where you call this function. - /// - line: - /// The line number to present an error on if a failure occurs. - /// The default is the line number of the test case where you call this function. - func scroll( - _ direction: ScrollDirection = .down, - to elementPath: KeyPath>, - value: Value, - in path: KeyPath, - timeout: Measurement = .seconds(30), - file: StaticString = #file, - line: UInt = #line - ) async throws where Value: UnsignedInteger { - let target = Source.assumedElement(elementPath, value: value, file: file, line: line) - try await scroll(direction, to: target, in: path, timeout: timeout, file: file, line: line) - } - - /// Scrolls the given scroll view in the specified direction until the desired - /// `XCUIElement` comes into existence. If the element is not found before - /// the timeout expires, an error is thrown. - /// - /// - Parameters: - /// - direction: - /// The direction in which the scroll view should be scrolled. - /// The default is `.down`. - /// - elementPath: - /// `KeyPath` relative to `Self` that identifies an `AXDynamicComponent` - /// - value: - /// The dynamic value to use while resolving the component - /// - path: - /// The key path that identifies an `AXScrollView` which contains `element` - /// - timeout: - /// Duration of time that this call should wait for the element to come into existence. - /// The default is 10 seconds. - /// - file: - /// The file to present an error in if a failure occurs. - /// The default is the filename of the test case where you call this function. - /// - line: - /// The line number to present an error on if a failure occurs. - /// The default is the line number of the test case where you call this function. - func scroll( - _ direction: ScrollDirection = .down, - to elementPath: KeyPath>, - value: Value, - in path: KeyPath, - timeout: Measurement = .seconds(30), - file: StaticString = #file, - line: UInt = #line - ) async throws where Value: StringProtocol { - let target = Source.assumedElement(elementPath, value: value, file: file, line: line) - try await scroll(direction, to: target, in: path, timeout: timeout, file: file, line: line) - } - - /// Scrolls the given scroll view in the specified direction until the desired - /// `XCUIElement` comes into existence. If the element is not found before - /// the timeout expires, an error is thrown. + /// `XCUIElement` comes into existence. /// /// - Parameters: /// - direction: @@ -201,13 +86,11 @@ public extension AXScreenNavigator { /// The key path that identifies an `AXScrollView` which contains `element` /// - timeout: /// Duration of time that this call should wait for the element to come into existence. - /// The default is 10 seconds. + /// The default is 30 seconds. /// - file: /// The file to present an error in if a failure occurs. - /// The default is the filename of the test case where you call this function. /// - line: /// The line number to present an error on if a failure occurs. - /// The default is the line number of the test case where you call this function. func scroll( _ direction: ScrollDirection = .down, to element: XCUIElement, @@ -216,26 +99,27 @@ public extension AXScreenNavigator { file: StaticString = #file, line: UInt = #line ) async throws { - let start = Date() + let scrollView = try await Source.element(path, file: file, line: line) + let transaction = ScrollTransaction(direction: direction) + let deadline = Date().addingTimeInterval(timeout.timeInterval) - while Date().timeIntervalSince(start) < timeout.timeInterval { + while Date() < deadline { if element.exists { return } - let scrollView = try await Source.element(path, file: file, line: line) - let transaction = ScrollTransaction(direction: direction) - let start = scrollView.coordinate(withNormalizedOffset: transaction.source) - let end = scrollView.coordinate(withNormalizedOffset: transaction.destination) + let origin = scrollView.coordinate(withNormalizedOffset: transaction.source) + let target = scrollView.coordinate(withNormalizedOffset: transaction.destination) - start.press( - forDuration: 0.1, - thenDragTo: end, - withVelocity: .default, - thenHoldForDuration: 0.1 + origin.press( + forDuration: 0.01, + thenDragTo: target, + withVelocity: .fast, + thenHoldForDuration: 0.01 ) } - throw AXFailure("Scrolling timed out. Element not found.", file: file, line: line) + if element.exists { return } + try AXFailure.fail("Scrolling timed out. Element not found.", file: file, line: line) } } diff --git a/Sources/AXComponentKitTestSupport/Navigation/AXScreenNavigator.swift b/Sources/AXComponentKitTestSupport/Navigation/AXScreenNavigator.swift index e79672c..a84f387 100644 --- a/Sources/AXComponentKitTestSupport/Navigation/AXScreenNavigator.swift +++ b/Sources/AXComponentKitTestSupport/Navigation/AXScreenNavigator.swift @@ -4,12 +4,12 @@ import XCTest public extension AXScreen { /// Convenience alias to easily obtain the `AXScreenNavigator` for a given `AXScreen` typealias Navigator = AXScreenNavigator + + /// Returns a new `AXScreenNavigator` for this screen type. + @MainActor static var navigator: Navigator { Navigator() } } /// Provides a composable interface for navigating between screens of content -/// -/// TODO: Make better -/// @MainActor public struct AXScreenNavigator where Source: AXScreen { public init() {} @@ -17,11 +17,11 @@ public struct AXScreenNavigator where Source: AXScreen { private func waitForScreenToExist( timeout duration: Measurement = .seconds(10) ) async -> Bool { - XCUIApplication() + let element = XCUIApplication() .descendants(matching: .any) .matching(identifier: Source.screenIdentifier) .firstMatch - .waitForExistence(timeout: duration.timeInterval) + return element.exists || element.waitForExistence(timeout: duration.timeInterval) } /// Navigate the test runner from the `Source` screen to some `Destination` screen @@ -51,14 +51,14 @@ public struct AXScreenNavigator where Source: AXScreen { /// to tap on a collection view cell in order to facilitate that transition. /// - Returns: /// An `AXScreenNavigator` that corresponds to the `Destination` screen. - public func performNavigation( + public func navigate( to _: Destination.Type, timeout: Measurement = .seconds(10), file: StaticString = #file, line: UInt = #line, - actions: (Source.Type) async throws -> Void + actions: @MainActor (Source.Type) async throws -> Void ) async throws -> AXScreenNavigator where Destination: AXScreen { - try await performNavigation(timeout: timeout, file: file, line: line, actions: actions) + try await navigate(timeout: timeout, file: file, line: line, actions: actions) } /// Navigate the test runner from the `Source` screen to some `Destination` screen @@ -86,29 +86,26 @@ public struct AXScreenNavigator where Source: AXScreen { /// to tap on a collection view cell in order to facilitate that transition. /// - Returns: /// An `AXScreenNavigator` that corresponds to the `Destination` screen. - public func performNavigation( + public func navigate( timeout: Measurement = .seconds(10), file: StaticString = #file, line: UInt = #line, - actions: (Source.Type) async throws -> Void + actions: @MainActor (Source.Type) async throws -> Void ) async throws -> AXScreenNavigator where Destination: AXScreen { let sourceExists = await waitForScreenToExist(timeout: timeout) if !sourceExists { - let message = "Source screen not found: \(type(of: Source.self))" - throw AXFailure(message, file: file, line: line) + try AXFailure.fail("Source screen not found: \(Source.self)", file: file, line: line) } - if sourceExists { - try await actions(Source.self) // Caller navigates to destination - } + try await actions(Source.self) let navigator = AXScreenNavigator() let destinationExists = await navigator.waitForScreenToExist(timeout: timeout) if !destinationExists { - let message = "Destination screen not found: \(type(of: Destination.self))" - throw AXFailure(message, file: file, line: line) + try AXFailure.fail("Destination screen not found: \(Destination.self)", file: file, line: line) } + NSLog("[AXCK] Screen did appear: %@", Destination.screenIdentifier) return navigator } } diff --git a/Sources/AXComponentKitTestSupport/Navigation/AXTabBarNavigable.swift b/Sources/AXComponentKitTestSupport/Navigation/AXTabBarNavigable.swift index efa41a5..ca45546 100644 --- a/Sources/AXComponentKitTestSupport/Navigation/AXTabBarNavigable.swift +++ b/Sources/AXComponentKitTestSupport/Navigation/AXTabBarNavigable.swift @@ -3,35 +3,6 @@ import Foundation public protocol AXTabBarNavigable {} -public extension AXScreen where Self: AXTabBarNavigable { - /// Asynchronously navigates the test runner to the specified `AXTabComponent` - /// once it comes into existence. Otherwise an error is thrown. - /// - /// - Parameters: - /// - path: - /// `KeyPath` relative to `Self` that identifies an `AXTabComponent` - /// - timeout: - /// Duration of time that this call should wait for the element to come into existence. - /// The default is 10 seconds. - /// - file: - /// The file to present an error in if a failure occurs. - /// The default is the filename of the test case where you call this function. - /// - line: - /// The line number to present an error on if a failure occurs. - /// The default is the line number of the test case where you call this function. - /// - Returns: - /// An `AXScreenNavigator` that corresponds to the `Destination` screen. - @discardableResult - static func navigate( - toTab path: KeyPath>, - timeout: Measurement = .seconds(10), - file: StaticString = #file, - line: UInt = #line - ) async throws -> AXScreenNavigator where Destination: AXScreen { - try await Navigator().navigate(toTab: path, timeout: timeout, file: file, line: line) - } -} - public extension AXScreenNavigator where Source: AXTabBarNavigable { /// Asynchronously navigates the test runner to the specified `AXTabComponent` /// once it comes into existence. Otherwise an error is thrown. @@ -57,7 +28,7 @@ public extension AXScreenNavigator where Source: AXTabBarNavigable { file: StaticString = #file, line: UInt = #line ) async throws -> AXScreenNavigator where Destination: AXScreen { - try await performNavigation(to: Destination.self, timeout: timeout, file: file, line: line) { source in + try await navigate(to: Destination.self, timeout: timeout, file: file, line: line) { source in try await source.element(path, timeout: timeout, file: file, line: line).tap() } } diff --git a/Sources/AXComponentKitTestSupport/Navigation/AXTabComponent.swift b/Sources/AXComponentKitTestSupport/Navigation/AXTabComponent.swift index 35545ce..ce4600b 100644 --- a/Sources/AXComponentKitTestSupport/Navigation/AXTabComponent.swift +++ b/Sources/AXComponentKitTestSupport/Navigation/AXTabComponent.swift @@ -3,9 +3,9 @@ import Foundation /// Identifies a `TabItem` by its name, since iOS doesn't allow developers to /// specify accessibility identifiers for those elements. -public struct AXTabComponent where Content: AXScreen { +public struct AXTabComponent: Sendable where Content: AXScreen { /// The name of the represented tab. Must match the label of the tab at runtime. - let name: String + public let name: String /// Creates a new `AXTabComponent` with the given name /// diff --git a/Sources/AXComponentKitTestSupport/Navigation/ScrollTransaction.swift b/Sources/AXComponentKitTestSupport/Navigation/ScrollTransaction.swift index cd2f211..ff8371e 100644 --- a/Sources/AXComponentKitTestSupport/Navigation/ScrollTransaction.swift +++ b/Sources/AXComponentKitTestSupport/Navigation/ScrollTransaction.swift @@ -63,13 +63,13 @@ internal struct ScrollTransaction { // works fine for now. switch direction { case .up: - self = .vertical(from: 0.25, to: 1.0) + self = .vertical(from: 0.3, to: 0.8) case .down: - self = .vertical(from: 0.9, to: 0.0) + self = .vertical(from: 0.8, to: 0.2) case .left: - self = .horizontal(from: 0.25, to: 1.0) + self = .horizontal(from: 0.3, to: 0.8) case .right: - self = .horizontal(from: 0.9, to: 0.0) + self = .horizontal(from: 0.8, to: 0.2) } } diff --git a/Sources/AXComponentKitTestSupport/XCTestCase+AsyncSetup.swift b/Sources/AXComponentKitTestSupport/XCTestCase+AsyncSetup.swift deleted file mode 100644 index 9bbc5bf..0000000 --- a/Sources/AXComponentKitTestSupport/XCTestCase+AsyncSetup.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Foundation -import XCTest - -public extension XCTestCase { - /// Allows the setup of an `XCTestCase` to be performed as an - /// async operation. Handles the `completion()` automatically - /// by catching any errors thrown and passing them in, or - /// by passing `nil` otherwise. - /// - /// ``` - /// @MainActor - /// final class ExampleScreenTests: XCTestCase { - /// override func setUp(completion: @escaping (Error?) -> Void) { - /// setUp(completion: completion) { - /// XCUIApplication().launch() - /// try await ExampleScreen.navigate(toTab: \.someTab) - /// } - /// } - /// } - /// ``` - /// - Parameters: - /// - completion: - /// The `XCTestCase` setup completion handler - /// - actions: - /// The actions to perform during setup - func setUp( - completion: @escaping (Error?) -> Void, - _ actions: @escaping () async throws -> Void - ) { - Task { - do { - try await actions() - completion(nil) - } catch { - completion(error) - } - } - } -} From f79b4b13d324aed7810036b584320d76eeb6ceb0 Mon Sep 17 00:00:00 2001 From: Chris Stroud Date: Wed, 20 May 2026 12:25:32 -0400 Subject: [PATCH 2/8] Expand sample app into comprehensive dogfood demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add four-tab layout with catalog, profile, and settings screens - Add deep navigation test coverage (catalog → category → item detail) - Add capability protocols (DismissibleScreen, NavigationScreen) - Convert UI tests to xctestplan format - Demonstrate @AXScreen macro, animation suppression, and tab navigation --- .../project.pbxproj | 202 ++++-------------- .../xcschemes/AXComponentKitSample.xcscheme | 8 +- .../AXComponentKitSampleApp.swift | 2 + .../AXScreens/CatalogScreen.swift | 10 + .../AXScreens/Category.swift | 18 ++ .../AXScreens/CategoryDetailScreen.swift | 8 + .../AXScreens/DetailScreen.swift | 7 +- .../AXScreens/FirstTabScreen.swift | 7 +- .../AXScreens/ItemDetailScreen.swift | 9 + .../AXScreens/ProfileScreen.swift | 9 + .../AXScreens/SecondTabScreen.swift | 7 +- .../AXScreens/SettingsScreen.swift | 16 ++ .../AXScreens/ShareScreen.swift | 9 + .../Views/ContentView.swift | 8 + .../Views/Fourth Tab/CatalogView.swift | 46 ++++ .../Views/Fourth Tab/CategoryDetailView.swift | 21 ++ .../Views/Fourth Tab/ItemDetailView.swift | 32 +++ .../Views/Shared/ShareSheetView.swift | 35 +++ .../Views/Third Tab/ProfileView.swift | 42 ++++ .../Views/Third Tab/SettingsView.swift | 57 +++++ .../UITests/AXComponentKitSample.xctestplan | 30 +++ .../Capabilities/DismissibleScreen.swift | 19 ++ .../Capabilities/RootTabBarNavigable.swift | 9 +- .../UITests/CatalogScreenTests.swift | 49 +++++ .../UITests/DeepNavigationTests.swift | 76 +++++++ .../UITests/FirstTabScreenTests.swift | 17 +- .../CatalogScreen+Navigator.swift | 18 ++ .../CatalogScreen+TestSupport.swift | 4 + .../CategoryDetailScreen+Navigator.swift | 18 ++ .../FirstTabScreen+Navigator.swift | 14 +- .../ItemDetailScreen+Navigator.swift | 15 ++ .../ProfileScreen+Navigator.swift | 15 ++ .../ProfileScreen+TestSupport.swift | 7 + .../SecondTabScreen+Navigator.swift | 13 +- .../SettingsScreen+Navigator.swift | 15 ++ .../SettingsScreen+TestSupport.swift | 4 + .../ShareScreen+TestSupport.swift | 4 + .../UITests/SecondTabScreenTests.swift | 13 +- .../UITests/SettingsScreenTests.swift | 51 +++++ 39 files changed, 730 insertions(+), 214 deletions(-) create mode 100644 AXComponentKitSample/AXComponentKitSample/AXScreens/CatalogScreen.swift create mode 100644 AXComponentKitSample/AXComponentKitSample/AXScreens/Category.swift create mode 100644 AXComponentKitSample/AXComponentKitSample/AXScreens/CategoryDetailScreen.swift create mode 100644 AXComponentKitSample/AXComponentKitSample/AXScreens/ItemDetailScreen.swift create mode 100644 AXComponentKitSample/AXComponentKitSample/AXScreens/ProfileScreen.swift create mode 100644 AXComponentKitSample/AXComponentKitSample/AXScreens/SettingsScreen.swift create mode 100644 AXComponentKitSample/AXComponentKitSample/AXScreens/ShareScreen.swift create mode 100644 AXComponentKitSample/AXComponentKitSample/Views/Fourth Tab/CatalogView.swift create mode 100644 AXComponentKitSample/AXComponentKitSample/Views/Fourth Tab/CategoryDetailView.swift create mode 100644 AXComponentKitSample/AXComponentKitSample/Views/Fourth Tab/ItemDetailView.swift create mode 100644 AXComponentKitSample/AXComponentKitSample/Views/Shared/ShareSheetView.swift create mode 100644 AXComponentKitSample/AXComponentKitSample/Views/Third Tab/ProfileView.swift create mode 100644 AXComponentKitSample/AXComponentKitSample/Views/Third Tab/SettingsView.swift create mode 100644 AXComponentKitSample/UITests/AXComponentKitSample.xctestplan create mode 100644 AXComponentKitSample/UITests/Capabilities/DismissibleScreen.swift create mode 100644 AXComponentKitSample/UITests/CatalogScreenTests.swift create mode 100644 AXComponentKitSample/UITests/DeepNavigationTests.swift create mode 100644 AXComponentKitSample/UITests/Screens+TestSupport/CatalogScreen+Navigator.swift create mode 100644 AXComponentKitSample/UITests/Screens+TestSupport/CatalogScreen+TestSupport.swift create mode 100644 AXComponentKitSample/UITests/Screens+TestSupport/CategoryDetailScreen+Navigator.swift create mode 100644 AXComponentKitSample/UITests/Screens+TestSupport/ItemDetailScreen+Navigator.swift create mode 100644 AXComponentKitSample/UITests/Screens+TestSupport/ProfileScreen+Navigator.swift create mode 100644 AXComponentKitSample/UITests/Screens+TestSupport/ProfileScreen+TestSupport.swift create mode 100644 AXComponentKitSample/UITests/Screens+TestSupport/SettingsScreen+Navigator.swift create mode 100644 AXComponentKitSample/UITests/Screens+TestSupport/SettingsScreen+TestSupport.swift create mode 100644 AXComponentKitSample/UITests/Screens+TestSupport/ShareScreen+TestSupport.swift create mode 100644 AXComponentKitSample/UITests/SettingsScreenTests.swift diff --git a/AXComponentKitSample/AXComponentKitSample.xcodeproj/project.pbxproj b/AXComponentKitSample/AXComponentKitSample.xcodeproj/project.pbxproj index 7ce392d..187ce47 100644 --- a/AXComponentKitSample/AXComponentKitSample.xcodeproj/project.pbxproj +++ b/AXComponentKitSample/AXComponentKitSample.xcodeproj/project.pbxproj @@ -3,34 +3,13 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ - 6B53EDA428D512A500D8B1FC /* AXComponentKitSampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B53EDA328D512A500D8B1FC /* AXComponentKitSampleApp.swift */; }; - 6B53EDA628D512A500D8B1FC /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B53EDA528D512A500D8B1FC /* ContentView.swift */; }; - 6B53EDA828D512A700D8B1FC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6B53EDA728D512A700D8B1FC /* Assets.xcassets */; }; - 6B53EDAB28D512A700D8B1FC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6B53EDAA28D512A700D8B1FC /* Preview Assets.xcassets */; }; - 6B53EDBB28D5133000D8B1FC /* FirstTabScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B53EDBA28D5133000D8B1FC /* FirstTabScreenTests.swift */; }; - 6B53EDC528D513F300D8B1FC /* FirstTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B53EDC428D513F300D8B1FC /* FirstTabView.swift */; }; - 6B53EDC728D513FB00D8B1FC /* SecondTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B53EDC628D513FB00D8B1FC /* SecondTabView.swift */; }; - 6B53EDC928D5164D00D8B1FC /* DetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B53EDC828D5164D00D8B1FC /* DetailView.swift */; }; - 6B53EDCB28D5184B00D8B1FC /* FirstTabScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B53EDCA28D5184B00D8B1FC /* FirstTabScreen.swift */; }; - 6B53EDCC28D5185200D8B1FC /* FirstTabScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B53EDCA28D5184B00D8B1FC /* FirstTabScreen.swift */; }; - 6B53EDD028D5191B00D8B1FC /* NavigationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B53EDCE28D5191B00D8B1FC /* NavigationScreen.swift */; }; - 6B53EDD128D5191B00D8B1FC /* RootTabBarNavigable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B53EDCF28D5191B00D8B1FC /* RootTabBarNavigable.swift */; }; - 6B53EDD528D51BEE00D8B1FC /* SecondTabScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B53EDD428D51BEE00D8B1FC /* SecondTabScreen.swift */; }; - 6B53EDD628D51BEE00D8B1FC /* SecondTabScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B53EDD428D51BEE00D8B1FC /* SecondTabScreen.swift */; }; 6B53EDDB28D51DAA00D8B1FC /* AXComponentKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6B53EDDA28D51DAA00D8B1FC /* AXComponentKit */; }; - 6B53EDE128D8C19300D8B1FC /* DetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B53EDE028D8C19300D8B1FC /* DetailScreen.swift */; }; - 6B53EDE228D8C19300D8B1FC /* DetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B53EDE028D8C19300D8B1FC /* DetailScreen.swift */; }; - 6B53EDE528D8C22F00D8B1FC /* FirstTabScreen+TestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B53EDE328D8C22F00D8B1FC /* FirstTabScreen+TestSupport.swift */; }; - 6B53EDE828D8C2A900D8B1FC /* SecondTabScreen+TestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B53EDE728D8C2A900D8B1FC /* SecondTabScreen+TestSupport.swift */; }; - 6B53EDEA28D8C2D100D8B1FC /* DetailScreen+TestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B53EDE928D8C2D100D8B1FC /* DetailScreen+TestSupport.swift */; }; 6B53EDEC28D8C35E00D8B1FC /* AXComponentKitTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = 6B53EDEB28D8C35E00D8B1FC /* AXComponentKitTestSupport */; }; - 6B6ABDB728E487740064A86F /* SecondTabScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B6ABDB628E487740064A86F /* SecondTabScreenTests.swift */; }; - 6B6ABDB928E488330064A86F /* FirstTabScreen+Navigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B6ABDB828E488330064A86F /* FirstTabScreen+Navigator.swift */; }; - 6B6ABDBB28E48A200064A86F /* SecondTabScreen+Navigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B6ABDBA28E48A200064A86F /* SecondTabScreen+Navigator.swift */; }; + BB000001BB000001BB000001 /* AXComponentKitMacroSupport in Frameworks */ = {isa = PBXBuildFile; productRef = BB000002BB000002BB000002 /* AXComponentKitMacroSupport */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -45,36 +24,49 @@ /* Begin PBXFileReference section */ 6B53EDA028D512A500D8B1FC /* AXComponentKitSample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AXComponentKitSample.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 6B53EDA328D512A500D8B1FC /* AXComponentKitSampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXComponentKitSampleApp.swift; sourceTree = ""; }; - 6B53EDA528D512A500D8B1FC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - 6B53EDA728D512A700D8B1FC /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 6B53EDAA28D512A700D8B1FC /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 6B53EDB828D5133000D8B1FC /* UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 6B53EDBA28D5133000D8B1FC /* FirstTabScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstTabScreenTests.swift; sourceTree = ""; }; - 6B53EDC428D513F300D8B1FC /* FirstTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstTabView.swift; sourceTree = ""; }; - 6B53EDC628D513FB00D8B1FC /* SecondTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondTabView.swift; sourceTree = ""; }; - 6B53EDC828D5164D00D8B1FC /* DetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailView.swift; sourceTree = ""; }; - 6B53EDCA28D5184B00D8B1FC /* FirstTabScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstTabScreen.swift; sourceTree = ""; }; - 6B53EDCE28D5191B00D8B1FC /* NavigationScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationScreen.swift; sourceTree = ""; }; - 6B53EDCF28D5191B00D8B1FC /* RootTabBarNavigable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RootTabBarNavigable.swift; sourceTree = ""; }; - 6B53EDD428D51BEE00D8B1FC /* SecondTabScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondTabScreen.swift; sourceTree = ""; }; 6B53EDDE28D51DC100D8B1FC /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; - 6B53EDE028D8C19300D8B1FC /* DetailScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailScreen.swift; sourceTree = ""; }; - 6B53EDE328D8C22F00D8B1FC /* FirstTabScreen+TestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FirstTabScreen+TestSupport.swift"; sourceTree = ""; }; - 6B53EDE728D8C2A900D8B1FC /* SecondTabScreen+TestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SecondTabScreen+TestSupport.swift"; sourceTree = ""; }; - 6B53EDE928D8C2D100D8B1FC /* DetailScreen+TestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DetailScreen+TestSupport.swift"; sourceTree = ""; }; 6B53EE0328DCC5A300D8B1FC /* AXComponentKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = AXComponentKit; path = ..; sourceTree = ""; }; - 6B6ABDB628E487740064A86F /* SecondTabScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondTabScreenTests.swift; sourceTree = ""; }; - 6B6ABDB828E488330064A86F /* FirstTabScreen+Navigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FirstTabScreen+Navigator.swift"; sourceTree = ""; }; - 6B6ABDBA28E48A200064A86F /* SecondTabScreen+Navigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SecondTabScreen+Navigator.swift"; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + B2F227442FACF8A800CF41A1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + AXComponentKitSample.xctestplan, + ); + target = 6B53EDB728D5133000D8B1FC /* UITests */; + }; + B2F227862FACF8AE00CF41A1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + AXScreens/CatalogScreen.swift, + AXScreens/Category.swift, + AXScreens/CategoryDetailScreen.swift, + AXScreens/DetailScreen.swift, + AXScreens/FirstTabScreen.swift, + AXScreens/ItemDetailScreen.swift, + AXScreens/ProfileScreen.swift, + AXScreens/SecondTabScreen.swift, + AXScreens/SettingsScreen.swift, + AXScreens/ShareScreen.swift, + ); + target = 6B53EDB728D5133000D8B1FC /* UITests */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + B2F2272E2FACF8A700CF41A1 /* UITests */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (B2F227442FACF8A800CF41A1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = UITests; sourceTree = ""; }; + B2F227642FACF8AD00CF41A1 /* AXComponentKitSample */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (B2F227862FACF8AE00CF41A1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = AXComponentKitSample; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 6B53ED9D28D512A500D8B1FC /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 6B53EDDB28D51DAA00D8B1FC /* AXComponentKit in Frameworks */, + BB000001BB000001BB000001 /* AXComponentKitMacroSupport in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -93,8 +85,8 @@ isa = PBXGroup; children = ( 6B53EDB128D512D700D8B1FC /* Packages */, - 6B53EDA228D512A500D8B1FC /* AXComponentKitSample */, - 6B53EDB928D5133000D8B1FC /* UITests */, + B2F227642FACF8AD00CF41A1 /* AXComponentKitSample */, + B2F2272E2FACF8A700CF41A1 /* UITests */, 6B53EDA128D512A500D8B1FC /* Products */, 6B53EDD928D51DAA00D8B1FC /* Frameworks */, ); @@ -109,26 +101,6 @@ name = Products; sourceTree = ""; }; - 6B53EDA228D512A500D8B1FC /* AXComponentKitSample */ = { - isa = PBXGroup; - children = ( - 6B53EDA328D512A500D8B1FC /* AXComponentKitSampleApp.swift */, - 6B53EDE628D8C26600D8B1FC /* AXScreens */, - 6B6ABDB328E4850C0064A86F /* Views */, - 6B53EDA728D512A700D8B1FC /* Assets.xcassets */, - 6B53EDA928D512A700D8B1FC /* Preview Content */, - ); - path = AXComponentKitSample; - sourceTree = ""; - }; - 6B53EDA928D512A700D8B1FC /* Preview Content */ = { - isa = PBXGroup; - children = ( - 6B53EDAA28D512A700D8B1FC /* Preview Assets.xcassets */, - ); - path = "Preview Content"; - sourceTree = ""; - }; 6B53EDB128D512D700D8B1FC /* Packages */ = { isa = PBXGroup; children = ( @@ -137,38 +109,6 @@ name = Packages; sourceTree = ""; }; - 6B53EDB928D5133000D8B1FC /* UITests */ = { - isa = PBXGroup; - children = ( - 6B53EDBA28D5133000D8B1FC /* FirstTabScreenTests.swift */, - 6B6ABDB628E487740064A86F /* SecondTabScreenTests.swift */, - 6B53EDCD28D518F900D8B1FC /* Capabilities */, - 6B53EDC328D5133A00D8B1FC /* Screens+TestSupport */, - ); - path = UITests; - sourceTree = ""; - }; - 6B53EDC328D5133A00D8B1FC /* Screens+TestSupport */ = { - isa = PBXGroup; - children = ( - 6B6ABDB828E488330064A86F /* FirstTabScreen+Navigator.swift */, - 6B53EDE328D8C22F00D8B1FC /* FirstTabScreen+TestSupport.swift */, - 6B53EDE728D8C2A900D8B1FC /* SecondTabScreen+TestSupport.swift */, - 6B6ABDBA28E48A200064A86F /* SecondTabScreen+Navigator.swift */, - 6B53EDE928D8C2D100D8B1FC /* DetailScreen+TestSupport.swift */, - ); - path = "Screens+TestSupport"; - sourceTree = ""; - }; - 6B53EDCD28D518F900D8B1FC /* Capabilities */ = { - isa = PBXGroup; - children = ( - 6B53EDCE28D5191B00D8B1FC /* NavigationScreen.swift */, - 6B53EDCF28D5191B00D8B1FC /* RootTabBarNavigable.swift */, - ); - path = Capabilities; - sourceTree = ""; - }; 6B53EDD928D51DAA00D8B1FC /* Frameworks */ = { isa = PBXGroup; children = ( @@ -177,43 +117,6 @@ name = Frameworks; sourceTree = ""; }; - 6B53EDE628D8C26600D8B1FC /* AXScreens */ = { - isa = PBXGroup; - children = ( - 6B53EDCA28D5184B00D8B1FC /* FirstTabScreen.swift */, - 6B53EDD428D51BEE00D8B1FC /* SecondTabScreen.swift */, - 6B53EDE028D8C19300D8B1FC /* DetailScreen.swift */, - ); - path = AXScreens; - sourceTree = ""; - }; - 6B6ABDB328E4850C0064A86F /* Views */ = { - isa = PBXGroup; - children = ( - 6B53EDA528D512A500D8B1FC /* ContentView.swift */, - 6B6ABDB428E4851E0064A86F /* First Tab */, - 6B6ABDB528E485270064A86F /* Second Tab */, - ); - path = Views; - sourceTree = ""; - }; - 6B6ABDB428E4851E0064A86F /* First Tab */ = { - isa = PBXGroup; - children = ( - 6B53EDC428D513F300D8B1FC /* FirstTabView.swift */, - 6B53EDC828D5164D00D8B1FC /* DetailView.swift */, - ); - path = "First Tab"; - sourceTree = ""; - }; - 6B6ABDB528E485270064A86F /* Second Tab */ = { - isa = PBXGroup; - children = ( - 6B53EDC628D513FB00D8B1FC /* SecondTabView.swift */, - ); - path = "Second Tab"; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -229,9 +132,13 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + B2F227642FACF8AD00CF41A1 /* AXComponentKitSample */, + ); name = AXComponentKitSample; packageProductDependencies = ( 6B53EDDA28D51DAA00D8B1FC /* AXComponentKit */, + BB000002BB000002BB000002 /* AXComponentKitMacroSupport */, ); productName = AXComponentKitSample; productReference = 6B53EDA028D512A500D8B1FC /* AXComponentKitSample.app */; @@ -251,6 +158,9 @@ 6B53EDEE28D8C36D00D8B1FC /* PBXTargetDependency */, 6B53EDBF28D5133000D8B1FC /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + B2F2272E2FACF8A700CF41A1 /* UITests */, + ); name = UITests; packageProductDependencies = ( 6B53EDEB28D8C35E00D8B1FC /* AXComponentKitTestSupport */, @@ -302,8 +212,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 6B53EDAB28D512A700D8B1FC /* Preview Assets.xcassets in Resources */, - 6B53EDA828D512A700D8B1FC /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -321,14 +229,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 6B53EDC728D513FB00D8B1FC /* SecondTabView.swift in Sources */, - 6B53EDD528D51BEE00D8B1FC /* SecondTabScreen.swift in Sources */, - 6B53EDC928D5164D00D8B1FC /* DetailView.swift in Sources */, - 6B53EDA628D512A500D8B1FC /* ContentView.swift in Sources */, - 6B53EDC528D513F300D8B1FC /* FirstTabView.swift in Sources */, - 6B53EDCC28D5185200D8B1FC /* FirstTabScreen.swift in Sources */, - 6B53EDA428D512A500D8B1FC /* AXComponentKitSampleApp.swift in Sources */, - 6B53EDE128D8C19300D8B1FC /* DetailScreen.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -336,18 +236,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 6B53EDD028D5191B00D8B1FC /* NavigationScreen.swift in Sources */, - 6B53EDCB28D5184B00D8B1FC /* FirstTabScreen.swift in Sources */, - 6B53EDE228D8C19300D8B1FC /* DetailScreen.swift in Sources */, - 6B53EDE528D8C22F00D8B1FC /* FirstTabScreen+TestSupport.swift in Sources */, - 6B6ABDB928E488330064A86F /* FirstTabScreen+Navigator.swift in Sources */, - 6B53EDD128D5191B00D8B1FC /* RootTabBarNavigable.swift in Sources */, - 6B53EDBB28D5133000D8B1FC /* FirstTabScreenTests.swift in Sources */, - 6B53EDEA28D8C2D100D8B1FC /* DetailScreen+TestSupport.swift in Sources */, - 6B53EDD628D51BEE00D8B1FC /* SecondTabScreen.swift in Sources */, - 6B53EDE828D8C2A900D8B1FC /* SecondTabScreen+TestSupport.swift in Sources */, - 6B6ABDB728E487740064A86F /* SecondTabScreenTests.swift in Sources */, - 6B6ABDBB28E48A200064A86F /* SecondTabScreen+Navigator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -613,6 +501,10 @@ isa = XCSwiftPackageProductDependency; productName = AXComponentKitTestSupport; }; + BB000002BB000002BB000002 /* AXComponentKitMacroSupport */ = { + isa = XCSwiftPackageProductDependency; + productName = AXComponentKitMacroSupport; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 6B53ED9828D512A500D8B1FC /* Project object */; diff --git a/AXComponentKitSample/AXComponentKitSample.xcodeproj/xcshareddata/xcschemes/AXComponentKitSample.xcscheme b/AXComponentKitSample/AXComponentKitSample.xcodeproj/xcshareddata/xcschemes/AXComponentKitSample.xcscheme index caf6a37..26a06c1 100644 --- a/AXComponentKitSample/AXComponentKitSample.xcodeproj/xcshareddata/xcschemes/AXComponentKitSample.xcscheme +++ b/AXComponentKitSample/AXComponentKitSample.xcodeproj/xcshareddata/xcschemes/AXComponentKitSample.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> @@ -27,6 +27,12 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + = "catalog-category-card" + let featuredItem: AXDynamicComponent = "catalog-featured-item" +} diff --git a/AXComponentKitSample/AXComponentKitSample/AXScreens/Category.swift b/AXComponentKitSample/AXComponentKitSample/AXScreens/Category.swift new file mode 100644 index 0000000..4d00d81 --- /dev/null +++ b/AXComponentKitSample/AXComponentKitSample/AXScreens/Category.swift @@ -0,0 +1,18 @@ +import AXComponentKit +import Foundation + +enum Category: String, CaseIterable, AXDynamicValue { + case electronics + case books + case clothing + + var automationDynamicValue: String { rawValue } + + var displayName: String { + switch self { + case .electronics: "Electronics" + case .books: "Books" + case .clothing: "Clothing" + } + } +} diff --git a/AXComponentKitSample/AXComponentKitSample/AXScreens/CategoryDetailScreen.swift b/AXComponentKitSample/AXComponentKitSample/AXScreens/CategoryDetailScreen.swift new file mode 100644 index 0000000..0a7731f --- /dev/null +++ b/AXComponentKitSample/AXComponentKitSample/AXScreens/CategoryDetailScreen.swift @@ -0,0 +1,8 @@ +import AXComponentKit +import AXComponentKitMacroSupport + +@AXScreen +struct CategoryDetailScreen { + let itemList: AXScrollView = "category-detail-item-list" + let item: AXDynamicComponent = "category-detail-item" +} diff --git a/AXComponentKitSample/AXComponentKitSample/AXScreens/DetailScreen.swift b/AXComponentKitSample/AXComponentKitSample/AXScreens/DetailScreen.swift index 3759a33..cfd90e5 100644 --- a/AXComponentKitSample/AXComponentKitSample/AXScreens/DetailScreen.swift +++ b/AXComponentKitSample/AXComponentKitSample/AXScreens/DetailScreen.swift @@ -1,8 +1,7 @@ import AXComponentKit -import Foundation - -struct DetailScreen: AXScreen { - static let screenIdentifier = "detail-screen" +import AXComponentKitMacroSupport +@AXScreen +struct DetailScreen { let contentLabel: AXComponent = "content-label" } diff --git a/AXComponentKitSample/AXComponentKitSample/AXScreens/FirstTabScreen.swift b/AXComponentKitSample/AXComponentKitSample/AXScreens/FirstTabScreen.swift index d5d3e3e..fba7d02 100644 --- a/AXComponentKitSample/AXComponentKitSample/AXScreens/FirstTabScreen.swift +++ b/AXComponentKitSample/AXComponentKitSample/AXScreens/FirstTabScreen.swift @@ -1,8 +1,7 @@ import AXComponentKit -import Foundation - -struct FirstTabScreen: AXScreen { - static let screenIdentifier = "first-tab-screen" +import AXComponentKitMacroSupport +@AXScreen +struct FirstTabScreen { let detailButton: AXComponent = "first-tab-screen-detail-button" } diff --git a/AXComponentKitSample/AXComponentKitSample/AXScreens/ItemDetailScreen.swift b/AXComponentKitSample/AXComponentKitSample/AXScreens/ItemDetailScreen.swift new file mode 100644 index 0000000..e1d250a --- /dev/null +++ b/AXComponentKitSample/AXComponentKitSample/AXScreens/ItemDetailScreen.swift @@ -0,0 +1,9 @@ +import AXComponentKit +import AXComponentKitMacroSupport + +@AXScreen +struct ItemDetailScreen { + let titleLabel: AXComponent = "item-detail-title-label" + let descriptionLabel: AXComponent = "item-detail-description-label" + let shareButton: AXComponent = "item-detail-share-button" +} diff --git a/AXComponentKitSample/AXComponentKitSample/AXScreens/ProfileScreen.swift b/AXComponentKitSample/AXComponentKitSample/AXScreens/ProfileScreen.swift new file mode 100644 index 0000000..dee03b5 --- /dev/null +++ b/AXComponentKitSample/AXComponentKitSample/AXScreens/ProfileScreen.swift @@ -0,0 +1,9 @@ +import AXComponentKit +import AXComponentKitMacroSupport + +@AXScreen +struct ProfileScreen { + let profileField: AXDynamicComponent = "profile-field" + let saveButton: AXComponent = "profile-save-button" + let closeButton: AXComponent = "profile-close-button" +} diff --git a/AXComponentKitSample/AXComponentKitSample/AXScreens/SecondTabScreen.swift b/AXComponentKitSample/AXComponentKitSample/AXScreens/SecondTabScreen.swift index 8abf51f..d4130d8 100644 --- a/AXComponentKitSample/AXComponentKitSample/AXScreens/SecondTabScreen.swift +++ b/AXComponentKitSample/AXComponentKitSample/AXScreens/SecondTabScreen.swift @@ -1,9 +1,8 @@ import AXComponentKit -import Foundation - -struct SecondTabScreen: AXScreen { - static let screenIdentifier = "second-tab-screen" +import AXComponentKitMacroSupport +@AXScreen +struct SecondTabScreen { let table: AXScrollView = "second-table-table-view" let rowItem: AXDynamicComponent = "second-tab-dynamic-row" } diff --git a/AXComponentKitSample/AXComponentKitSample/AXScreens/SettingsScreen.swift b/AXComponentKitSample/AXComponentKitSample/AXScreens/SettingsScreen.swift new file mode 100644 index 0000000..f7b86e9 --- /dev/null +++ b/AXComponentKitSample/AXComponentKitSample/AXScreens/SettingsScreen.swift @@ -0,0 +1,16 @@ +import AXComponentKit +import AXComponentKitMacroSupport + +@AXScreen +struct SettingsScreen { + let notificationsToggle: AXComponent = "settings-notifications-toggle" + let darkModeToggle: AXComponent = "settings-dark-mode-toggle" + let locationToggle: AXComponent = "settings-location-toggle" + let analyticsToggle: AXComponent = "settings-analytics-toggle" + let versionLabel: AXComponent = "settings-version-label" + let privacyButton: AXComponent = "settings-privacy-button" + let termsButton: AXComponent = "settings-terms-button" + let profileButton: AXComponent = "settings-profile-button" + let settingsList: AXScrollView = "settings-list-scroll-view" + let supportEmail: AXComponent = AXComponent(prefix: "settings", "support-email-label") +} diff --git a/AXComponentKitSample/AXComponentKitSample/AXScreens/ShareScreen.swift b/AXComponentKitSample/AXComponentKitSample/AXScreens/ShareScreen.swift new file mode 100644 index 0000000..856cafd --- /dev/null +++ b/AXComponentKitSample/AXComponentKitSample/AXScreens/ShareScreen.swift @@ -0,0 +1,9 @@ +import AXComponentKit +import AXComponentKitMacroSupport + +@AXScreen +struct ShareScreen { + let messageField: AXComponent = "share-message-field" + let sendButton: AXComponent = "share-send-button" + let dismissButton: AXComponent = "share-dismiss-button" +} diff --git a/AXComponentKitSample/AXComponentKitSample/Views/ContentView.swift b/AXComponentKitSample/AXComponentKitSample/Views/ContentView.swift index b032262..9f6dc07 100644 --- a/AXComponentKitSample/AXComponentKitSample/Views/ContentView.swift +++ b/AXComponentKitSample/AXComponentKitSample/Views/ContentView.swift @@ -11,6 +11,14 @@ struct ContentView: View { .tabItem { Label("Second", systemImage: "circle") } + SettingsView() + .tabItem { + Label("Settings", systemImage: "gear") + } + CatalogView() + .tabItem { + Label("Catalog", systemImage: "square.grid.2x2") + } } } } diff --git a/AXComponentKitSample/AXComponentKitSample/Views/Fourth Tab/CatalogView.swift b/AXComponentKitSample/AXComponentKitSample/Views/Fourth Tab/CatalogView.swift new file mode 100644 index 0000000..c14ad6e --- /dev/null +++ b/AXComponentKitSample/AXComponentKitSample/Views/Fourth Tab/CatalogView.swift @@ -0,0 +1,46 @@ +import AXComponentKit +import SwiftUI + +struct CatalogView: View { + @State private var path = NavigationPath() + + private let featuredItems = 1 ... 8 + + var body: some View { + NavigationStack(path: $path) { + List { + Section("Featured") { + ScrollView(.horizontal) { + HStack(spacing: 12) { + ForEach(featuredItems, id: \.self) { index in + RoundedRectangle(cornerRadius: 8) + .fill(Color.blue.opacity(0.2)) + .frame(width: 120, height: 80) + .overlay(Text("Item \(index)")) + .automationComponent(\CatalogScreen.featuredItem, value: index) + } + } + .padding(.horizontal) + } + .automationScrollView(\CatalogScreen.featuredCarousel) + .listRowInsets(EdgeInsets()) + } + + Section("Categories") { + ForEach(Category.allCases, id: \.self) { category in + Button(category.displayName) { + path.append(category) + } + .automationComponent(\CatalogScreen.categoryCard, value: category) + } + } + } + .automationScrollView(\CatalogScreen.categoryList) + .navigationTitle("Catalog") + .navigationDestination(for: Category.self) { category in + CategoryDetailView(category: category) + } + } + .automationScreen(CatalogScreen.self) + } +} diff --git a/AXComponentKitSample/AXComponentKitSample/Views/Fourth Tab/CategoryDetailView.swift b/AXComponentKitSample/AXComponentKitSample/Views/Fourth Tab/CategoryDetailView.swift new file mode 100644 index 0000000..8e07432 --- /dev/null +++ b/AXComponentKitSample/AXComponentKitSample/Views/Fourth Tab/CategoryDetailView.swift @@ -0,0 +1,21 @@ +import AXComponentKit +import SwiftUI + +struct CategoryDetailView: View { + let category: Category + private let items = 1 ... 50 + + var body: some View { + Form { + ForEach(items, id: \.self) { index in + NavigationLink("Item \(index)") { + ItemDetailView(itemIndex: index, category: category) + } + .automationComponent(\CategoryDetailScreen.item, value: index) + } + } + .automationScrollView(\CategoryDetailScreen.itemList) + .navigationTitle(category.displayName) + .automationScreen(CategoryDetailScreen.self) + } +} diff --git a/AXComponentKitSample/AXComponentKitSample/Views/Fourth Tab/ItemDetailView.swift b/AXComponentKitSample/AXComponentKitSample/Views/Fourth Tab/ItemDetailView.swift new file mode 100644 index 0000000..9054c0e --- /dev/null +++ b/AXComponentKitSample/AXComponentKitSample/Views/Fourth Tab/ItemDetailView.swift @@ -0,0 +1,32 @@ +import AXComponentKit +import SwiftUI + +struct ItemDetailView: View { + let itemIndex: Int + let category: Category + + @State private var showShare = false + + var body: some View { + Form { + Section { + Text("Item \(itemIndex) — \(category.displayName)") + .automationComponent(\ItemDetailScreen.titleLabel) + Text("This is a detailed description of item \(itemIndex) in the \(category.displayName) category.") + .automationComponent(\ItemDetailScreen.descriptionLabel) + } + + Section { + Button("Share") { + showShare = true + } + .automationComponent(\ItemDetailScreen.shareButton) + } + } + .navigationTitle("Item \(itemIndex)") + .sheet(isPresented: $showShare) { + ShareSheetView() + } + .automationScreen(ItemDetailScreen.self) + } +} diff --git a/AXComponentKitSample/AXComponentKitSample/Views/Shared/ShareSheetView.swift b/AXComponentKitSample/AXComponentKitSample/Views/Shared/ShareSheetView.swift new file mode 100644 index 0000000..ea3c9c4 --- /dev/null +++ b/AXComponentKitSample/AXComponentKitSample/Views/Shared/ShareSheetView.swift @@ -0,0 +1,35 @@ +import AXComponentKit +import SwiftUI + +struct ShareSheetView: View { + @Environment(\.dismiss) private var dismiss + @State private var message = "" + + var body: some View { + NavigationStack { + Form { + Section("Message") { + TextField("Add a message…", text: $message, axis: .vertical) + .automationComponent(\ShareScreen.messageField) + } + + Section { + Button("Send") { + dismiss() + } + .automationComponent(\ShareScreen.sendButton) + } + } + .navigationTitle("Share") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + .automationComponent(\ShareScreen.dismissButton) + } + } + } + .automationScreen(ShareScreen.self) + } +} diff --git a/AXComponentKitSample/AXComponentKitSample/Views/Third Tab/ProfileView.swift b/AXComponentKitSample/AXComponentKitSample/Views/Third Tab/ProfileView.swift new file mode 100644 index 0000000..af71200 --- /dev/null +++ b/AXComponentKitSample/AXComponentKitSample/Views/Third Tab/ProfileView.swift @@ -0,0 +1,42 @@ +import AXComponentKit +import SwiftUI + +struct ProfileView: View { + @Environment(\.dismiss) private var dismiss + + private let fields = ["name", "email", "phone"] + + @State private var name = "Jane Doe" + @State private var email = "jane@example.com" + @State private var phone = "555-1234" + + var body: some View { + NavigationStack { + Form { + Section("Personal Information") { + TextField("Name", text: $name) + .automationComponent(\ProfileScreen.profileField, value: "name") + TextField("Email", text: $email) + .automationComponent(\ProfileScreen.profileField, value: "email") + TextField("Phone", text: $phone) + .automationComponent(\ProfileScreen.profileField, value: "phone") + } + + Section { + Button("Save") {} + .automationComponent(\ProfileScreen.saveButton) + } + } + .navigationTitle("Profile") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { + dismiss() + } + .automationComponent(\ProfileScreen.closeButton) + } + } + } + .automationScreen(ProfileScreen.self) + } +} diff --git a/AXComponentKitSample/AXComponentKitSample/Views/Third Tab/SettingsView.swift b/AXComponentKitSample/AXComponentKitSample/Views/Third Tab/SettingsView.swift new file mode 100644 index 0000000..ce88f34 --- /dev/null +++ b/AXComponentKitSample/AXComponentKitSample/Views/Third Tab/SettingsView.swift @@ -0,0 +1,57 @@ +import AXComponentKit +import SwiftUI + +struct SettingsView: View { + @State private var notificationsEnabled = true + @State private var darkModeEnabled = false + @State private var locationEnabled = true + @State private var analyticsEnabled = false + @State private var showProfile = false + + var body: some View { + NavigationStack { + Form { + Section("Preferences") { + Toggle("Notifications", isOn: $notificationsEnabled) + .automationComponent(\SettingsScreen.notificationsToggle) + Toggle("Dark Mode", isOn: $darkModeEnabled) + .automationComponent(\SettingsScreen.darkModeToggle) + Toggle("Location Services", isOn: $locationEnabled) + .automationComponent(\SettingsScreen.locationToggle) + Toggle("Analytics", isOn: $analyticsEnabled) + .automationComponent(\SettingsScreen.analyticsToggle) + } + + Section("Account") { + Button("View Profile") { + showProfile = true + } + .automationComponent(\SettingsScreen.profileButton) + } + + Section("Legal") { + Button("Privacy Policy") {} + .automationComponent(\SettingsScreen.privacyButton) + Button("Terms of Service") {} + .automationComponent(\SettingsScreen.termsButton) + } + + Section("Support") { + Text("support@example.com") + .automationComponent(\SettingsScreen.supportEmail) + } + + Section("About") { + Text("Version 1.0.0") + .automationComponent(\SettingsScreen.versionLabel) + } + } + .automationScrollView(\SettingsScreen.settingsList) + .navigationTitle("Settings") + .sheet(isPresented: $showProfile) { + ProfileView() + } + } + .automationScreen(SettingsScreen.self) + } +} diff --git a/AXComponentKitSample/UITests/AXComponentKitSample.xctestplan b/AXComponentKitSample/UITests/AXComponentKitSample.xctestplan new file mode 100644 index 0000000..8469eed --- /dev/null +++ b/AXComponentKitSample/UITests/AXComponentKitSample.xctestplan @@ -0,0 +1,30 @@ +{ + "configurations" : [ + { + "id" : "1C548B71-6FE2-41C8-9A43-DC65A35E973A", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : false, + "performanceAntipatternCheckerEnabled" : false, + "targetForVariableExpansion" : { + "containerPath" : "container:AXComponentKitSample.xcodeproj", + "identifier" : "6B53ED9F28D512A500D8B1FC", + "name" : "AXComponentKitSample" + } + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:AXComponentKitSample.xcodeproj", + "identifier" : "6B53EDB728D5133000D8B1FC", + "name" : "UITests" + } + } + ], + "version" : 1 +} diff --git a/AXComponentKitSample/UITests/Capabilities/DismissibleScreen.swift b/AXComponentKitSample/UITests/Capabilities/DismissibleScreen.swift new file mode 100644 index 0000000..5a01ed2 --- /dev/null +++ b/AXComponentKitSample/UITests/Capabilities/DismissibleScreen.swift @@ -0,0 +1,19 @@ +import AXComponentKit +import AXComponentKitTestSupport +import XCTest + +protocol DismissibleScreen: AXScreen { + var dismissButton: AXComponent { get } +} + +extension AXScreenNavigator where Source: DismissibleScreen { + /// Taps the dismiss button on the current screen. Does not assert + /// a destination screen — the caller should verify where they land + /// after dismissal via `SomeScreen.exists()`. + func dismiss( + file: StaticString = #file, + line: UInt = #line + ) async throws { + try await Source.element(\.dismissButton, file: file, line: line).tap() + } +} diff --git a/AXComponentKitSample/UITests/Capabilities/RootTabBarNavigable.swift b/AXComponentKitSample/UITests/Capabilities/RootTabBarNavigable.swift index 8fd57af..ca44771 100644 --- a/AXComponentKitSample/UITests/Capabilities/RootTabBarNavigable.swift +++ b/AXComponentKitSample/UITests/Capabilities/RootTabBarNavigable.swift @@ -13,7 +13,12 @@ extension RootTabBarNavigable { .init(name: "Second") } - var ghost: AXTabComponent { - .init(name: "No u") + var settings: AXTabComponent { + .init(name: "Settings") } + + var catalog: AXTabComponent { + .init(name: "Catalog") + } + } diff --git a/AXComponentKitSample/UITests/CatalogScreenTests.swift b/AXComponentKitSample/UITests/CatalogScreenTests.swift new file mode 100644 index 0000000..c2a5344 --- /dev/null +++ b/AXComponentKitSample/UITests/CatalogScreenTests.swift @@ -0,0 +1,49 @@ +import AXComponentKitTestSupport +import XCTest + +@MainActor +final class CatalogScreenTests: XCTestCase { + override func setUp() async throws { + XCUIApplication.automationLaunch() + try await FirstTabScreen.navigator.navigate(toTab: \.catalog) + } + + func testCatalogScreenExists() async throws { + try await CatalogScreen.exists(timeout: .seconds(15)) + } + + func testFeaturedCarouselExists() async throws { + try await CatalogScreen.element(\.featuredCarousel) + } + + func testCategoryListExists() async throws { + try await CatalogScreen.element(\.categoryList) + } + + func testAllCategoryCardsExist() async throws { + for category in Category.allCases { + try await CatalogScreen.element(\.categoryCard, value: category) + } + } + + func testFeaturedItemsExist() async throws { + let firstItem = CatalogScreen.assumedElement(\.featuredItem, value: 1) + XCTAssertTrue(firstItem.waitForExistence(timeout: 5)) + } + + func testCanNavigateToElectronicsCategory() async throws { + try await CatalogScreen.navigator.navigate(toCategory: .electronics) + try await CategoryDetailScreen.exists() + } + + func testCanNavigateToBooksCategory() async throws { + try await CatalogScreen.navigator.navigate(toCategory: .books) + try await CategoryDetailScreen.exists() + } + + func testCategoryDetailItemsExist() async throws { + try await CatalogScreen.navigator.navigate(toCategory: .clothing) + let firstItem = try await CategoryDetailScreen.element(\.item, value: 1) + XCTAssertTrue(firstItem.exists) + } +} diff --git a/AXComponentKitSample/UITests/DeepNavigationTests.swift b/AXComponentKitSample/UITests/DeepNavigationTests.swift new file mode 100644 index 0000000..a9591bf --- /dev/null +++ b/AXComponentKitSample/UITests/DeepNavigationTests.swift @@ -0,0 +1,76 @@ +import AXComponentKitTestSupport +import XCTest + +@MainActor +final class DeepNavigationTests: XCTestCase { + override func setUp() async throws { + XCUIApplication.automationLaunch() + try await FirstTabScreen.navigator.navigate(toTab: \.catalog) + } + + func testDeepNavigationChain() async throws { + // Level 1: Catalog tab + try await CatalogScreen.exists(timeout: .seconds(15)) + + // Level 2: Category detail + let categoryNav = try await CatalogScreen.navigator.navigate(toCategory: .electronics) + try await CategoryDetailScreen.exists() + + // Level 3: Item detail + let itemNav = try await categoryNav.navigate(toItem: 1) + try await ItemDetailScreen.exists() + + // Level 4: Share sheet (modal) + try await itemNav.navigateToShare() + try await ShareScreen.exists() + } + + func testDeepNavigationToScrolledItem() async throws { + let categoryNav = try await CatalogScreen.navigator.navigate(toCategory: .books) + + // Navigate to a deeply scrolled item + let itemNav = try await categoryNav.navigate(toItem: 30) + try await ItemDetailScreen.exists() + + // Verify item detail elements + try await ItemDetailScreen.element(\.titleLabel) + try await ItemDetailScreen.element(\.descriptionLabel) + try await ItemDetailScreen.element(\.shareButton) + + // Open and dismiss the share sheet + let shareNav = try await itemNav.navigateToShare() + try await ShareScreen.exists() + + try await ShareScreen.element(\.messageField) + try await ShareScreen.element(\.sendButton) + try await ShareScreen.element(\.dismissButton) + + try await shareNav.dismiss() + try await ItemDetailScreen.exists() + } + + func testModalDismissalReturnsToItemDetail() async throws { + try await CatalogScreen.navigator.navigate(toCategory: .clothing) + try await CategoryDetailScreen.navigator.navigate(toItem: 5) + + let shareNav = try await ItemDetailScreen.navigator.navigateToShare() + try await ShareScreen.exists(timeout: .seconds(15), "Share sheet should appear") + + try await shareNav.dismiss() + try await ItemDetailScreen.exists() + } + + func testAllTabsAccessible() async throws { + try await CatalogScreen.navigator.navigate(toTab: \.first) + try await FirstTabScreen.exists() + + try await FirstTabScreen.navigator.navigate(toTab: \.second) + try await SecondTabScreen.exists() + + try await SecondTabScreen.navigator.navigate(toTab: \.settings) + try await SettingsScreen.exists() + + try await SettingsScreen.navigator.navigate(toTab: \.catalog) + try await CatalogScreen.exists() + } +} diff --git a/AXComponentKitSample/UITests/FirstTabScreenTests.swift b/AXComponentKitSample/UITests/FirstTabScreenTests.swift index f5c8bb2..531e6f4 100644 --- a/AXComponentKitSample/UITests/FirstTabScreenTests.swift +++ b/AXComponentKitSample/UITests/FirstTabScreenTests.swift @@ -2,26 +2,17 @@ import AXComponentKitTestSupport import XCTest @MainActor -final class UITests: XCTestCase { - override func setUp(completion: @escaping (Error?) -> Void) { - setUp(completion: completion) { - XCUIApplication().launch() - } +final class FirstTabScreenTests: XCTestCase { + override func setUp() async throws { + XCUIApplication.automationLaunch() } func testFirstPageElementsExist() async throws { try await FirstTabScreen.exists() - try await FirstTabScreen.element(\.detailButton) - - let button = FirstTabScreen.assumedElement(\.detailButton) - - while !button.exists { - XCUIApplication().scroll(byDeltaX: 0, deltaY: 10) - } } func testCanNavigateToDetailScreen() async throws { - try await FirstTabScreen.navigateToDetailScreen() + try await FirstTabScreen.navigator.navigateToDetailScreen() } } diff --git a/AXComponentKitSample/UITests/Screens+TestSupport/CatalogScreen+Navigator.swift b/AXComponentKitSample/UITests/Screens+TestSupport/CatalogScreen+Navigator.swift new file mode 100644 index 0000000..0688c22 --- /dev/null +++ b/AXComponentKitSample/UITests/Screens+TestSupport/CatalogScreen+Navigator.swift @@ -0,0 +1,18 @@ +import AXComponentKit +import AXComponentKitTestSupport +import Foundation + +extension AXScreenNavigator where Source == CatalogScreen { + @discardableResult + func navigate( + toCategory category: Category, + file: StaticString = #file, + line: UInt = #line + ) async throws -> AXScreenNavigator { + try await navigate(to: CategoryDetailScreen.self, file: file, line: line) { screen in + try await scroll(.down, to: \.categoryCard, value: category, in: \.categoryList, file: file, line: line) + let card = try await screen.element(\.categoryCard, value: category, file: file, line: line) + card.tap() + } + } +} diff --git a/AXComponentKitSample/UITests/Screens+TestSupport/CatalogScreen+TestSupport.swift b/AXComponentKitSample/UITests/Screens+TestSupport/CatalogScreen+TestSupport.swift new file mode 100644 index 0000000..8a82fd8 --- /dev/null +++ b/AXComponentKitSample/UITests/Screens+TestSupport/CatalogScreen+TestSupport.swift @@ -0,0 +1,4 @@ +import AXComponentKitTestSupport +import Foundation + +extension CatalogScreen: RootTabBarNavigable {} diff --git a/AXComponentKitSample/UITests/Screens+TestSupport/CategoryDetailScreen+Navigator.swift b/AXComponentKitSample/UITests/Screens+TestSupport/CategoryDetailScreen+Navigator.swift new file mode 100644 index 0000000..4797928 --- /dev/null +++ b/AXComponentKitSample/UITests/Screens+TestSupport/CategoryDetailScreen+Navigator.swift @@ -0,0 +1,18 @@ +import AXComponentKit +import AXComponentKitTestSupport +import Foundation + +extension AXScreenNavigator where Source == CategoryDetailScreen { + @discardableResult + func navigate( + toItem index: Int, + file: StaticString = #file, + line: UInt = #line + ) async throws -> AXScreenNavigator { + try await navigate(to: ItemDetailScreen.self, file: file, line: line) { screen in + try await scroll(.down, to: \.item, value: index, in: \.itemList, file: file, line: line) + let row = try await screen.element(\.item, value: index, file: file, line: line) + row.tap() + } + } +} diff --git a/AXComponentKitSample/UITests/Screens+TestSupport/FirstTabScreen+Navigator.swift b/AXComponentKitSample/UITests/Screens+TestSupport/FirstTabScreen+Navigator.swift index 0b33353..6789590 100644 --- a/AXComponentKitSample/UITests/Screens+TestSupport/FirstTabScreen+Navigator.swift +++ b/AXComponentKitSample/UITests/Screens+TestSupport/FirstTabScreen+Navigator.swift @@ -2,24 +2,14 @@ import AXComponentKit import AXComponentKitTestSupport import Foundation -extension FirstTabScreen { - @discardableResult - static func navigateToDetailScreen( - file: StaticString = #file, - line: UInt = #line - ) async throws -> AXScreenNavigator { - try await Navigator().navigateToDetailScreen(file: file, line: line) - } -} - extension AXScreenNavigator where Source == FirstTabScreen { @discardableResult func navigateToDetailScreen( file: StaticString = #file, line: UInt = #line ) async throws -> AXScreenNavigator { - try await performNavigation(file: file, line: line) { screen in - try await screen.element(\.detailButton).tap() + try await navigate(file: file, line: line) { screen in + try await screen.tap(\.detailButton, file: file, line: line) } } } diff --git a/AXComponentKitSample/UITests/Screens+TestSupport/ItemDetailScreen+Navigator.swift b/AXComponentKitSample/UITests/Screens+TestSupport/ItemDetailScreen+Navigator.swift new file mode 100644 index 0000000..82480cb --- /dev/null +++ b/AXComponentKitSample/UITests/Screens+TestSupport/ItemDetailScreen+Navigator.swift @@ -0,0 +1,15 @@ +import AXComponentKit +import AXComponentKitTestSupport +import Foundation + +extension AXScreenNavigator where Source == ItemDetailScreen { + @discardableResult + func navigateToShare( + file: StaticString = #file, + line: UInt = #line + ) async throws -> AXScreenNavigator { + try await navigate(to: ShareScreen.self, file: file, line: line) { screen in + try await screen.tap(\.shareButton, file: file, line: line) + } + } +} diff --git a/AXComponentKitSample/UITests/Screens+TestSupport/ProfileScreen+Navigator.swift b/AXComponentKitSample/UITests/Screens+TestSupport/ProfileScreen+Navigator.swift new file mode 100644 index 0000000..2f52fa1 --- /dev/null +++ b/AXComponentKitSample/UITests/Screens+TestSupport/ProfileScreen+Navigator.swift @@ -0,0 +1,15 @@ +import AXComponentKit +import AXComponentKitTestSupport +import Foundation + +extension AXScreenNavigator where Source == ProfileScreen { + @discardableResult + func save( + file: StaticString = #file, + line: UInt = #line + ) async throws -> AXScreenNavigator { + try await navigate(to: ProfileScreen.self, file: file, line: line) { screen in + try await screen.tap(\.saveButton, file: file, line: line) + } + } +} diff --git a/AXComponentKitSample/UITests/Screens+TestSupport/ProfileScreen+TestSupport.swift b/AXComponentKitSample/UITests/Screens+TestSupport/ProfileScreen+TestSupport.swift new file mode 100644 index 0000000..b15177f --- /dev/null +++ b/AXComponentKitSample/UITests/Screens+TestSupport/ProfileScreen+TestSupport.swift @@ -0,0 +1,7 @@ +import AXComponentKit +import AXComponentKitTestSupport +import Foundation + +extension ProfileScreen: DismissibleScreen { + var dismissButton: AXComponent { closeButton } +} diff --git a/AXComponentKitSample/UITests/Screens+TestSupport/SecondTabScreen+Navigator.swift b/AXComponentKitSample/UITests/Screens+TestSupport/SecondTabScreen+Navigator.swift index 20af865..dee727c 100644 --- a/AXComponentKitSample/UITests/Screens+TestSupport/SecondTabScreen+Navigator.swift +++ b/AXComponentKitSample/UITests/Screens+TestSupport/SecondTabScreen+Navigator.swift @@ -1,17 +1,6 @@ import AXComponentKitTestSupport import Foundation -extension SecondTabScreen { - @discardableResult - static func navigate( - toItem ordinal: Int, - file: StaticString = #file, - line: UInt = #line - ) async throws -> AXScreenNavigator { - try await Navigator().navigate(toItem: ordinal, file: file, line: line) - } -} - extension AXScreenNavigator where Source == SecondTabScreen { @discardableResult func navigate( @@ -19,7 +8,7 @@ extension AXScreenNavigator where Source == SecondTabScreen { file: StaticString = #file, line: UInt = #line ) async throws -> AXScreenNavigator { - try await performNavigation(file: file, line: line) { screen in + try await navigate(file: file, line: line) { screen in try await scroll(to: \.rowItem, value: ordinal, in: \.table, file: file, line: line) let rowItem = try await screen.element(\.rowItem, value: ordinal, file: file, line: line) rowItem.tap() diff --git a/AXComponentKitSample/UITests/Screens+TestSupport/SettingsScreen+Navigator.swift b/AXComponentKitSample/UITests/Screens+TestSupport/SettingsScreen+Navigator.swift new file mode 100644 index 0000000..e203bc9 --- /dev/null +++ b/AXComponentKitSample/UITests/Screens+TestSupport/SettingsScreen+Navigator.swift @@ -0,0 +1,15 @@ +import AXComponentKit +import AXComponentKitTestSupport +import Foundation + +extension AXScreenNavigator where Source == SettingsScreen { + @discardableResult + func navigateToProfile( + file: StaticString = #file, + line: UInt = #line + ) async throws -> AXScreenNavigator { + try await navigate(to: ProfileScreen.self, file: file, line: line) { screen in + try await screen.tap(\.profileButton, file: file, line: line) + } + } +} diff --git a/AXComponentKitSample/UITests/Screens+TestSupport/SettingsScreen+TestSupport.swift b/AXComponentKitSample/UITests/Screens+TestSupport/SettingsScreen+TestSupport.swift new file mode 100644 index 0000000..1acc2c0 --- /dev/null +++ b/AXComponentKitSample/UITests/Screens+TestSupport/SettingsScreen+TestSupport.swift @@ -0,0 +1,4 @@ +import AXComponentKitTestSupport +import Foundation + +extension SettingsScreen: RootTabBarNavigable {} diff --git a/AXComponentKitSample/UITests/Screens+TestSupport/ShareScreen+TestSupport.swift b/AXComponentKitSample/UITests/Screens+TestSupport/ShareScreen+TestSupport.swift new file mode 100644 index 0000000..f78ca12 --- /dev/null +++ b/AXComponentKitSample/UITests/Screens+TestSupport/ShareScreen+TestSupport.swift @@ -0,0 +1,4 @@ +import AXComponentKitTestSupport +import Foundation + +extension ShareScreen: DismissibleScreen {} diff --git a/AXComponentKitSample/UITests/SecondTabScreenTests.swift b/AXComponentKitSample/UITests/SecondTabScreenTests.swift index c0d9168..955bb97 100644 --- a/AXComponentKitSample/UITests/SecondTabScreenTests.swift +++ b/AXComponentKitSample/UITests/SecondTabScreenTests.swift @@ -1,12 +1,11 @@ +import AXComponentKitTestSupport import XCTest @MainActor final class SecondTabScreenTests: XCTestCase { - override func setUp(completion: @escaping (Error?) -> Void) { - setUp(completion: completion) { - XCUIApplication().launch() - try await FirstTabScreen.navigate(toTab: \.second) - } + override func setUp() async throws { + XCUIApplication.automationLaunch() + try await FirstTabScreen.navigator.navigate(toTab: \.second) } func testCanTapSpecificRow() async throws { @@ -15,10 +14,10 @@ final class SecondTabScreenTests: XCTestCase { } func testCanNavigateToDetailScreen() async throws { - try await SecondTabScreen.navigate(toItem: 3) + try await SecondTabScreen.navigator.navigate(toItem: 3) } func testCanScrollDownAndNavigate() async throws { - try await SecondTabScreen.navigate(toItem: 80) + try await SecondTabScreen.navigator.navigate(toItem: 80) } } diff --git a/AXComponentKitSample/UITests/SettingsScreenTests.swift b/AXComponentKitSample/UITests/SettingsScreenTests.swift new file mode 100644 index 0000000..9127f3d --- /dev/null +++ b/AXComponentKitSample/UITests/SettingsScreenTests.swift @@ -0,0 +1,51 @@ +import AXComponentKitTestSupport +import XCTest + +@MainActor +final class SettingsScreenTests: XCTestCase { + override func setUp() async throws { + XCUIApplication.automationLaunch() + try await FirstTabScreen.navigator.navigate(toTab: \.settings) + } + + func testSettingsScreenExists() async throws { + try await SettingsScreen.exists(timeout: .seconds(15)) + } + + func testAllTogglesExist() async throws { + try await SettingsScreen.element(\.notificationsToggle) + try await SettingsScreen.element(\.darkModeToggle) + try await SettingsScreen.element(\.locationToggle) + try await SettingsScreen.element(\.analyticsToggle) + } + + func testStaticComponentsExist() async throws { + try await SettingsScreen.element(\.versionLabel) + try await SettingsScreen.element(\.supportEmail) + try await SettingsScreen.element(\.privacyButton) + try await SettingsScreen.element(\.termsButton) + try await SettingsScreen.element(\.profileButton) + } + + func testScrollViewExists() async throws { + try await SettingsScreen.element(\.settingsList) + } + + func testCanNavigateToProfile() async throws { + try await SettingsScreen.navigator.navigateToProfile() + try await ProfileScreen.exists() + } + + func testProfileScreenHasFields() async throws { + try await SettingsScreen.navigator.navigateToProfile() + try await ProfileScreen.element(\.profileField, value: "name") + try await ProfileScreen.element(\.profileField, value: "email") + try await ProfileScreen.element(\.profileField, value: "phone") + } + + func testProfileCanBeDismissed() async throws { + let profileNavigator = try await SettingsScreen.navigator.navigateToProfile() + try await profileNavigator.dismiss() + try await SettingsScreen.exists() + } +} From f0d50dc48453d180c0b1defcf1b1a06011e150c5 Mon Sep 17 00:00:00 2001 From: Chris Stroud Date: Wed, 20 May 2026 12:25:45 -0400 Subject: [PATCH 3/8] Update documentation for all new APIs and modernization changes - Rewrite README with installation, quick start, animation suppression, and dynamic component sections - Document optional-value and prefixed automationComponent overloads in IntegratingWithViews - Document firstElement(anyOf:) and tapFirst(anyOf:) in WritingYourFirstTest - Update navigator documentation for static .navigator property and navigate() rename - Refresh Overview, ScreenModels, and CustomNavigatorOperations docc articles --- README.md | 220 +++++++++++++++++- .../IntegratingWithViews.md | 66 ++++++ .../AXComponentKit.docc/Overview.md | 30 ++- .../AXComponentKit.docc/ScreenModels.md | 30 ++- .../CustomNavigatorOperations.md | 47 +--- .../NavigatorFundamentals.md | 37 ++- .../WritingYourFirstTest.md | 42 +++- 7 files changed, 398 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 91e8e75..967fc76 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,221 @@ # AXComponentKit -See documentation: +A modular, protocol-oriented UI testing framework for iOS that works in concert with XCTest. AXComponentKit provides typed screen models, composable navigation, and element querying abstractions that make UI tests more reliable, readable, and maintainable. -[AXComponentKit](https://willowtreeapps.github.io/AXComponentKit/framework/documentation/axcomponentkit/) +## Features -[AXComponentKitTestSupport](https://willowtreeapps.github.io/AXComponentKit/testing/documentation/axcomponentkittestsupport/) +- **Screen models** — define screens as lightweight structs with typed component properties +- **Composable navigation** — chain navigators with compile-time safety across screen boundaries +- **Element querying** — type-safe element lookup with guaranteed-existence, assumed-existence, and first-match-by-prefix variants +- **Tab bar navigation** — protocol-based tab switching with `AXTabBarNavigable` +- **Scroll-to-element** — directional scroll helpers with timeout and failure reporting +- **Animation suppression** — automatic UIKit, Core Animation, and SwiftUI animation disabling for test runs +- **`@AXScreen` macro** — optional syntactic sugar that auto-generates `screenIdentifier` from type names +- **Swift 6 ready** — full strict concurrency compliance + +## Installation + +AXComponentKit is distributed as a Swift Package with three products: + +```swift +dependencies: [ + .package(url: "https://github.com/willowtreeapps/AXComponentKit", from: "2.0.0"), +] +``` + +| Product | Purpose | Link to | +|---------|---------|---------| +| `AXComponentKit` | Screen models, components, view modifiers | App target | +| `AXComponentKitTestSupport` | Navigators, element queries, test helpers | UI test target | +| `AXComponentKitMacroSupport` | `@AXScreen` macro (optional, adds swift-syntax) | App target | + +## Quick Start + +### 1. Define a screen model + +```swift +import AXComponentKit +import AXComponentKitMacroSupport // optional, for @AXScreen + +@AXScreen +struct LoginScreen { + let usernameField: AXComponent = "login-username-field" + let passwordField: AXComponent = "login-password-field" + let submitButton: AXComponent = "login-submit-button" +} +``` + +Without the macro: + +```swift +struct LoginScreen: AXScreen { + static let screenIdentifier = "login-screen" + let usernameField: AXComponent = "login-username-field" + let passwordField: AXComponent = "login-password-field" + let submitButton: AXComponent = "login-submit-button" +} +``` + +### 2. Apply modifiers in your views + +```swift +struct LoginView: View { + var body: some View { + VStack { + TextField("Username", text: $username) + .automationComponent(\LoginScreen.usernameField) + SecureField("Password", text: $password) + .automationComponent(\LoginScreen.passwordField) + Button("Sign In") { login() } + .automationComponent(\LoginScreen.submitButton) + } + .automationScreen(LoginScreen.self) + } +} +``` + +### 3. Write navigator extensions + +```swift +import AXComponentKitTestSupport + +extension AXScreenNavigator where Source == LoginScreen { + @discardableResult + func login( + file: StaticString = #file, + line: UInt = #line + ) async throws -> AXScreenNavigator { + try await navigate(file: file, line: line) { screen in + try await screen.tap(\.submitButton) + } + } +} +``` + +### 4. Write tests + +```swift +@MainActor +final class LoginTests: XCTestCase { + override func setUp() async throws { + XCUIApplication.automationLaunch() + } + + func testCanLogin() async throws { + try await LoginScreen.exists() + try await LoginScreen.navigator.login() + try await HomeScreen.exists() + } +} +``` + +## Animation Suppression + +AXComponentKit automatically suppresses animations during test runs to improve speed and reliability. + +**App side** — add one modifier at your root view: + +```swift +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .automationOptimized() + } + } +} +``` + +For UIKit apps, call from your AppDelegate: + +```swift +AXAutomation.suppressAnimationsIfNeeded() +``` + +**Test side** — use `automationLaunch()` instead of `launch()`: + +```swift +override func setUp() async throws { + XCUIApplication.automationLaunch() +} +``` + +This injects a launch argument that the app-side modifier detects. In production (without the argument), both APIs are no-ops with zero runtime cost. + +## Dynamic Components + +For elements identified at runtime (list rows, category cards, etc.), use `AXDynamicComponent`: + +```swift +@AXScreen +struct CatalogScreen { + let categoryCard: AXDynamicComponent = "catalog-category" + let itemRow: AXDynamicComponent = "catalog-item" +} +``` + +Query them with a value: + +```swift +try await CatalogScreen.element(\.categoryCard, value: "electronics") +try await CatalogScreen.element(\.itemRow, value: 42) +``` + +When the value may be nil (e.g. during loading states), pass an optional to suppress the identifier entirely: + +```swift +Text(item.name) + .automationComponent(\CatalogScreen.categoryCard, value: item.slug) +``` + +If you need to distinguish elements that share a component definition, supply a custom prefix: + +```swift +Text(item.name) + .automationComponent(\CatalogScreen.categoryCard, value: item.slug, prefix: "featured") +// identifier: "featured-catalog-category_electronics" +``` + +When the exact dynamic value isn't known at test time, query for the first matching element by prefix: + +```swift +let card = try await CatalogScreen.firstElement(anyOf: \.categoryCard) +card.tap() + +// Or as a single call: +try await CatalogScreen.tapFirst(anyOf: \.categoryCard) +``` + +Custom types can conform to `AXIdentifierConvertible` (or its refinement `AXDynamicValue`) for use as dynamic values. + +## Capability Protocols + +Share components across screens using protocol composition: + +```swift +protocol DismissibleScreen: AXScreen { + var dismissButton: AXComponent { get } +} + +extension AXScreenNavigator where Source: DismissibleScreen { + func dismiss() async throws { + try await Source.tap(\.dismissButton) + } +} +``` + +## Documentation + +- [AXComponentKit](https://willowtreeapps.github.io/AXComponentKit/framework/documentation/axcomponentkit/) — app-side API reference +- [AXComponentKitTestSupport](https://willowtreeapps.github.io/AXComponentKit/testing/documentation/axcomponentkittestsupport/) — test-side API reference + +## Requirements + +- iOS 16+ / macOS 13+ +- Swift 6.0+ +- Xcode 16+ + +## License + +See [LICENSE](LICENSE) for details. diff --git a/Sources/AXComponentKit/AXComponentKit.docc/IntegratingWithViews.md b/Sources/AXComponentKit/AXComponentKit.docc/IntegratingWithViews.md index db01c9b..1906b51 100644 --- a/Sources/AXComponentKit/AXComponentKit.docc/IntegratingWithViews.md +++ b/Sources/AXComponentKit/AXComponentKit.docc/IntegratingWithViews.md @@ -73,6 +73,34 @@ struct SecondTabView: View { } ``` +#### Optional Dynamic Values + +When a dynamic value may be `nil` — for example, during a loading or placeholder state — use the optional-value overload. When the value is `nil`, no accessibility identifier is applied and the element is invisible to automation queries: + +```swift +struct SecondTabView: View { + + let items: [Item] // Item.id may be nil during loading + + var body: some View { + List(items) { item in + Text(item.title) + .automationComponent(\SecondTabScreen.rowItem, value: item.id) + } + } +} +``` + +#### Prefixed Dynamic Components + +To distinguish elements that share a component definition but represent a different semantic state, supply a custom prefix. The identifier becomes `"{prefix}-{componentPrefix}_{value}"`: + +```swift +Text(item.title) + .automationComponent(\SecondTabScreen.rowItem, value: item.index, prefix: "featured") +// identifier: "featured-second-tab-dynamic-row_3" +``` + #### Scrollview Components Adding an additional line to the example from above, we can declare the scroll view that houses all row elements. @@ -91,3 +119,41 @@ struct SecondTabView: View { } } ``` + +## Animation Suppression + +UI tests are significantly more reliable when animations are disabled. AXComponentKit provides first-class support for this that activates only when the automation runner is present — it is a no-op in production builds. + +### SwiftUI Apps + +Apply the `.automationOptimized()` modifier at your app's root view. It disables SwiftUI transaction animations and calls into `AnimationSuppressor` to handle UIKit and Core Animation layers as well. + +```swift +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .automationOptimized() + } + } +} +``` + +### UIKit and Hybrid Apps + +For apps with a UIKit `AppDelegate` (or a hybrid UIKit+SwiftUI app where the root window is managed by UIKit), call `AXAutomation.suppressAnimationsIfNeeded()` from `application(_:didFinishLaunchingWithOptions:)`: + +```swift +func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? +) -> Bool { + Task { @MainActor in + AXAutomation.suppressAnimationsIfNeeded() + } + return true +} +``` + +> Note: `suppressAnimationsIfNeeded()` is `@MainActor`-isolated. Call it from a `Task { @MainActor in … }` block if your launch method is not already on the main actor. It disables `UIView` animations globally and sets `CALayer.speed = 100` on every new window as it becomes visible, which collapses Core Animation durations to near-zero. diff --git a/Sources/AXComponentKit/AXComponentKit.docc/Overview.md b/Sources/AXComponentKit/AXComponentKit.docc/Overview.md index 00d4859..9124439 100644 --- a/Sources/AXComponentKit/AXComponentKit.docc/Overview.md +++ b/Sources/AXComponentKit/AXComponentKit.docc/Overview.md @@ -4,7 +4,12 @@ Modular UI testing framework abstraction that works in concert with XCTest ## Overview -TODO +AXComponentKit provides a type-safe, composable layer on top of XCTest UI automation. Rather than littering tests with raw string-based accessibility identifier lookups, AXComponentKit lets you declare your screens and their interactive components as Swift types. The test runner then uses those types to locate elements, verify screen transitions, and surface actionable errors when expectations aren't met. + +The framework is split into two targets: + +- **AXComponentKit** — ships with your app target. Contains the `@AXScreen` macro, the `AXScreen` protocol, component types (`AXComponent`, `AXDynamicComponent`, `AXScrollView`), and view modifiers that wire components into the accessibility tree. +- **AXComponentKitTestSupport** — added to your UI test target only. Contains `AXScreenNavigator`, element-querying helpers, and the composable navigation primitives. #### Getting Started @@ -16,5 +21,26 @@ TODO #### Adding To Your Project -TODO: Explain how to pull in as a Swift Package +Add AXComponentKit via Swift Package Manager. In Xcode, choose **File › Add Package Dependencies…** and enter the repository URL. Then: + +1. Add **AXComponentKit** to your app target. +2. Add **AXComponentKitTestSupport** to your UI test target only — never to the app target, as it links against XCTest. + +Or, in your `Package.swift`: + +```swift +dependencies: [ + .package(url: "https://github.com//AXComponentKit.git", from: "1.0.0"), +], +targets: [ + .target( + name: "MyApp", + dependencies: ["AXComponentKit"] + ), + .testTarget( + name: "MyAppUITests", + dependencies: ["AXComponentKitTestSupport"] + ), +] +``` diff --git a/Sources/AXComponentKit/AXComponentKit.docc/ScreenModels.md b/Sources/AXComponentKit/AXComponentKit.docc/ScreenModels.md index 631bad2..8546f0a 100644 --- a/Sources/AXComponentKit/AXComponentKit.docc/ScreenModels.md +++ b/Sources/AXComponentKit/AXComponentKit.docc/ScreenModels.md @@ -10,12 +10,36 @@ The Screen Model is a container for components that exist within "a screen full" Screen models in `AXComponentKit` rely heavily on protocols. For something to be "a screen," the only requirement is that it conforms to ``AXScreen``. Additional protocols can (and should!) be created as part of your test target that allow your screen to adopt additional capabilities. More on that _here_ (link). -Here is how we define the initial screen in our sample app, without any of its components, and without satisfying the requirements of ``AXScreen``: +### Using the `@AXScreen` Macro + +The recommended way to declare a screen model is with the `@AXScreen` macro. It synthesizes the ``AXScreen`` conformance and a `screenIdentifier` automatically by converting your struct name from PascalCase to kebab-case: + +```swift +@AXScreen +struct FirstTabScreen { + // screenIdentifier = "first-tab-screen" (auto-generated) +} +``` + +You can also supply a custom identifier if the auto-derived one doesn't suit your needs: + +```swift +@AXScreen(identifier: "my-custom-screen-id") +struct FirstTabScreen {} +``` + +> Note: `@AXScreen` can only be applied to a `struct`. If you need class-based semantics, conform to ``AXScreen`` manually. + +### Manual Conformance + +If you prefer not to use the macro, you can conform to ``AXScreen`` directly. Here is how we define the initial screen in our sample app without any of its components: + ```swift struct FirstTabScreen: AXScreen { // TODO: Fulfill protocol requirements } ``` + > Note: ``AXScreen`` requires an `init()` initializer, which is synthesized by the compiler automatically if all properties have an initial default value. All screen models in the sample project rely on the compiler to provide this initializer. ### Screen Identifiers @@ -24,7 +48,7 @@ From a test automation perspective, being able to ask the question "what screen To facilitate this, every screen model must provide a `screenIdentifier` that should uniquely identify that screen. As of now, these are static identifiers and there is not a mechanism for dynamic variation in the spirit of ``AXComponent``. -Fulfilling this requirement for our example screen looks something like this: +Fulfilling this requirement manually for our example screen looks something like this: ```swift struct FirstTabScreen: AXScreen { static let screenIdentifier = "first-tab-screen" @@ -83,7 +107,7 @@ By default, ``AXDynamicComponent`` has support for Swift's signed and unsigned i #### Custom Types -``AXDynamicComponent`` can also support identifiers that are generated based on custom types not natively supported by `AXComponentKit`. Custom types that conform to the ``AXDynamicValue`` protocol can work just as effortlessly as the default types listed above. +``AXDynamicComponent`` can also support identifiers that are generated based on custom types not natively supported by `AXComponentKit`. The underlying requirement is conformance to ``AXIdentifierConvertible``, which has a single requirement: an `automationIdentifier: String` property. The higher-level ``AXDynamicValue`` protocol refines ``AXIdentifierConvertible`` and is what custom types should conform to — it is the standard extension point for user-defined types. Here is an example of a custom type being defined and used in a contrived example: diff --git a/Sources/AXComponentKitTestSupport/AXComponentKitTestSupport.docc/CustomNavigatorOperations.md b/Sources/AXComponentKitTestSupport/AXComponentKitTestSupport.docc/CustomNavigatorOperations.md index 7ab4302..af78eb3 100644 --- a/Sources/AXComponentKitTestSupport/AXComponentKitTestSupport.docc/CustomNavigatorOperations.md +++ b/Sources/AXComponentKitTestSupport/AXComponentKitTestSupport.docc/CustomNavigatorOperations.md @@ -12,7 +12,7 @@ extension AXScreenNavigator where Source == SecondTabScreen { file: StaticString = #file, line: UInt = #line ) async throws -> AXScreenNavigator { - try await performNavigation(file: file, line: line) { screen in + try await navigate(file: file, line: line) { screen in try await scroll(to: \.rowItem, value: ordinal, in: \.table, file: file, line: line) let rowItem = try await screen.element(\.rowItem, value: ordinal, file: file, line: line) rowItem.tap() @@ -24,53 +24,28 @@ Since navigation operations are atomic, we are assured that we're on the source These kinds of guarantees can be difficult. What if the screen is somehow scrolled down already when this executes? As written, we don't have a way to deal with that. Most of the time we simply don't have enough information to know which direction we should scroll based on what is currently visible. Perhaps a future version of the framework will allow us to be more intelligent about this, but for now we can only move in a prescribed direction. -### Static Variants +### Calling Navigator Operations -The actual version of our example function has an extra method with the same signature in a different extension. We _strongly_ recommend that you follow this pattern when implementing navigation operations to make your code as clean and clear at the call-site as possible. This additional variant is an extension on the source screen model's type, adding a static function of the same name: +Every navigator operation is defined on `AXScreenNavigator` extensions, so you always have access to them through a navigator instance. `AXScreen` ships a `navigator` computed property that creates a navigator for that screen type, so you can invoke an operation directly from the screen type without boilerplate: ```swift -extension SecondTabScreen { - @discardableResult - static func navigate( - toItem ordinal: Int, - file: StaticString = #file, - line: UInt = #line - ) async throws -> AXScreenNavigator { - try await Navigator().navigate(toItem: ordinal, file: file, line: line) - } -} +// Full form — explicit navigator +SecondTabScreen.Navigator().navigate(toItem: 3) -extension AXScreenNavigator where Source == SecondTabScreen { - @discardableResult - func navigate( - toItem ordinal: Int, - file: StaticString = #file, - line: UInt = #line - ) async throws -> AXScreenNavigator { - try await performNavigation(file: file, line: line) { screen in - try await scroll(to: \.rowItem, value: ordinal, in: \.table, file: file, line: line) - let rowItem = try await screen.element(\.rowItem, value: ordinal, file: file, line: line) - rowItem.tap() - } - } -} +// Preferred short form — using the built-in navigator property +SecondTabScreen.navigator.navigate(toItem: 3) ``` -Here is the before and after of having this extra extension: - -```swift - SecondTabScreen.Navigator().navigate(toItem: 3) // Before - SecondTabScreen.navigate(toItem: 3) // After -``` +Both are valid; the short form is preferred at call sites for clarity. ### Using The Navigator Extension -With a navigation operation that takes care of the scrolling behavior in place, we can call `navigate(toItem)` on `SecondTabScreen` like so. +With a navigation operation that takes care of the scrolling behavior in place, we can call `navigate(toItem:)` via `SecondTabScreen.navigator` like so. ```swift func testCanNavigateToDetailScreen() async throws { - try await SecondTabScreen.navigate(toItem: 3) + try await SecondTabScreen.navigator.navigate(toItem: 3) } ``` @@ -78,6 +53,6 @@ A test that _must_ scroll to find the specified element would be essentially ide ```swift func testCanScrollDownAndNavigate() async throws { - try await SecondTabScreen.navigate(toItem: 80) + try await SecondTabScreen.navigator.navigate(toItem: 80) } ``` diff --git a/Sources/AXComponentKitTestSupport/AXComponentKitTestSupport.docc/NavigatorFundamentals.md b/Sources/AXComponentKitTestSupport/AXComponentKitTestSupport.docc/NavigatorFundamentals.md index b36f57d..8a6f231 100644 --- a/Sources/AXComponentKitTestSupport/AXComponentKitTestSupport.docc/NavigatorFundamentals.md +++ b/Sources/AXComponentKitTestSupport/AXComponentKitTestSupport.docc/NavigatorFundamentals.md @@ -1,26 +1,26 @@ # Navigator Fundamentals How tests move from one place to another -``AXScreenNavigator`` is the foundational building block for handling navigation in a UI automation test. The navigator itself is generic and takes an `AXScreenModel`, which acts as its source. Declaring a new navigator for the first tab screen in our sample app looks like this: +``AXScreenNavigator`` is the foundational building block for handling navigation in a UI automation test. The navigator itself is generic and takes an ``AXScreen``, which acts as its source. Declaring a new navigator for the first tab screen in our sample app looks like this: ```swift let navigator = AXScreenNavigator() ``` -We usually read this as "A navigator starting at FirstTabScreen." Alternatively, `AXComponentKit` includes a typealias on `AXScreen` that makes it possible to get the navigator for a given screen like this: +We usually read this as "A navigator starting at FirstTabScreen." Alternatively, `AXComponentKit` includes a `navigator` computed property on `AXScreen` that returns a navigator for that screen type — this is the preferred form: ```swift -let navigator = FirstTabScreen.Navigator() // AXScreenNavigator +let navigator = FirstTabScreen.navigator // AXScreenNavigator ``` -Most places create navigator instances in this way because it's more concise, but both are valid constructions that mean the same thing. +Most places create navigator instances in this way because it's more concise. The explicit `Navigator()` constructor is also valid and means the same thing. A navigator allows the test runner to begin at the source screen and perform some operation to move to a destination screen. Here's an example from our sample app that starts at the first tab's screen and navigates to the second tab: ```swift -try await FirstTabScreen.navigate(toTab: \.second) +try await FirstTabScreen.navigator.navigate(toTab: \.second) ``` Every operation that a navigator can perform returns a new navigator instance for the destination screen. This is where navigator composition becomes really powerful, as seen here, where we move to the second tab and then immediately navigate to the third item in the list: ```swift -try await FirstTabScreen.navigate(toTab: \.second) // AXScreenNavigator +try await FirstTabScreen.navigator.navigate(toTab: \.second) // AXScreenNavigator .navigate(toItem: 3) // AXScreenNavigator ``` @@ -28,20 +28,13 @@ try await FirstTabScreen.navigate(toTab: \.second) // AXScreenNavigator { +extension AXScreenNavigator where Source == SecondTabScreen { ... } ``` -> Note: For the sake of compatability and better compile times, we use pre-5.7 Swift syntax. > Warning: When adding new `AXScreenNavigator` extensions, be sure they are being added to your UI testing target ONLY. `AXScreenNavigator` relies on the `XCTest` framework, which is not available for standard application code. @@ -57,7 +50,7 @@ extension AXScreenNavigator where Source == SecondTabScreen { file: StaticString = #file, /*3️⃣*/ line: UInt = #line ) async /*4️⃣*/ throws /*5️⃣*/ -> AXScreenNavigator { - try await performNavigation(file: file, line: line) /*6️⃣*/ { _ in + try await navigate(file: file, line: line) /*6️⃣*/ { _ in // TODO: Interact with the app } } @@ -66,9 +59,9 @@ extension AXScreenNavigator where Source == SecondTabScreen { - 1️⃣ The `@discardableResult` allows us to invoke a chain of navigators without Swift forcing us to do something with the last navigator in the chain: ```swift -let nav = SecondTabScreen.Navigator() -_ = nav.performNavigation(toItem: 3) // No @discardableResult -nav.performNavigation(toItem: 3) // ✅ +let nav = SecondTabScreen.navigator +_ = nav.navigate(toItem: 3) // No @discardableResult +nav.navigate(toItem: 3) // ✅ ``` - 2️⃣ The element we're going to navigate to is an `AXDynamicComponent`, so we need to know the value in order to locate the correct row and tap on it. We'll ignore this for now since we're going to focus on the actual test interactions down below. @@ -85,7 +78,7 @@ nav.performNavigation(toItem: 3) // ✅ > Important: Due to a bug in the XCTest framework, async tests within an XCTestCase do not support `continueAfterFailure = false`. Once this bug is fixed in a future version of Xcode, AXComponentKit will likely move away from making functions throw and reduce duplicated diagnostics. -- 6️⃣ `performNavigation(...)` should be present in all navigator operations because it handles the assertions around source/destination screen existence. The function returns the destination navigator when that screen appears, and Swift's implicit return values + type inference make calling the function more succinct than it would be if we wrote out everything fully: +- 6️⃣ `navigate(...)` should be present in all navigator operations because it handles the assertions around source/destination screen existence. The function returns the destination navigator when that screen appears, and Swift's implicit return values + type inference make calling the function more succinct than it would be if we wrote out everything fully: ```swift extension AXScreenNavigator where Source == SecondTabScreen { @discardableResult @@ -94,7 +87,7 @@ extension AXScreenNavigator where Source == SecondTabScreen { file: StaticString = #file, line: UInt = #line ) async throws -> AXScreenNavigator { - return try await performNavigation(to: DetailScreen.self, file: file, line: line) { _ in + return try await navigate(to: DetailScreen.self, file: file, line: line) { _ in // TODO: Interact with the app } } @@ -112,7 +105,7 @@ extension AXScreenNavigator where Source == <#Source#> { file: StaticString = #file, line: UInt = #line ) async throws -> AXScreenNavigator<<#Destination#>> { - try await performNavigation(file: file, line: line) { screen in + try await navigate(file: file, line: line) { screen in <#Body#> } } diff --git a/Sources/AXComponentKitTestSupport/AXComponentKitTestSupport.docc/WritingYourFirstTest.md b/Sources/AXComponentKitTestSupport/AXComponentKitTestSupport.docc/WritingYourFirstTest.md index 12263e2..0b83498 100644 --- a/Sources/AXComponentKitTestSupport/AXComponentKitTestSupport.docc/WritingYourFirstTest.md +++ b/Sources/AXComponentKitTestSupport/AXComponentKitTestSupport.docc/WritingYourFirstTest.md @@ -8,24 +8,22 @@ There is a good amount of work involved in setting up `AXComponentKit`, but the ### Create a Test Case -Test classes are typically constructed around the screen where the tests they contain start, however, more complex screens may be represented in more than one test class with the tests in each of those classes being defined by the functionality that they are testing. The following example references a simple screen in the test app, so one test class will suffice here. When setting up a new test class, it is helpful to override the class' `setup` method, which is called at the beginning of each test with any code required to navigate to the screen being tested in the class. +Test classes are typically constructed around the screen where the tests they contain start, however, more complex screens may be represented in more than one test class with the tests in each of those classes being defined by the functionality that they are testing. The following example references a simple screen in the test app, so one test class will suffice here. When setting up a new test class, it is helpful to override the async `setUp` method, which is called at the beginning of each test with any code required to navigate to the screen being tested in the class. ```swift @MainActor final class SecondTabScreenTests: XCTestCase { - override func setUp(completion: @escaping (Error?) -> Void) { - setUp(completion: completion) { - XCUIApplication().launch() - try await FirstTabScreen.navigate(toTab: \.second) - } + override func setUp() async throws { + XCUIApplication.automationLaunch() + try await FirstTabScreen.navigator.navigate(toTab: \.second) } ``` -In this example `setup` launches the app, and because this class is for tests of `SecondTabScreen`, the app will navigate immediately from the first tab to the second. This code only has to be written once rather than at the beginning of each test. +In this example `setUp` launches the app using `XCUIApplication.automationLaunch()` (which sets the AXComponentKit runner flag and calls `launch()`), and because this class is for tests of `SecondTabScreen`, the app will navigate immediately from the first tab to the second. This code only has to be written once rather than at the beginning of each test. #### Writing a Test -`SecondTabScreen` contains a numbered list of rows in a `ScrollView`. It is important to note that each of these rows has an identifier of type `AXDynamicComponent`, meaning that it has a unique identifier based on its row index. With this starting point, adding a test that taps the 4th item in a row looks like this. +`SecondTabScreen` contains a numbered list of rows in a `ScrollView`. It is important to note that each of these rows has an identifier of type `AXDynamicComponent`, meaning that it has a unique identifier based on its row index. With this starting point, adding a test that taps the 4th item in a row looks like this. ```swift final class SecondTabScreenTests: XCTestCase { @@ -37,8 +35,36 @@ final class SecondTabScreenTests: XCTestCase { } ``` +> Tip: `AXScreenModel` also provides a `tap()` convenience method that combines element querying and tapping in a single call: +> ```swift +> try await SecondTabScreen.tap(\.rowItem, value: 3) +> ``` + >Note: This query is marked with `try` and `await` to ensure safety by waiting for the the row to exist and failing the test before the tap if it does not. +#### Querying When the Value Is Unknown + +Sometimes the exact dynamic value isn't known at test time — for example, tapping the first item in a list of server-returned data. Use `firstElement(anyOf:)` to find the first element whose identifier begins with the component's prefix, regardless of the value suffix: + +```swift +func testCanTapFirstRow() async throws { + let row = try await SecondTabScreen.firstElement(anyOf: \.rowItem) + row.tap() +} +``` + +Or combine the query and tap into a single call: + +```swift +func testCanTapFirstRow() async throws { + try await SecondTabScreen.tapFirst(anyOf: \.rowItem) +} +``` + +Both methods wait for the element to exist before interacting with it, following the same safety guarantees as the value-based queries. + +#### Scrolling to Off-Screen Elements + This test queries the 4th row and then taps it to navigate to a detail screen, but this logic assumes that the 4th item in the list is visible, which is not necessarily true if the view scrolls. To reduce potential flakiness in this test that could occur as the screen grows and scales, we can make it safer by adding in the ability for the app to scroll if the element _might not be_ visible. If the element is visible initially, it will be returned without scrolling. To address this, see how we use this example to create a custom navigator extension in From 459cd58a4f45e263eb8379a8ea160be495b09eaa Mon Sep 17 00:00:00 2001 From: Chris Stroud Date: Wed, 20 May 2026 12:26:40 -0400 Subject: [PATCH 4/8] Bump version references to 2.0.0 --- Sources/AXComponentKit/AXComponentKit.docc/Overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AXComponentKit/AXComponentKit.docc/Overview.md b/Sources/AXComponentKit/AXComponentKit.docc/Overview.md index 9124439..e6b986c 100644 --- a/Sources/AXComponentKit/AXComponentKit.docc/Overview.md +++ b/Sources/AXComponentKit/AXComponentKit.docc/Overview.md @@ -30,7 +30,7 @@ Or, in your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com//AXComponentKit.git", from: "1.0.0"), + .package(url: "https://github.com/willowtreeapps/AXComponentKit.git", from: "2.0.0"), ], targets: [ .target( From 08a9be20da21acc87518f59df3e13120978bf0b1 Mon Sep 17 00:00:00 2001 From: Chris Stroud Date: Wed, 20 May 2026 14:52:03 -0400 Subject: [PATCH 5/8] Address review feedback: add explicit Foundation import, guard against interpolated macro identifiers - Add explicit `import Foundation` to AXScreenNavigator.swift instead of relying on transitive import from XCTest for NSLog availability - Add `segments.count == 1` guard in @AXScreen macro's extractIdentifier to fall back to auto-derived name instead of silently truncating interpolated string literals --- Sources/AXComponentKitMacros/AXScreenMacro.swift | 1 + .../AXComponentKitTestSupport/Navigation/AXScreenNavigator.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Sources/AXComponentKitMacros/AXScreenMacro.swift b/Sources/AXComponentKitMacros/AXScreenMacro.swift index a909f09..4a76db3 100644 --- a/Sources/AXComponentKitMacros/AXScreenMacro.swift +++ b/Sources/AXComponentKitMacros/AXScreenMacro.swift @@ -53,6 +53,7 @@ public struct AXScreenMacro: MemberMacro, ExtensionMacro { for argument in arguments { if argument.label?.trimmedDescription == "identifier", let stringLiteral = argument.expression.as(StringLiteralExprSyntax.self), + stringLiteral.segments.count == 1, let segment = stringLiteral.segments.first?.as(StringSegmentSyntax.self) { return segment.content.text } diff --git a/Sources/AXComponentKitTestSupport/Navigation/AXScreenNavigator.swift b/Sources/AXComponentKitTestSupport/Navigation/AXScreenNavigator.swift index a84f387..a83b62e 100644 --- a/Sources/AXComponentKitTestSupport/Navigation/AXScreenNavigator.swift +++ b/Sources/AXComponentKitTestSupport/Navigation/AXScreenNavigator.swift @@ -1,4 +1,5 @@ import AXComponentKit +import Foundation import XCTest public extension AXScreen { From 7328ff752e1ec1b79dd4f739e890afb080e5b0a5 Mon Sep 17 00:00:00 2001 From: Chris Stroud Date: Wed, 20 May 2026 15:07:57 -0400 Subject: [PATCH 6/8] Fix prefixed-element query mismatch and @AXScreen macro redundancy guards - Add firstElement(anyOf:prefix:) and tapFirst(anyOf:prefix:) overloads that mirror the writer-side automationComponent(_:value:prefix:) API, so prefixed identifiers are not silently missed by BEGINSWITH queries - Guard ExtensionMacro against emitting redundant AXScreen conformance when the type already conforms (check protocols.isEmpty) - Guard MemberMacro against emitting duplicate screenIdentifier when the struct already declares one manually --- .../AXComponentKitMacros/AXScreenMacro.swift | 9 ++ .../AXScreenModel+FirstDynamicElement.swift | 82 +++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/Sources/AXComponentKitMacros/AXScreenMacro.swift b/Sources/AXComponentKitMacros/AXScreenMacro.swift index 4a76db3..c0e59c6 100644 --- a/Sources/AXComponentKitMacros/AXScreenMacro.swift +++ b/Sources/AXComponentKitMacros/AXScreenMacro.swift @@ -15,6 +15,14 @@ public struct AXScreenMacro: MemberMacro, ExtensionMacro { throw AXScreenMacroError.notAStruct } + let hasExistingIdentifier = declaration.memberBlock.members.contains { member in + guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { return false } + return varDecl.bindings.contains { binding in + binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text == "screenIdentifier" + } + } + guard !hasExistingIdentifier else { return [] } + let typeName = structDecl.name.trimmedDescription let identifier = extractIdentifier(from: node) ?? pascalCaseToKebabCase(typeName) @@ -35,6 +43,7 @@ public struct AXScreenMacro: MemberMacro, ExtensionMacro { guard declaration.as(StructDeclSyntax.self) != nil else { throw AXScreenMacroError.notAStruct } + guard !protocols.isEmpty else { return [] } let extensionDecl: DeclSyntax = "extension \(type.trimmed): AXScreen {}" guard let ext = extensionDecl.as(ExtensionDeclSyntax.self) else { diff --git a/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+FirstDynamicElement.swift b/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+FirstDynamicElement.swift index 8cc35d5..1c83cfa 100644 --- a/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+FirstDynamicElement.swift +++ b/Sources/AXComponentKitTestSupport/Element Querying/AXScreenModel+FirstDynamicElement.swift @@ -80,4 +80,86 @@ public extension AXScreen { let element = try await firstElement(anyOf: path, timeout: timeout, file: file, line: line) element.tap() } + + /// Returns the first element whose accessibility identifier begins with the + /// prefixed dynamic component's identifier prefix, regardless of the specific + /// value suffix. Waits for existence before returning. + /// + /// Use when the writer-side view modifier applied a custom prefix via + /// `automationComponent(_:value:prefix:)` and the exact dynamic value is + /// not known at test time. + /// + /// ```swift + /// let card = try await CatalogScreen.firstElement(anyOf: \.categoryCard, prefix: "featured") + /// card.tap() + /// ``` + /// + /// - Parameters: + /// - path: + /// `KeyPath` relative to `Self` that identifies an `AXDynamicComponent` + /// - prefix: + /// The custom prefix that was supplied to the writer-side view modifier. + /// - timeout: + /// Duration of time that this call should wait for the element to come into existence. + /// The default is 10 seconds. + /// - file: + /// The file to present an error in if a failure occurs. + /// The default is the filename of the test case where you call this function. + /// - line: + /// The line number to present an error on if a failure occurs. + /// The default is the line number of the test case where you call this function. + /// - Returns: + /// The first `XCUIElement` whose identifier begins with the prefixed component prefix, + /// guaranteed to exist when this function returns. + @discardableResult + static func firstElement( + anyOf path: KeyPath>, + prefix: String, + timeout: Measurement = .seconds(10), + file: StaticString = #file, + line: UInt = #line + ) async throws -> XCUIElement where Value: AXIdentifierConvertible { + let component = Self()[keyPath: path] + let prefixedName = [prefix, component.prefix].filter { !$0.isEmpty }.joined(separator: "-") + let predicate = NSPredicate(format: "identifier BEGINSWITH %@", prefixedName) + let element = XCUIApplication() + .descendants(matching: .any) + .matching(predicate) + .firstMatch + let message = "No element found with identifier beginning with: \"\(prefixedName)\"" + return try element.awaitingExistence(timeout: timeout, message, file: file, line: line) + } + + /// Taps the first element whose accessibility identifier begins with the + /// prefixed dynamic component's identifier prefix, regardless of the specific + /// value suffix. Waits for existence before tapping. + /// + /// ```swift + /// try await CatalogScreen.tapFirst(anyOf: \.categoryCard, prefix: "featured") + /// ``` + /// + /// - Parameters: + /// - path: + /// `KeyPath` relative to `Self` that identifies an `AXDynamicComponent` + /// - prefix: + /// The custom prefix that was supplied to the writer-side view modifier. + /// - timeout: + /// Duration of time that this call should wait for the element to come into existence. + /// The default is 10 seconds. + /// - file: + /// The file to present an error in if a failure occurs. + /// The default is the filename of the test case where you call this function. + /// - line: + /// The line number to present an error on if a failure occurs. + /// The default is the line number of the test case where you call this function. + static func tapFirst( + anyOf path: KeyPath>, + prefix: String, + timeout: Measurement = .seconds(10), + file: StaticString = #file, + line: UInt = #line + ) async throws where Value: AXIdentifierConvertible { + let element = try await firstElement(anyOf: path, prefix: prefix, timeout: timeout, file: file, line: line) + element.tap() + } } From b290532ebfc27dc4a843ca6cc966a67605e260d1 Mon Sep 17 00:00:00 2001 From: Chris Stroud Date: Wed, 20 May 2026 15:19:50 -0400 Subject: [PATCH 7/8] Add macro unit tests covering @AXScreen expansion, guards, and kebab-case conversion - 7 tests for pascalCaseToKebabCase: simple, acronyms (UIKit, URLSession, AXScreen), single word, all-uppercase, already-lowercase - 6 tests for macro expansion: basic generation, custom identifier, interpolated string fallback, existing screenIdentifier skip, existing conformance skip, error on class - Dedicated Xcode scheme for running macro tests on macOS --- .../AXComponentKitMacrosTests.xcscheme | 43 +++++ Package.swift | 7 + .../AXScreenMacroTests.swift | 177 ++++++++++++++++++ 3 files changed, 227 insertions(+) create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/AXComponentKitMacrosTests.xcscheme create mode 100644 Tests/AXComponentKitMacrosTests/AXScreenMacroTests.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/AXComponentKitMacrosTests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/AXComponentKitMacrosTests.xcscheme new file mode 100644 index 0000000..217f58a --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/AXComponentKitMacrosTests.xcscheme @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Package.swift b/Package.swift index a026825..fc71a16 100644 --- a/Package.swift +++ b/Package.swift @@ -46,5 +46,12 @@ let package = Package( "AXComponentKitMacros", ] ), + .testTarget( + name: "AXComponentKitMacrosTests", + dependencies: [ + "AXComponentKitMacros", + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + ] + ), ] ) diff --git a/Tests/AXComponentKitMacrosTests/AXScreenMacroTests.swift b/Tests/AXComponentKitMacrosTests/AXScreenMacroTests.swift new file mode 100644 index 0000000..cfbcb25 --- /dev/null +++ b/Tests/AXComponentKitMacrosTests/AXScreenMacroTests.swift @@ -0,0 +1,177 @@ +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import Testing + +@testable import AXComponentKitMacros + +private let testMacros: [String: Macro.Type] = [ + "AXScreen": AXScreenMacro.self, +] + +// MARK: - pascalCaseToKebabCase + +@Suite("PascalCase to kebab-case conversion") +struct PascalCaseToKebabCaseTests { + @Test("Simple PascalCase") + func simplePascalCase() { + #expect(AXScreenMacro.pascalCaseToKebabCase("FirstTabScreen") == "first-tab-screen") + } + + @Test("Leading acronym") + func leadingAcronym() { + #expect(AXScreenMacro.pascalCaseToKebabCase("UIKit") == "ui-kit") + } + + @Test("Multi-letter acronym mid-word") + func midAcronym() { + #expect(AXScreenMacro.pascalCaseToKebabCase("URLSession") == "url-session") + } + + @Test("Two-letter prefix") + func twoLetterPrefix() { + #expect(AXScreenMacro.pascalCaseToKebabCase("AXScreen") == "ax-screen") + } + + @Test("Single word") + func singleWord() { + #expect(AXScreenMacro.pascalCaseToKebabCase("Login") == "login") + } + + @Test("All uppercase") + func allUppercase() { + #expect(AXScreenMacro.pascalCaseToKebabCase("HTTP") == "http") + } + + @Test("Already lowercase") + func alreadyLowercase() { + #expect(AXScreenMacro.pascalCaseToKebabCase("settings") == "settings") + } +} + +// MARK: - Macro expansion + +@Suite("@AXScreen macro expansion") +struct AXScreenMacroExpansionTests { + @Test("Generates screenIdentifier and AXScreen conformance") + func basicExpansion() { + assertMacroExpansion( + """ + @AXScreen + struct LoginScreen { + let usernameField: AXComponent = "login-username" + } + """, + expandedSource: """ + struct LoginScreen { + let usernameField: AXComponent = "login-username" + + static let screenIdentifier = "login-screen" + } + + extension LoginScreen: AXScreen { + } + """, + macros: testMacros + ) + } + + @Test("Uses custom identifier when provided") + func customIdentifier() { + assertMacroExpansion( + """ + @AXScreen(identifier: "my-custom-id") + struct LoginScreen { + } + """, + expandedSource: """ + struct LoginScreen { + + static let screenIdentifier = "my-custom-id" + } + + extension LoginScreen: AXScreen { + } + """, + macros: testMacros + ) + } + + @Test("Falls back to derived name for interpolated identifier") + func interpolatedIdentifierFallback() { + assertMacroExpansion( + #""" + @AXScreen(identifier: "foo\(bar)") + struct LoginScreen { + } + """#, + expandedSource: #""" + struct LoginScreen { + + static let screenIdentifier = "login-screen" + } + + extension LoginScreen: AXScreen { + } + """#, + macros: testMacros + ) + } + + @Test("Skips screenIdentifier when already declared") + func existingScreenIdentifier() { + assertMacroExpansion( + """ + @AXScreen + struct LoginScreen { + static let screenIdentifier = "manual-id" + } + """, + expandedSource: """ + struct LoginScreen { + static let screenIdentifier = "manual-id" + } + + extension LoginScreen: AXScreen { + } + """, + macros: testMacros + ) + } + + @Test("Skips conformance when type already conforms") + func existingConformance() { + assertMacroExpansion( + """ + @AXScreen + struct LoginScreen: AXScreen { + } + """, + expandedSource: """ + struct LoginScreen: AXScreen { + + static let screenIdentifier = "login-screen" + } + """, + macros: testMacros + ) + } + + @Test("Errors when applied to a class") + func errorOnClass() { + assertMacroExpansion( + """ + @AXScreen + class LoginScreen { + } + """, + expandedSource: """ + class LoginScreen { + } + """, + diagnostics: [ + DiagnosticSpec(message: "@AXScreen can only be applied to a struct", line: 1, column: 1), + ], + macros: testMacros + ) + } +} From bc0f73928ae29c2505857350f98e139dbba4c8b4 Mon Sep 17 00:00:00 2001 From: Chris Stroud Date: Wed, 20 May 2026 18:07:51 -0400 Subject: [PATCH 8/8] Restore .accessibilityHidden(true) on ScreenIdentityModifier's Color.clear background Accidentally dropped during the AXIdentifierConvertible refactor. Without it, the screen identifier element relies on an undocumented SwiftUI heuristic to stay out of the VoiceOver tree. --- .../Extensions/App UI/View+ScreenIdentityModifier.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/AXComponentKit/Extensions/App UI/View+ScreenIdentityModifier.swift b/Sources/AXComponentKit/Extensions/App UI/View+ScreenIdentityModifier.swift index 1fa00fa..fcd2a7c 100644 --- a/Sources/AXComponentKit/Extensions/App UI/View+ScreenIdentityModifier.swift +++ b/Sources/AXComponentKit/Extensions/App UI/View+ScreenIdentityModifier.swift @@ -7,6 +7,7 @@ private struct ScreenIdentityModifier: ViewModifier { content.background( Color.clear .accessibilityIdentifier(identifier) + .accessibilityHidden(true) ) } }