diff --git a/HammerTests.podspec b/HammerTests.podspec index fd8137c..50a9bb4 100644 --- a/HammerTests.podspec +++ b/HammerTests.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "HammerTests" - spec.version = "0.16.0" + spec.version = "0.17.0" spec.summary = "iOS touch and keyboard synthesis library for unit tests." spec.description = "Hammer is a touch and keyboard synthesis library for emulating user interaction events. It enables new ways of triggering UI actions in unit tests, replicating a real world environment as much as possible." spec.homepage = "https://github.com/lyft/Hammer" diff --git a/Sources/Hammer/EventGenerator/EventGenerator+Settings.swift b/Sources/Hammer/EventGenerator/EventGenerator+Settings.swift new file mode 100644 index 0000000..c477979 --- /dev/null +++ b/Sources/Hammer/EventGenerator/EventGenerator+Settings.swift @@ -0,0 +1,22 @@ +import Foundation + +extension EventGenerator { + /// Shared setting values for all event generators + public static var settings = Settings() + + /// Shared setting values for all event generators + public struct Settings { + /// The delay to wait after activating the accessibility engine. + public var accessibilityActivateDelay: TimeInterval = 0.02 + + /// The delay to wait after activating the accessibility engine for the first time in a simulator. + public var accessibilityActivateFirstTimeDelay: TimeInterval = 5.0 + + /// The accessibility engine is required for finding accessibility labels. We proactively enable it + /// to avoid issues with the first test case that uses it. + public var forceActivateAccessibilityEngine: Bool = true + + /// If we should wait for animations to complete when an event generator is created. + public var waitForAnimations: Bool = false + } +} diff --git a/Sources/Hammer/EventGenerator/EventGenerator.swift b/Sources/Hammer/EventGenerator/EventGenerator.swift index 61895d9..d3243d7 100644 --- a/Sources/Hammer/EventGenerator/EventGenerator.swift +++ b/Sources/Hammer/EventGenerator/EventGenerator.swift @@ -114,11 +114,25 @@ public final class EventGenerator { public func waitUntilWindowIsReady(timeout: TimeInterval = 3) throws { do { try self.waitUntil(self.isWindowReady, timeout: timeout) + try self.waitUntilAccessibilityActivate() + + if EventGenerator.settings.waitForAnimations { + try self.waitUntilAnimationsAreFinished(timeout: timeout) + } + + try self.waitUntilRunloopIsFlushed(timeout: timeout) } catch { throw HammerError.windowIsNotReadyForInteraction } } + /// Waits until animations are finished. + /// + /// - parameter timeout: The maximum time to wait for the window to be ready. + public func waitUntilAnimationsAreFinished(timeout: TimeInterval) throws { + try self.waitUntil(!self.hasRunningAnimations, timeout: timeout) + } + /// Returns if the window is ready to receive user interaction events public var isWindowReady: Bool { guard !(UIApplication.shared as UIApplicationDeprecated).isIgnoringInteractionEvents @@ -139,9 +153,39 @@ public final class EventGenerator { } } + if let hammerWindow = self.window as? HammerWindow, !hammerWindow.viewControllerHasAppeared { + return false + } + return true } + // Returns if the view or any of its subviews has running animations. + public var hasRunningAnimations: Bool { + // Recursive + func hasRunningAnimations(currentView: UIView) -> Bool { + // If the view is not visible, we do not need to consider it as running animation + guard self.viewIsVisible(currentView) else { + return false + } + + // If there are animations running on the layer, return true + if currentView.layer.animationKeys()?.isEmpty == false { + return true + } + + // Special case for parallax dimming view which happens during some animations + if String(describing: type(of: currentView)) == "_UIParallaxDimmingView" { + return true + } + + // Traverse subviews + return currentView.subviews.contains { hasRunningAnimations(currentView: $0) } + } + + return hasRunningAnimations(currentView: self.window) + } + /// Gets the next event ID to use. Event IDs are global and sequential. /// /// - returns: The next event ID. @@ -180,6 +224,42 @@ public final class EventGenerator { try self.sendMarkerEvent { try? waiter.complete() } try waiter.start() } + + // MARK: - Accessibility initialization + + private var isAccessibilityActivated = false + + private func waitUntilAccessibilityActivate() throws { + guard EventGenerator.settings.forceActivateAccessibilityEngine else { + return + } + + UIApplication.shared.accessibilityActivate() + if self.isAccessibilityActivated { + return + } + + // The first time the accessibility engine is activated in a simulator it needs more time to warm up + // and start producing consistent results, after that only a short delay per test case is enough + let simAccessibilityActivatedKey = "accessibility_activated" + let simAccessibilityActivated = UserDefaults.standard.bool(forKey: simAccessibilityActivatedKey) + if !simAccessibilityActivated { + print("Activating accessibility engine for the first time in this simulator and waiting 5s") + } else { + print("Activating accessibility engine and waiting 0.1s") + } + + try self.wait( + simAccessibilityActivated + ? EventGenerator.settings.accessibilityActivateDelay // Default: 0.02s + : EventGenerator.settings.accessibilityActivateFirstTimeDelay // Default: 5.0s + ) + + self.isAccessibilityActivated = true + if !simAccessibilityActivated { + UserDefaults.standard.set(true, forKey: simAccessibilityActivatedKey) + } + } } // Bypasses deprecation warning for `isIgnoringInteractionEvents` diff --git a/Sources/Hammer/Utilties/HammerWindow.swift b/Sources/Hammer/Utilties/HammerWindow.swift index 62c1266..b321465 100644 --- a/Sources/Hammer/Utilties/HammerWindow.swift +++ b/Sources/Hammer/Utilties/HammerWindow.swift @@ -8,6 +8,10 @@ final class HammerWindow: UIWindow { return .zero } + var viewControllerHasAppeared: Bool { + return self.hammerViewController.hasAppeared + } + init() { super.init(frame: UIScreen.main.bounds) self.rootViewController = self.hammerViewController @@ -41,6 +45,8 @@ private final class HammerViewController: UIViewController { override var shouldAutomaticallyForwardAppearanceMethods: Bool { false } override var prefersStatusBarHidden: Bool { true } + var hasAppeared = false + override func viewDidLoad() { super.viewDidLoad() self.view.backgroundColor = .clear @@ -56,6 +62,16 @@ private final class HammerViewController: UIViewController { ]) } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + self.hasAppeared = true + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + self.hasAppeared = false + } + func presentContained(_ viewController: UIViewController) { viewController.beginAppearanceTransition(true, animated: false) self.addChild(viewController) diff --git a/Sources/Hammer/Utilties/Subviews.swift b/Sources/Hammer/Utilties/Subviews.swift index 5b5365e..966836b 100644 --- a/Sources/Hammer/Utilties/Subviews.swift +++ b/Sources/Hammer/Utilties/Subviews.swift @@ -154,31 +154,7 @@ extension EventGenerator { /// /// - returns: If the view is visible public func viewIsVisible(_ view: UIView, visibility: Visibility = .partial) -> Bool { - guard view.isDescendant(of: self.window) else { - return false - } - - // Recursive - func viewIsVisible(currentView: UIView) -> Bool { - guard !currentView.isHidden && currentView.alpha >= 0.01 else { - return false - } - - guard let superview = currentView.superview else { - return currentView == self.window - } - - if superview.clipsToBounds { - let adjustedBounds = view.convert(view.bounds, to: superview) - guard superview.bounds.isVisible(adjustedBounds, visibility: visibility) else { - return false - } - } - - return viewIsVisible(currentView: superview) - } - - return viewIsVisible(currentView: view) + return view.isVisible(inWindow: self.window, visibility: visibility) } /// Returns if the specified rect is visible. diff --git a/Sources/Hammer/Utilties/UIKit+Extensions.swift b/Sources/Hammer/Utilties/UIKit+Extensions.swift index c9b10d3..4b10e5d 100644 --- a/Sources/Hammer/Utilties/UIKit+Extensions.swift +++ b/Sources/Hammer/Utilties/UIKit+Extensions.swift @@ -75,4 +75,38 @@ extension UIView { var topLevelView: UIView { return self.superview?.topLevelView ?? self } + + /// Returns if the view is visible. + /// + /// - parameter window: The window to check if the view is part of. + /// - parameter visibility: How determine if the view is visible. + /// + /// - returns: If the view is visible + func isVisible(inWindow window: UIWindow, visibility: EventGenerator.Visibility = .partial) -> Bool { + guard self.isDescendant(of: window) else { + return false + } + + // Recursive + func isVisible(currentView: UIView) -> Bool { + guard !currentView.isHidden && currentView.alpha >= 0.01 else { + return false + } + + guard let superview = currentView.superview else { + return currentView == window + } + + if superview.clipsToBounds { + let adjustedBounds = self.convert(self.bounds, to: superview) + guard superview.bounds.isVisible(adjustedBounds, visibility: visibility) else { + return false + } + } + + return isVisible(currentView: superview) + } + + return isVisible(currentView: self) + } } diff --git a/Sources/Hammer/Utilties/Waiting.swift b/Sources/Hammer/Utilties/Waiting.swift index 9f10ad8..7339e2d 100644 --- a/Sources/Hammer/Utilties/Waiting.swift +++ b/Sources/Hammer/Utilties/Waiting.swift @@ -239,4 +239,29 @@ extension EventGenerator { try self.waitUntil(self.viewIsHittable(self.mainView), timeout: timeout, checkInterval: checkInterval) } + + // MARK: - System waiting + + /// Waits for the main runloop is flushed and all scheduled tasks have executed. + /// + /// - parameter timeout: The maximum time to wait. + /// + /// - throws: An error if the runloop is not flushed within the specified time. + public func waitUntilRunloopIsFlushed(timeout: TimeInterval) throws { + var errorCompleting: Error? + + let waiter = Waiter(timeout: timeout) + DispatchQueue.main.async { + do { + try waiter.complete() + } catch { + errorCompleting = error + } + } + + try waiter.start() + if let errorCompleting { + throw errorCompleting + } + } }