Skip to content

Commit c9c36b7

Browse files
Reduce flakiness by improving some checks to ensure the app is ready. (#61)
Changes: 1. Ensures the runloop is fully flushed before continuing 2. Force enables the accessibility engine and waits for it to load if it is the first time loading on a device 3. Improve detection for view did appear to hopefully detect when the window's root view controller has completed appearance better and reduce issues from slow sim boot 4. Check for unfinished animations with the assumption that we can use those for transitions. 5. Adds a new settings object to be able to configure some of these options
1 parent 536170a commit c9c36b7

File tree

7 files changed

+179
-26
lines changed

7 files changed

+179
-26
lines changed

HammerTests.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Pod::Spec.new do |spec|
22
spec.name = "HammerTests"
3-
spec.version = "0.16.0"
3+
spec.version = "0.17.0"
44
spec.summary = "iOS touch and keyboard synthesis library for unit tests."
55
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."
66
spec.homepage = "https://github.com/lyft/Hammer"
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import Foundation
2+
3+
extension EventGenerator {
4+
/// Shared setting values for all event generators
5+
public static var settings = Settings()
6+
7+
/// Shared setting values for all event generators
8+
public struct Settings {
9+
/// The delay to wait after activating the accessibility engine.
10+
public var accessibilityActivateDelay: TimeInterval = 0.02
11+
12+
/// The delay to wait after activating the accessibility engine for the first time in a simulator.
13+
public var accessibilityActivateFirstTimeDelay: TimeInterval = 5.0
14+
15+
/// The accessibility engine is required for finding accessibility labels. We proactively enable it
16+
/// to avoid issues with the first test case that uses it.
17+
public var forceActivateAccessibilityEngine: Bool = true
18+
19+
/// If we should wait for animations to complete when an event generator is created.
20+
public var waitForAnimations: Bool = false
21+
}
22+
}

Sources/Hammer/EventGenerator/EventGenerator.swift

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,25 @@ public final class EventGenerator {
114114
public func waitUntilWindowIsReady(timeout: TimeInterval = 3) throws {
115115
do {
116116
try self.waitUntil(self.isWindowReady, timeout: timeout)
117+
try self.waitUntilAccessibilityActivate()
118+
119+
if EventGenerator.settings.waitForAnimations {
120+
try self.waitUntilAnimationsAreFinished(timeout: timeout)
121+
}
122+
123+
try self.waitUntilRunloopIsFlushed(timeout: timeout)
117124
} catch {
118125
throw HammerError.windowIsNotReadyForInteraction
119126
}
120127
}
121128

129+
/// Waits until animations are finished.
130+
///
131+
/// - parameter timeout: The maximum time to wait for the window to be ready.
132+
public func waitUntilAnimationsAreFinished(timeout: TimeInterval) throws {
133+
try self.waitUntil(!self.hasRunningAnimations, timeout: timeout)
134+
}
135+
122136
/// Returns if the window is ready to receive user interaction events
123137
public var isWindowReady: Bool {
124138
guard !(UIApplication.shared as UIApplicationDeprecated).isIgnoringInteractionEvents
@@ -139,9 +153,39 @@ public final class EventGenerator {
139153
}
140154
}
141155

156+
if let hammerWindow = self.window as? HammerWindow, !hammerWindow.viewControllerHasAppeared {
157+
return false
158+
}
159+
142160
return true
143161
}
144162

163+
// Returns if the view or any of its subviews has running animations.
164+
public var hasRunningAnimations: Bool {
165+
// Recursive
166+
func hasRunningAnimations(currentView: UIView) -> Bool {
167+
// If the view is not visible, we do not need to consider it as running animation
168+
guard self.viewIsVisible(currentView) else {
169+
return false
170+
}
171+
172+
// If there are animations running on the layer, return true
173+
if currentView.layer.animationKeys()?.isEmpty == false {
174+
return true
175+
}
176+
177+
// Special case for parallax dimming view which happens during some animations
178+
if String(describing: type(of: currentView)) == "_UIParallaxDimmingView" {
179+
return true
180+
}
181+
182+
// Traverse subviews
183+
return currentView.subviews.contains { hasRunningAnimations(currentView: $0) }
184+
}
185+
186+
return hasRunningAnimations(currentView: self.window)
187+
}
188+
145189
/// Gets the next event ID to use. Event IDs are global and sequential.
146190
///
147191
/// - returns: The next event ID.
@@ -180,6 +224,42 @@ public final class EventGenerator {
180224
try self.sendMarkerEvent { try? waiter.complete() }
181225
try waiter.start()
182226
}
227+
228+
// MARK: - Accessibility initialization
229+
230+
private var isAccessibilityActivated = false
231+
232+
private func waitUntilAccessibilityActivate() throws {
233+
guard EventGenerator.settings.forceActivateAccessibilityEngine else {
234+
return
235+
}
236+
237+
UIApplication.shared.accessibilityActivate()
238+
if self.isAccessibilityActivated {
239+
return
240+
}
241+
242+
// The first time the accessibility engine is activated in a simulator it needs more time to warm up
243+
// and start producing consistent results, after that only a short delay per test case is enough
244+
let simAccessibilityActivatedKey = "accessibility_activated"
245+
let simAccessibilityActivated = UserDefaults.standard.bool(forKey: simAccessibilityActivatedKey)
246+
if !simAccessibilityActivated {
247+
print("Activating accessibility engine for the first time in this simulator and waiting 5s")
248+
} else {
249+
print("Activating accessibility engine and waiting 0.1s")
250+
}
251+
252+
try self.wait(
253+
simAccessibilityActivated
254+
? EventGenerator.settings.accessibilityActivateDelay // Default: 0.02s
255+
: EventGenerator.settings.accessibilityActivateFirstTimeDelay // Default: 5.0s
256+
)
257+
258+
self.isAccessibilityActivated = true
259+
if !simAccessibilityActivated {
260+
UserDefaults.standard.set(true, forKey: simAccessibilityActivatedKey)
261+
}
262+
}
183263
}
184264

185265
// Bypasses deprecation warning for `isIgnoringInteractionEvents`

Sources/Hammer/Utilties/HammerWindow.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ final class HammerWindow: UIWindow {
88
return .zero
99
}
1010

11+
var viewControllerHasAppeared: Bool {
12+
return self.hammerViewController.hasAppeared
13+
}
14+
1115
init() {
1216
super.init(frame: UIScreen.main.bounds)
1317
self.rootViewController = self.hammerViewController
@@ -41,6 +45,8 @@ private final class HammerViewController: UIViewController {
4145
override var shouldAutomaticallyForwardAppearanceMethods: Bool { false }
4246
override var prefersStatusBarHidden: Bool { true }
4347

48+
var hasAppeared = false
49+
4450
override func viewDidLoad() {
4551
super.viewDidLoad()
4652
self.view.backgroundColor = .clear
@@ -56,6 +62,16 @@ private final class HammerViewController: UIViewController {
5662
])
5763
}
5864

65+
override func viewDidAppear(_ animated: Bool) {
66+
super.viewDidAppear(animated)
67+
self.hasAppeared = true
68+
}
69+
70+
override func viewWillDisappear(_ animated: Bool) {
71+
super.viewWillDisappear(animated)
72+
self.hasAppeared = false
73+
}
74+
5975
func presentContained(_ viewController: UIViewController) {
6076
viewController.beginAppearanceTransition(true, animated: false)
6177
self.addChild(viewController)

Sources/Hammer/Utilties/Subviews.swift

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -154,31 +154,7 @@ extension EventGenerator {
154154
///
155155
/// - returns: If the view is visible
156156
public func viewIsVisible(_ view: UIView, visibility: Visibility = .partial) -> Bool {
157-
guard view.isDescendant(of: self.window) else {
158-
return false
159-
}
160-
161-
// Recursive
162-
func viewIsVisible(currentView: UIView) -> Bool {
163-
guard !currentView.isHidden && currentView.alpha >= 0.01 else {
164-
return false
165-
}
166-
167-
guard let superview = currentView.superview else {
168-
return currentView == self.window
169-
}
170-
171-
if superview.clipsToBounds {
172-
let adjustedBounds = view.convert(view.bounds, to: superview)
173-
guard superview.bounds.isVisible(adjustedBounds, visibility: visibility) else {
174-
return false
175-
}
176-
}
177-
178-
return viewIsVisible(currentView: superview)
179-
}
180-
181-
return viewIsVisible(currentView: view)
157+
return view.isVisible(inWindow: self.window, visibility: visibility)
182158
}
183159

184160
/// Returns if the specified rect is visible.

Sources/Hammer/Utilties/UIKit+Extensions.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,38 @@ extension UIView {
7575
var topLevelView: UIView {
7676
return self.superview?.topLevelView ?? self
7777
}
78+
79+
/// Returns if the view is visible.
80+
///
81+
/// - parameter window: The window to check if the view is part of.
82+
/// - parameter visibility: How determine if the view is visible.
83+
///
84+
/// - returns: If the view is visible
85+
func isVisible(inWindow window: UIWindow, visibility: EventGenerator.Visibility = .partial) -> Bool {
86+
guard self.isDescendant(of: window) else {
87+
return false
88+
}
89+
90+
// Recursive
91+
func isVisible(currentView: UIView) -> Bool {
92+
guard !currentView.isHidden && currentView.alpha >= 0.01 else {
93+
return false
94+
}
95+
96+
guard let superview = currentView.superview else {
97+
return currentView == window
98+
}
99+
100+
if superview.clipsToBounds {
101+
let adjustedBounds = self.convert(self.bounds, to: superview)
102+
guard superview.bounds.isVisible(adjustedBounds, visibility: visibility) else {
103+
return false
104+
}
105+
}
106+
107+
return isVisible(currentView: superview)
108+
}
109+
110+
return isVisible(currentView: self)
111+
}
78112
}

Sources/Hammer/Utilties/Waiting.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,4 +239,29 @@ extension EventGenerator {
239239
try self.waitUntil(self.viewIsHittable(self.mainView),
240240
timeout: timeout, checkInterval: checkInterval)
241241
}
242+
243+
// MARK: - System waiting
244+
245+
/// Waits for the main runloop is flushed and all scheduled tasks have executed.
246+
///
247+
/// - parameter timeout: The maximum time to wait.
248+
///
249+
/// - throws: An error if the runloop is not flushed within the specified time.
250+
public func waitUntilRunloopIsFlushed(timeout: TimeInterval) throws {
251+
var errorCompleting: Error?
252+
253+
let waiter = Waiter(timeout: timeout)
254+
DispatchQueue.main.async {
255+
do {
256+
try waiter.complete()
257+
} catch {
258+
errorCompleting = error
259+
}
260+
}
261+
262+
try waiter.start()
263+
if let errorCompleting {
264+
throw errorCompleting
265+
}
266+
}
242267
}

0 commit comments

Comments
 (0)